@jossuealcala/madre 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/package.json +1 -1
- package/public/app.js +57 -27
- package/public/es.js +12 -2
- package/public/styles.css +4 -0
- package/public/syntax.js +56 -2
- package/src/room/control.mjs +10 -3
- package/src/room.mjs +3 -0
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.5.2 · Sin publicar
|
|
8
|
+
|
|
9
|
+
Tres reportes de la 0.5.1, y la razón por la que ninguno lo atrapó una prueba. Arreglos: nada cambia modos, módulos, agentes ni a dónde sale nada.
|
|
10
|
+
|
|
11
|
+
### Los tres reportes de 0.5.1
|
|
12
|
+
|
|
13
|
+
- **Ningún comando de módulo funcionaba al abrir la sala** (#8). `/git`, `/ahp`, `/pendientes`: todos contestaban «comando desconocido» con el módulo encendido, porque la única llamada que le pide al servidor la lista se había caído **dentro** del bloque que cierra una instalación en vivo. Hasta que ocurriera una instalación, el compositor no conocía ni un comando de módulo. Ahora se pide al cargar, y también cuando un módulo entra, sale o cambia de interruptor — que es cuando cambia lo que el compositor acepta.
|
|
14
|
+
- **Las franjas de CREATE del historial no se dibujaban** (#9). `renderLease` leía `CAP_LABELS`, declarada cuatrocientas líneas más abajo; el historial se repite antes de llegar ahí, el `try` de cada renderizador se tragaba el error, y la franja simplemente no aparecía. En vivo salía bien, que es lo que lo hizo invisible.
|
|
15
|
+
- **«@CODEX PUEDE CREA»** (#10). La frase ya se traducía entera, pero `{what}` traía las etiquetas de la ficha, que son imperativos para un botón suelto. Dentro de una oración hace falta el infinitivo, así que ahora hay dos juegos: el de la ficha y el de la frase. Con ello, «instalado por **ti**» en vez de «por you» —el servidor manda `you` y el `??` nunca alcanzaba la traducción—, `@madre en la sala` en la ficha de OLLAMA, y la instrucción de `/module` dice ahora qué poner en `vendor`: sin decirlo, Codex escribió `'you'` y la ficha lo mostró como autor.
|
|
16
|
+
|
|
17
|
+
### Por qué ninguno de los tres lo atrapó una prueba
|
|
18
|
+
|
|
19
|
+
- La prueba de humo reproduce un transcript real y falla si algo revienta al dibujarlo. Pero ese transcript tenía **ocho** de los **sesenta y un** tipos de evento que la sala sabe dibujar: los otros cincuenta y tres no los ejecutaba nadie. Por ahí pasaron tres constantes declaradas al fondo del archivo y leídas durante el arranque — y como cada renderizador está envuelto en un `try`, el error se tragaba, la franja no aparecía, y la suite seguía verde.
|
|
20
|
+
- Ahora entra un evento sintético **de cada tipo** detrás del transcript real. Un campo que a mis payloads les falte es culpa de la prueba y solo se exige sobre el transcript de verdad; lo que no se perdona nunca, en ningún tipo de evento, es que un renderizador lea algo que todavía no existe.
|
|
21
|
+
- Encontró una quinta en su primer intento: `modules`, que cuatro renderizadores de instalación leían durante el historial. Cada instalación y cada baja de módulo jamás aparecieron al recargar.
|
|
22
|
+
|
|
23
|
+
## 0.5.1 · 2026-09-28
|
|
24
|
+
|
|
25
|
+
Cuatro cosas que salieron del QA de 0.5.0 el mismo día que se publicó. Ninguna cambia modos, módulos, agentes ni a dónde sale nada: son arreglos.
|
|
26
|
+
|
|
27
|
+
### Los cuatro reportes de 0.5.0
|
|
28
|
+
|
|
29
|
+
- **`/module` nunca llegaba a su tarjeta de instalación** (#5). MADRE le dice al agente en qué carpeta escribir el borrador —`.pulse/out/<turno>/`— y después revertía esa escritura por caer en zona prohibida: `.pulse/` lo es. La sala nombraba un sitio y castigaba por usarlo. Aquí nunca se vio porque **este** repositorio ignora `.pulse/` y git jamás reportó esos archivos; en un proyecto que no lo ignora, la función entera no existía. Ahora la carpeta de borrador de un turno no es zona prohibida **para ese turno**, y solo para ese: todo lo demás bajo `.pulse` se sigue revirtiendo, y hay una prueba con las dos mitades.
|
|
30
|
+
- **El resaltador tomaba el `//` de un regex como comentario** (#6). `/^https?:\/\//` dejaba gris el resto de la línea. Un literal tiene que ser uno de los candidatos que el escáner considera, no algo que revise después — si salta primero al `//`, nunca se entera de que había un literal abierto. Y el error contrario sería peor, así que `a / b / c` sigue siendo una división: una barra abre un literal solo donde puede empezar un valor, y una que no cierra en su línea era división desde el principio.
|
|
31
|
+
- **Dos textos en inglés** (#7). El vendor de `@madre` —«the room · local»— nunca pasó por el catálogo, y quedó a la vista cuando el menú de revisar empezó a mostrarlo. Y la franja de CREATE decía `@CODEX MAY CREA, GENERA IMÁGENES`: los verbos traducidos dentro de una frase que no. Una frase se traduce entera o no se traduce.
|
|
32
|
+
- **El centinela deja de archivar lo que MU/TH/UR ya explica** (#4). El reporte que llegó al repositorio era `Reading additional input from stdin...`, que no es una falla sino un aviso de Codex — llegó ahí porque MADRE entregaba lo último de stderr como si fuera la causa. Desde 0.4.1 el catálogo lo conoce (`MU-002`) y el centinela no archiva lo que el catálogo explica, así que se explica en la sala en vez de enviárselo al autor. Hay una prueba con el texto exacto de ese reporte.
|
|
33
|
+
- De paso, la tercera zona muerta temporal de la semana: el mapa de vendors quedó declarado al fondo del archivo y la fila de agentes se pinta durante el arranque. **Esta vez la atrapó la prueba de humo**, que es para lo que se reforzó.
|
|
34
|
+
|
|
7
35
|
## 0.5.0 · 2026-09-28
|
|
8
36
|
|
|
9
37
|
**Por qué menor y no parche.** Cambia a dónde sale lo que sale de tu máquina —el colector vive ahora bajo `madre.run`— y, sobre todo, **puede impedir que algo salga**: si tienes `OPENAI_BASE_URL` o `ANTHROPIC_BASE_URL` apuntando a un gateway, esos turnos quedan detenidos hasta que quites la variable o autorices ese endpoint con `PULSE_ALLOW_CUSTOM_AGENT_ENDPOINTS`. Es el arreglo correcto, pero un número de parche se lee como «actualiza sin mirar», y este no lo es. El ledger y la memoria de 0.4.x se siguen leyendo igual.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jossuealcala/madre",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "MADRE · one local room where the AI coding agents already on your machine (Codex, Claude Code, Gemini CLI, OpenCode) work on a project together, with a shared memory every one of them recalls and a local @madre that speaks for it. Read-only by default, per-message permission modes up to a checkpointed CONTROL. Nothing leaves your machine on its own.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
package/public/app.js
CHANGED
|
@@ -141,6 +141,40 @@ const failureKey = (failure) => `${failure.time ?? ''}|${failure.agent ?? ''}|${
|
|
|
141
141
|
const saveDismissed = () => { try { localStorage.setItem('pulse.mother.dismissed', JSON.stringify([...dismissed].slice(-400))); } catch { /* no storage */ } };
|
|
142
142
|
const openFailures = () => state.failures.filter((failure) => !failure.recovered && !dismissed.has(failureKey(failure)));
|
|
143
143
|
|
|
144
|
+
// Every other vendor is a company and a company is called the same everywhere. @madre's is a
|
|
145
|
+
// sentence, so it is said in the room's language — declared, not computed, so both guards see it.
|
|
146
|
+
const VENDOR_WORDS = { 'the room · local': t('the room · local'), local: t('local') };
|
|
147
|
+
const vendorWord = (id) => { const said = brandOf(id).vendor ?? ''; return VENDOR_WORDS[said] ?? said; };
|
|
148
|
+
|
|
149
|
+
// What an agent can do, said two ways. On its card each one is a tag — a word standing alone —
|
|
150
|
+
// and inside a sentence it has to conjugate: «puede crea» was the button's imperative dropped
|
|
151
|
+
// into a phrase that needed an infinitive. Declared up here because the transcript is replayed at
|
|
152
|
+
// the top of this file, long before half of it has run: `renderLease` reached for these while
|
|
153
|
+
// they were still in their dead zone, the try around it swallowed the error, and every CREATE
|
|
154
|
+
// strip in the history simply never appeared.
|
|
155
|
+
const CAP_LABELS = { read: t('read'), imageIn: t('image in'), write: t('create'), imageGen: t('image gen'), web: t('web') };
|
|
156
|
+
const CAP_VERBS = { read: t('read the project'), imageIn: t('take images'), write: t('create files'), imageGen: t('generate images'), web: t('reach the web') };
|
|
157
|
+
const capVerb = (scope) => CAP_VERBS[scope] ?? CAP_LABELS[scope] ?? scope;
|
|
158
|
+
|
|
159
|
+
// MODULES' own handles. Up here with the rest of the module's state because the transcript is
|
|
160
|
+
// replayed near the top of this file and an installation line in the history writes to it: four
|
|
161
|
+
// renderers reached for this while it was still in its dead zone, and every install and removal
|
|
162
|
+
// ever recorded vanished from the history without a word.
|
|
163
|
+
const modules = {
|
|
164
|
+
dialog: document.querySelector('#modules'),
|
|
165
|
+
button: document.querySelector('#modules-button'),
|
|
166
|
+
count: document.querySelector('#modules-count'),
|
|
167
|
+
close: document.querySelector('#modules-close'),
|
|
168
|
+
list: document.querySelector('#modules-list'),
|
|
169
|
+
add: document.querySelector('#modules-add'),
|
|
170
|
+
file: document.querySelector('#modules-file'),
|
|
171
|
+
note: document.querySelector('#modules-note'),
|
|
172
|
+
items: [],
|
|
173
|
+
installing: null,
|
|
174
|
+
logs: new Map(), // id -> array of lines
|
|
175
|
+
confirming: null,
|
|
176
|
+
};
|
|
177
|
+
|
|
144
178
|
// Which agent last wrote to a path, taken from what the room already recorded: the artifacts a
|
|
145
179
|
// creation lease produced, and the files a CONTROL turn changed. Nothing new is stored and nothing
|
|
146
180
|
// is inferred from the filesystem — if MADRE did not see it happen, the file carries no mark.
|
|
@@ -368,7 +402,7 @@ function showViewerMenu(x, y) {
|
|
|
368
402
|
item.type = 'button';
|
|
369
403
|
// `@codex Codex` says one thing twice. The handle is the name; beside it goes only what the
|
|
370
404
|
// handle does not already tell you — the vendor, or why it cannot take this.
|
|
371
|
-
const aside = agent.ready ? (
|
|
405
|
+
const aside = agent.ready ? vendorWord(agent.id) : t('no session');
|
|
372
406
|
item.append(el('b', null, `@${agent.id}`), el('span', null, aside));
|
|
373
407
|
item.disabled = !agent.ready;
|
|
374
408
|
item.addEventListener('click', () => reviewWith(agent.id));
|
|
@@ -774,7 +808,7 @@ function renderAgents() {
|
|
|
774
808
|
status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline',
|
|
775
809
|
});
|
|
776
810
|
node.title = [
|
|
777
|
-
`${agent.label}${
|
|
811
|
+
`${agent.label}${vendorWord(agent.id) ? ` · ${vendorWord(agent.id)}` : ''}`,
|
|
778
812
|
agent.ready ? t('Ready') : agent.detected ? t('Detected, adapter pending') : t('Not installed'),
|
|
779
813
|
agent.version ? agent.version : null,
|
|
780
814
|
official === null
|
|
@@ -1144,7 +1178,7 @@ function bridgeCard(agent) {
|
|
|
1144
1178
|
head.append(avatar(agent.id, { size: 30, status: stage === 'ready' ? 'ready' : agent.detected ? 'detected' : 'offline' }));
|
|
1145
1179
|
const title = el('div', 'title');
|
|
1146
1180
|
title.append(el('b', null, agent.label));
|
|
1147
|
-
title.append(el('span', 'vendor',
|
|
1181
|
+
title.append(el('span', 'vendor', vendorWord(agent.id)));
|
|
1148
1182
|
head.append(title);
|
|
1149
1183
|
head.append(el('span', `state ${stage}`, stage === 'missing' ? t('NOT INSTALLED') : stage === 'inert' ? t('NO ADAPTER') : stage === 'route-blocked' ? t('ROUTE BLOCKED') : stage === 'ready' ? t('READY') : t('SIGNED OUT')));
|
|
1150
1184
|
card.append(head);
|
|
@@ -1503,7 +1537,7 @@ function showReplyMenu(source, x, y) {
|
|
|
1503
1537
|
item.type = 'button';
|
|
1504
1538
|
// The same twice-said name as the viewer's menu: the handle IS the name. Beside it goes only
|
|
1505
1539
|
// what the handle does not carry — that this is the one who just spoke, its vendor, or why not.
|
|
1506
|
-
const aside = !agent.ready ? t('no session') : agent.id === source.sender ? t('the same agent') : (
|
|
1540
|
+
const aside = !agent.ready ? t('no session') : agent.id === source.sender ? t('the same agent') : vendorWord(agent.id);
|
|
1507
1541
|
item.append(el('b', null, `@${agent.id}`), el('span', null, aside));
|
|
1508
1542
|
item.disabled = !agent.ready;
|
|
1509
1543
|
item.addEventListener('click', () => replyWith(agent.id, source));
|
|
@@ -1730,8 +1764,8 @@ function renderModuleInstalledOrRemoved(event) {
|
|
|
1730
1764
|
const { name, origin, by } = event.payload;
|
|
1731
1765
|
const node = el('div', 'system module-proposed');
|
|
1732
1766
|
node.append(t('module · '), el('b', 'who', name), event.type === 'extension.installed'
|
|
1733
|
-
? t(' installed for {where} by {who} · switch it on in MODULES', { where: origin === 'project' ? t('this project') : t('every room'), who: by
|
|
1734
|
-
: t(' removed by {who}', { who: by
|
|
1767
|
+
? t(' installed for {where} by {who} · switch it on in MODULES', { where: origin === 'project' ? t('this project') : t('every room'), who: !by || by === 'you' ? t('you') : `@${by}` })
|
|
1768
|
+
: t(' removed by {who}', { who: !by || by === 'you' ? t('you') : `@${by}` }));
|
|
1735
1769
|
return node;
|
|
1736
1770
|
}
|
|
1737
1771
|
|
|
@@ -2156,7 +2190,10 @@ function renderLease(event) {
|
|
|
2156
2190
|
node.style.setProperty('--agent', agentColor(agent));
|
|
2157
2191
|
const { delegated = false, scratchDir = null, grantedBy = null } = event.payload;
|
|
2158
2192
|
node.append(el('b', null, standing ? t('default #2 · ') : escalated ? `${t('#2 granted on request')}${escalated === 'plan' ? t(' · whole plan') : ''} · ` : delegated ? t('#2 by @{who} · ', { who: grantedBy }) : t('create · ')));
|
|
2159
|
-
|
|
2193
|
+
// The verbs were translated and the sentence holding them was not: «@CODEX MAY CREA, GENERA
|
|
2194
|
+
// IMÁGENES». A sentence is translated whole or it is not translated.
|
|
2195
|
+
const can = scopes.map(capVerb).join(', ');
|
|
2196
|
+
node.append(`${t('@{agent} may {what}', { agent, what: can || t('create files') })}${unavailable.length ? t(' (cannot {what})', { what: unavailable.map(capVerb).join(', ') }) : ''} `);
|
|
2160
2197
|
if (outDir === '.' || !outDir) {
|
|
2161
2198
|
node.append(t('anywhere in the project · existing files stay untouched'));
|
|
2162
2199
|
if (scratchDir) { node.append(t(' · scratch ')); const link = el('a', 'file-link', scratchDir); link.href = '#'; link.addEventListener('click', (ev) => { ev.preventDefault(); }); node.append(link); }
|
|
@@ -2384,6 +2421,8 @@ function renderEventNode(event) {
|
|
|
2384
2421
|
}
|
|
2385
2422
|
case 'room.settings': return;
|
|
2386
2423
|
case 'extension.toggled':
|
|
2424
|
+
// A module switched on or off changes what the composer accepts.
|
|
2425
|
+
if (!replaying) void refreshCommands();
|
|
2387
2426
|
if (event.payload.id === 'ash') syncAshUI(Boolean(event.payload.enabled));
|
|
2388
2427
|
if (event.payload.id === 'ripley') { state.ripley = Boolean(event.payload.enabled); syncViewerMode(); }
|
|
2389
2428
|
if (!replaying) void refreshModules();
|
|
@@ -2402,7 +2441,7 @@ function renderEventNode(event) {
|
|
|
2402
2441
|
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; }
|
|
2403
2442
|
case 'privacy.redacted': node = renderPrivacy(event); break;
|
|
2404
2443
|
case 'module.proposed': node = renderModuleProposed(event); break;
|
|
2405
|
-
case 'extension.installed': case 'extension.removed': node = renderModuleInstalledOrRemoved(event); break;
|
|
2444
|
+
case 'extension.installed': case 'extension.removed': if (!replaying) void refreshCommands(); node = renderModuleInstalledOrRemoved(event); break;
|
|
2406
2445
|
case 'privacy.purged': node = renderPrivacy(event); break;
|
|
2407
2446
|
case 'privacy.warning': if (!replaying) toast(t('MU/TH/UR › your message carries {n} private {term}. Agents will read it as you wrote it; their replies are guarded.', { n: event.payload.hits, term: event.payload.hits === 1 ? t('term') : t('terms') })); return;
|
|
2408
2447
|
case 'memory.forgotten': node = renderForgotten(event); break;
|
|
@@ -2888,6 +2927,10 @@ if (!els.target.options.length) els.target.add(new Option(t('No agent ready'), '
|
|
|
2888
2927
|
renderAgents();
|
|
2889
2928
|
renderPicker();
|
|
2890
2929
|
renderOnboarding();
|
|
2930
|
+
// What the composer will accept. A module's commands live on the server, so the page has to ask:
|
|
2931
|
+
// this call had slipped inside the branch that ends a live installation, and until one happened
|
|
2932
|
+
// `/git` and every module command answered «unknown» in a room that had them enabled all along.
|
|
2933
|
+
void refreshCommands();
|
|
2891
2934
|
for (const event of initial.events) renderEvent(event);
|
|
2892
2935
|
scrollToEnd();
|
|
2893
2936
|
matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { applyTheme(themeMode); renderAgents(); renderPicker(); });
|
|
@@ -3179,7 +3222,9 @@ async function runSlashCommand(text) {
|
|
|
3179
3222
|
return {
|
|
3180
3223
|
handled: false,
|
|
3181
3224
|
target,
|
|
3182
|
-
|
|
3225
|
+
// The card shows `vendor` under the name, so the instruction has to say what it is for.
|
|
3226
|
+
// Left unsaid, Codex wrote `vendor: 'you'` and the room printed it as the author.
|
|
3227
|
+
text: t('Create a MADRE module for this request: {request} Write exactly one <id>.module.mjs file for review. Set vendor to whoever wrote it — the human\'s name or team if you know it, otherwise leave it out; never the word "you". Do not install it; MADRE will show the human an install card.', { request: rest }),
|
|
3183
3228
|
};
|
|
3184
3229
|
}
|
|
3185
3230
|
return { handled: false, text: rest, target };
|
|
@@ -3815,20 +3860,6 @@ mother.dialog.addEventListener('close', () => clearTimeout(mother.bootTimer));
|
|
|
3815
3860
|
|
|
3816
3861
|
/* ---------- MODULES: optional integrations installed by their own tools ---------- */
|
|
3817
3862
|
|
|
3818
|
-
const modules = {
|
|
3819
|
-
dialog: document.querySelector('#modules'),
|
|
3820
|
-
button: document.querySelector('#modules-button'),
|
|
3821
|
-
count: document.querySelector('#modules-count'),
|
|
3822
|
-
close: document.querySelector('#modules-close'),
|
|
3823
|
-
list: document.querySelector('#modules-list'),
|
|
3824
|
-
add: document.querySelector('#modules-add'),
|
|
3825
|
-
file: document.querySelector('#modules-file'),
|
|
3826
|
-
note: document.querySelector('#modules-note'),
|
|
3827
|
-
items: [],
|
|
3828
|
-
installing: null,
|
|
3829
|
-
logs: new Map(), // id -> array of lines
|
|
3830
|
-
confirming: null,
|
|
3831
|
-
};
|
|
3832
3863
|
|
|
3833
3864
|
function renderModuleEvent(event) {
|
|
3834
3865
|
const { id, name, command, platforms = [], ok, code, error, status, problems = [] } = event.payload;
|
|
@@ -3848,7 +3879,7 @@ function renderModuleEvent(event) {
|
|
|
3848
3879
|
: `${t('{name} install failed', { name })}${error ? ` · ${error}` : code !== undefined ? t(' · exit {code}', { code }) : ''}`);
|
|
3849
3880
|
modules.installing = null;
|
|
3850
3881
|
void refreshModules();
|
|
3851
|
-
void refreshCommands();
|
|
3882
|
+
void refreshCommands();
|
|
3852
3883
|
}
|
|
3853
3884
|
state.lastSender = null;
|
|
3854
3885
|
return node;
|
|
@@ -4371,7 +4402,7 @@ function builtinCard(item) {
|
|
|
4371
4402
|
for (const [role, model, present, label] of [
|
|
4372
4403
|
['embeddings', item.recommended?.embed ?? 'nomic-embed-text', Boolean(info.embedModel), t('EMBEDDINGS')],
|
|
4373
4404
|
['archivist', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), t('ARCHIVIST')],
|
|
4374
|
-
['agent', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), '@
|
|
4405
|
+
['agent', item.recommended?.chat ?? 'qwen2.5:3b', Boolean(info.chatModel), t('@madre in the room')],
|
|
4375
4406
|
]) {
|
|
4376
4407
|
if (present) {
|
|
4377
4408
|
const box = el('label', 'toggle');
|
|
@@ -4672,7 +4703,6 @@ function trackStats(event) {
|
|
|
4672
4703
|
|
|
4673
4704
|
function fmtMs(ms) { return ms === null ? '—' : ms < 1000 ? `${ms}ms` : `${Math.round(ms / 1000)}s`; }
|
|
4674
4705
|
|
|
4675
|
-
const CAP_LABELS = { read: t('read'), imageIn: t('image in'), write: t('create'), imageGen: t('image gen'), web: t('web') };
|
|
4676
4706
|
function capabilityBadges(id) {
|
|
4677
4707
|
const caps = state.capabilities[id] ?? {};
|
|
4678
4708
|
const wrap = el('div', 'caps');
|
|
@@ -4699,7 +4729,7 @@ function openAgentPop(id, anchor) {
|
|
|
4699
4729
|
const head = el('div', 'head');
|
|
4700
4730
|
head.append(avatar(id, { size: 24 }));
|
|
4701
4731
|
head.append(el('b', null, agent.label));
|
|
4702
|
-
head.append(el('span', 'vendor',
|
|
4732
|
+
head.append(el('span', 'vendor', vendorWord(id)));
|
|
4703
4733
|
pop.append(head);
|
|
4704
4734
|
const big = el('div', 'big', official === null ? (formatTokens(agent.tokens ?? 0) || '0') : `${Math.round(official)}%`);
|
|
4705
4735
|
big.append(el('small', null, official === null ? t('budget tokens · local 5h window') : t("of the provider's {window} limit", { window: fmtWindow(agent.officialWindows?.primary?.windowMinutes ?? 300) }) + (agent.officialResetAt ? t(' · resets {when}', { when: fmtReset(agent.officialResetAt) }) : t(' · window reset'))));
|
package/public/es.js
CHANGED
|
@@ -1805,6 +1805,16 @@ export const ES = {
|
|
|
1805
1805
|
'/module writes a file, so it needs #2 CREATE. Your request is still in the composer.':
|
|
1806
1806
|
'/module escribe un archivo, así que necesita #2 CREATE. Tu petición sigue en el compositor.',
|
|
1807
1807
|
'ARM #2 CREATE': 'ARMAR #2 CREATE',
|
|
1808
|
+
// Lo que un agente puede hacer, dicho dentro de una frase: infinitivo, no el imperativo de
|
|
1809
|
+
// la etiqueta suelta de su ficha. «puede crea» era el botón metido en una oración.
|
|
1810
|
+
'read the project': 'leer el proyecto',
|
|
1811
|
+
'take images': 'recibir imágenes',
|
|
1812
|
+
'generate images': 'generar imágenes',
|
|
1813
|
+
'reach the web': 'alcanzar la web',
|
|
1814
|
+
'the room · local': 'la sala · local',
|
|
1815
|
+
'@{agent} may {what}': '@{agent} puede {what}',
|
|
1816
|
+
' (cannot {what})': ' (no puede {what})',
|
|
1817
|
+
'create files': 'crear archivos',
|
|
1808
1818
|
'review it with': 'revisarlo con',
|
|
1809
1819
|
'no session': 'sin sesión',
|
|
1810
1820
|
'CLICK A LINE NUMBER TO SELECT · SHIFT FOR A RANGE':
|
|
@@ -2026,8 +2036,8 @@ export const ES = {
|
|
|
2026
2036
|
'MU/TH/UR › /module needs #2 CREATE. Choose #2 in the mode chip, then send again.':
|
|
2027
2037
|
'MU/TH/UR › /module necesita #2 CREATE. Elige #2 en el chip de modo y vuelve a enviar.',
|
|
2028
2038
|
'MU/TH/UR · MODULE · NEEDS #2 ›': 'MU/TH/UR · MODULE · REQUIERE #2 ›',
|
|
2029
|
-
'Create a MADRE module for this request: {request} Write exactly one <id>.module.mjs file for review. Do not install it; MADRE will show the human an install card.':
|
|
2030
|
-
'Crea un módulo de MADRE para esta petición: {request} Escribe exactamente un archivo <id>.module.mjs para revisión. No lo instales; MADRE le
|
|
2039
|
+
'Create a MADRE module for this request: {request} Write exactly one <id>.module.mjs file for review. Set vendor to whoever wrote it — the human\'s name or team if you know it, otherwise leave it out; never the word "you". Do not install it; MADRE will show the human an install card.':
|
|
2040
|
+
'Crea un módulo de MADRE para esta petición: {request} Escribe exactamente un archivo <id>.module.mjs para revisión. Pon en vendor a quien lo escribió —el nombre o el equipo del humano si lo sabes, y si no, déjalo fuera; nunca la palabra «you»—. No lo instales; MADRE le va a mostrar al humano una tarjeta de instalación.',
|
|
2031
2041
|
'MU/TH/UR › unknown command /{name}. Type "/" to see what this room offers.':
|
|
2032
2042
|
'MU/TH/UR › comando desconocido /{name}. Escribe «/» para ver lo que ofrece esta sala.',
|
|
2033
2043
|
'{name} · uploading…': '{name} · subiendo…',
|
package/public/styles.css
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
--syn-comment: color-mix(in srgb, var(--text-3) 88%, transparent);
|
|
51
51
|
--syn-number: #ffd166;
|
|
52
52
|
--syn-literal: #ff9ecd;
|
|
53
|
+
--syn-regex: #7ce0d3;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
@media (prefers-color-scheme: light) {
|
|
@@ -60,6 +61,7 @@
|
|
|
60
61
|
--syn-comment: #6a7280;
|
|
61
62
|
--syn-number: #9a5000;
|
|
62
63
|
--syn-literal: #9c1f6b;
|
|
64
|
+
--syn-regex: #0a6a73;
|
|
63
65
|
--bg: #f4f5f2;
|
|
64
66
|
--bg-2: #ffffff;
|
|
65
67
|
--panel: #ffffff;
|
|
@@ -92,6 +94,7 @@
|
|
|
92
94
|
--syn-comment: #6a7280;
|
|
93
95
|
--syn-number: #9a5000;
|
|
94
96
|
--syn-literal: #9c1f6b;
|
|
97
|
+
--syn-regex: #0a6a73;
|
|
95
98
|
--bg: #f4f5f2;
|
|
96
99
|
--bg-2: #ffffff;
|
|
97
100
|
--panel: #ffffff;
|
|
@@ -1733,4 +1736,5 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
|
|
|
1733
1736
|
.viewer .code .t-comment { color: var(--syn-comment); font-style: italic; }
|
|
1734
1737
|
.viewer .code .t-number { color: var(--syn-number); }
|
|
1735
1738
|
.viewer .code .t-literal { color: var(--syn-literal); }
|
|
1739
|
+
.viewer .code .t-regex { color: var(--syn-regex); }
|
|
1736
1740
|
.viewer-lang { color: var(--ph-dim); font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase; }
|
package/public/syntax.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// are the same bytes. Anything it cannot classify stays plain text rather than being guessed at.
|
|
11
11
|
|
|
12
12
|
const FAMILIES = {
|
|
13
|
-
js: { ext: /\.(m|c)?jsx?$|\.tsx?$/i, label: 'JavaScript',
|
|
13
|
+
js: { ext: /\.(m|c)?jsx?$|\.tsx?$/i, label: 'JavaScript', regex: true,
|
|
14
14
|
keywords: /\b(await|async|break|case|catch|class|const|continue|default|delete|do|else|export|extends|finally|for|from|function|get|if|import|in|instanceof|let|new|of|return|set|static|super|switch|this|throw|try|typeof|var|void|while|yield|as|interface|type|enum|implements|readonly|declare|namespace|satisfies)\b/,
|
|
15
15
|
literals: /\b(true|false|null|undefined|NaN|Infinity)\b/,
|
|
16
16
|
line: /\/\/.*/, block: [/\/\*/, /\*\//], strings: ['"', "'", '`'] },
|
|
@@ -73,11 +73,14 @@ export function tokenize(line, family, state = { inBlock: false }) {
|
|
|
73
73
|
const lineOpen = rest.match(family.line);
|
|
74
74
|
const quote = family.strings.map((q) => rest.indexOf(q)).filter((at) => at >= 0).sort((a, b) => a - b)[0];
|
|
75
75
|
|
|
76
|
-
// Whichever comes first on this line decides what happens next.
|
|
76
|
+
// Whichever comes first on this line decides what happens next. A regular expression has to
|
|
77
|
+
// be among the candidates and not an afterthought: its own `//` is a line comment to every
|
|
78
|
+
// other rule here, so if the scan jumps to that `//` first it never learns a literal was open.
|
|
77
79
|
const marks = [
|
|
78
80
|
blockOpen && blockOpen.index === 0 ? { at: 0, what: 'block' } : blockOpen ? { at: blockOpen.index, what: 'block' } : null,
|
|
79
81
|
lineOpen && lineOpen.index !== undefined ? { at: lineOpen.index, what: 'line' } : null,
|
|
80
82
|
quote !== undefined ? { at: quote, what: 'string' } : null,
|
|
83
|
+
family.regex ? regexMark(rest, line, index, out) : null,
|
|
81
84
|
].filter(Boolean).sort((a, b) => a.at - b.at);
|
|
82
85
|
const next = marks[0];
|
|
83
86
|
|
|
@@ -93,6 +96,11 @@ export function tokenize(line, family, state = { inBlock: false }) {
|
|
|
93
96
|
push(rest, 'comment'); index = line.length; state.inBlock = true; break;
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
if (next.what === 'regex') {
|
|
100
|
+
const end = regexEnd(rest);
|
|
101
|
+
if (end) { push(rest.slice(0, end), 'regex'); index += end; continue; }
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
// A string: to its closing quote, respecting a backslash escape.
|
|
97
105
|
const q = rest[0];
|
|
98
106
|
let at = 1;
|
|
@@ -107,6 +115,52 @@ export function tokenize(line, family, state = { inBlock: false }) {
|
|
|
107
115
|
return out;
|
|
108
116
|
}
|
|
109
117
|
|
|
118
|
+
// The first slash on what is left that could open a regular expression, and only if the literal
|
|
119
|
+
// actually closes on this line: an unclosed one was division after all.
|
|
120
|
+
function regexMark(rest, line, offset, pieces) {
|
|
121
|
+
for (let at = 0; at < rest.length; at += 1) {
|
|
122
|
+
if (rest[at] !== '/') continue;
|
|
123
|
+
if (rest[at + 1] === '/' || rest[at + 1] === '*') return null; // a comment, not a literal
|
|
124
|
+
const before = line.slice(0, offset + at);
|
|
125
|
+
if (!canStartRegex(pieces, before)) continue;
|
|
126
|
+
return regexEnd(rest.slice(at)) ? { at, what: 'regex' } : null;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A slash opens a regular expression only where a value may begin. After a name, a number or a
|
|
132
|
+
// closing bracket it is division, and treating `a / b / c` as a literal would be worse than the
|
|
133
|
+
// bug this fixes. Whitespace does not count as the thing before.
|
|
134
|
+
function canStartRegex(pieces, before = '') {
|
|
135
|
+
const trimmed = before.trimEnd();
|
|
136
|
+
if (trimmed) return !/[\w$)\]]$/.test(trimmed);
|
|
137
|
+
for (let at = pieces.length - 1; at >= 0; at -= 1) {
|
|
138
|
+
const text = (pieces[at].text ?? '').trimEnd();
|
|
139
|
+
if (!text) continue;
|
|
140
|
+
if (pieces[at].kind === 'comment') continue;
|
|
141
|
+
if (pieces[at].kind === 'string' || pieces[at].kind === 'number') return false;
|
|
142
|
+
return !/[\w$)\]]$/.test(text);
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Where the literal ends: the closing slash plus its flags, skipping escapes and anything inside
|
|
148
|
+
// a character class, where an unescaped slash is ordinary. Returns 0 when it never closes, and
|
|
149
|
+
// then the slash was division after all.
|
|
150
|
+
function regexEnd(rest) {
|
|
151
|
+
let at = 1;
|
|
152
|
+
let inClass = false;
|
|
153
|
+
while (at < rest.length) {
|
|
154
|
+
const char = rest[at];
|
|
155
|
+
if (char === '\\') { at += 2; continue; }
|
|
156
|
+
if (inClass) { if (char === ']') inClass = false; at += 1; continue; }
|
|
157
|
+
if (char === '[') { inClass = true; at += 1; continue; }
|
|
158
|
+
if (char === '/') { at += 1; while (at < rest.length && /[a-z]/i.test(rest[at])) at += 1; return at; }
|
|
159
|
+
at += 1;
|
|
160
|
+
}
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
110
164
|
// Code between the comments and the strings: keywords, literals, numbers, and the rest plain.
|
|
111
165
|
function pushCode(chunk, family, push) {
|
|
112
166
|
const pattern = new RegExp(`${family.keywords.source}|${family.literals.source}|\\b\\d+(?:\\.\\d+)?\\b`, family.keywords.flags.includes('i') ? 'gi' : 'g');
|
package/src/room/control.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export class ControlDesk {
|
|
|
29
29
|
this.#checkpoints.set(checkpoint.id, checkpoint);
|
|
30
30
|
// Prevention first: .env files and MADRE's folders are read-only for the length of the turn.
|
|
31
31
|
const guard = await guardForbidden(this.#projectRoot);
|
|
32
|
-
const run = { agent: agent.id, messageId, checkpoint, since: new Date().toISOString(), guard, mode };
|
|
32
|
+
const run = { agent: agent.id, messageId, checkpoint, since: new Date().toISOString(), guard, mode, scratchDir: null };
|
|
33
33
|
if (mode >= 3) this.#holder = run;
|
|
34
34
|
const lease = { leaseId: checkpoint.id, outDir: this.#projectRoot, relativeDir: '.', scopes: { ...enabledScopes, write: true }, control: mode >= 3, airlock: mode === 4, create: mode === 2, checkpoint };
|
|
35
35
|
if (mode < 3) return { run, lease, announcement: null };
|
|
@@ -43,7 +43,14 @@ export class ControlDesk {
|
|
|
43
43
|
async settle(run) {
|
|
44
44
|
const diff = await diffCheckpoint(this.#projectRoot, run.checkpoint);
|
|
45
45
|
const additive = run.mode === 2;
|
|
46
|
-
|
|
46
|
+
// The draft folder MADRE itself hands the agent lives under `.pulse/out/`, and `.pulse/` is a
|
|
47
|
+
// forbidden zone — so the room named a place to write, the agent wrote there, and the room
|
|
48
|
+
// undid it. Nobody noticed here because this project ignores `.pulse/` and git never reported
|
|
49
|
+
// the files; in a project that does not, `/module` never reached its install card. A turn's
|
|
50
|
+
// own scratch folder is not a forbidden zone FOR THAT TURN. Every other `.pulse` path still is.
|
|
51
|
+
const ownScratch = run.scratchDir ? `${run.scratchDir.replace(/\/$/, '')}/` : null;
|
|
52
|
+
const mine = (path) => Boolean(ownScratch) && path.startsWith(ownScratch);
|
|
53
|
+
const toRevert = new Set(diff.forbidden.filter((path) => !mine(path)));
|
|
47
54
|
if (additive) for (const file of diff.files) if (file.status !== 'A') toRevert.add(file.path);
|
|
48
55
|
let reverted = [];
|
|
49
56
|
if (toRevert.size) {
|
|
@@ -51,7 +58,7 @@ export class ControlDesk {
|
|
|
51
58
|
reverted = [...restored.restored, ...restored.removed];
|
|
52
59
|
}
|
|
53
60
|
const kept = diff.files.filter((file) => !toRevert.has(file.path));
|
|
54
|
-
const changes = { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, mode: run.mode ?? 3, files: kept, stat: diff.stat, forbiddenReverted:
|
|
61
|
+
const changes = { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, mode: run.mode ?? 3, files: kept, stat: diff.stat, forbiddenReverted: reverted.filter((path) => diff.forbidden.includes(path) && !mine(path)), existingReverted: additive ? reverted.filter((path) => !diff.forbidden.includes(path)) : [] };
|
|
55
62
|
const count = changes.files.length;
|
|
56
63
|
const notes = [
|
|
57
64
|
changes.forbiddenReverted.length ? `${changes.forbiddenReverted.length} write(s) into forbidden zones were reverted.` : null,
|
package/src/room.mjs
CHANGED
|
@@ -1197,6 +1197,9 @@ export class Room {
|
|
|
1197
1197
|
// CREATE: photograph the project now; after the turn only what appeared stays.
|
|
1198
1198
|
const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode: 2 });
|
|
1199
1199
|
createRun = seat.run;
|
|
1200
|
+
// The folder this turn was told to draft in, so settling it does not undo what it was
|
|
1201
|
+
// invited to write. Only this turn's own folder; every other `.pulse` path stays forbidden.
|
|
1202
|
+
createRun.scratchDir = lease.scratchDir ?? null;
|
|
1200
1203
|
lease = { ...lease, checkpoint: seat.run.checkpoint };
|
|
1201
1204
|
}
|
|
1202
1205
|
turnScopes.imageGen = Boolean(lease?.scopes?.imageGen);
|