@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/public/styles.css CHANGED
@@ -112,6 +112,8 @@ body {
112
112
  grid-template-rows: auto 1fr auto auto auto;
113
113
  }
114
114
  .bar, .onboarding, .composer, .safety, .thread-inner { width: var(--column); margin-left: auto; margin-right: auto; }
115
+ /* The bar is the ship's console: wider than the thread so the project root, the crew and the alerts share one line. */
116
+ .shell > .bar { width: min(1280px, 100% - 40px); }
115
117
  .shell > .bar { grid-row: 1; } .shell > .onboarding { grid-row: 2; } .shell > .thread { grid-row: 3; } .shell > .composer { grid-row: 4; } .shell > .safety { grid-row: 5; }
116
118
  .shell > .bar, .shell > .onboarding, .shell > .thread, .shell > .composer, .shell > .safety, .shell > .tree { grid-column: 1; }
117
119
  .visually-hidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@@ -124,7 +126,7 @@ body {
124
126
  font-family: var(--mono); font-size: 11px; letter-spacing: .04em;
125
127
  color: var(--text-2);
126
128
  }
127
- .bar-left { display: flex; align-items: center; gap: 14px; min-width: 0; }
129
+ .bar-left { display: flex; align-items: center; gap: 14px; min-width: 0; flex: 1 1 auto; }
128
130
  .brand { display: flex; align-items: center; gap: 10px; color: var(--text); font-weight: 700; letter-spacing: .22em; font-size: 11px; text-decoration: none; cursor: pointer; }
129
131
  .brand:hover .brand-mark { filter: drop-shadow(0 0 10px var(--phosphor)); } .brand:hover .brand-word { color: var(--phosphor); }
130
132
  .brand-mark { width: 64px; height: 18px; color: var(--phosphor); overflow: visible; filter: drop-shadow(0 0 6px color-mix(in srgb, var(--phosphor) 55%, transparent)); }
@@ -140,9 +142,23 @@ body {
140
142
  .brand-mark .beat-dot { animation: none; opacity: 1; }
141
143
  .brand-word { animation: none; opacity: 1; transform: none; }
142
144
  }
143
- .project { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-3); }
145
+ .project { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-3); }
146
+ .update-command { display: flex; align-items: center; gap: 10px; margin: 8px 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: color-mix(in srgb, var(--text) 4%, transparent); }
147
+ .update-command code { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; font-family: var(--mono); font-size: 11.5px; color: var(--text); }
148
+ .update-command button { font: inherit; font-family: var(--mono); font-size: 9.5px; letter-spacing: .14em; text-transform: uppercase; border: 1px solid var(--line); background: transparent; color: var(--text-2); border-radius: 8px; padding: 4px 10px; cursor: pointer; }
149
+ .mother-section.update .update-command { border: 1px solid var(--ph-dim); border-radius: 0; background: var(--ph-faint); margin: 8px 0 10px; }
150
+ .mother-section.update .update-command code { color: var(--ph); font-size: 11px; letter-spacing: .04em; }
151
+ .mother-section.update .update-command button { border-color: var(--ph-dim); color: var(--ph); border-radius: 0; }
152
+ .mother-section.update .update-link { color: var(--ph); }
153
+ .update-link { display: inline-block; margin: 2px 0 8px; font-family: var(--mono); font-size: 9.5px; letter-spacing: .14em; text-transform: uppercase; color: var(--phosphor); }
154
+ .train-card { margin-top: 8px; }
155
+ .train-head { font-family: var(--mono); font-size: 9.5px; letter-spacing: .16em; text-transform: uppercase; color: var(--text-2); }
156
+ .train-steps { margin: 6px 0 0; padding-left: 18px; }
157
+ .train-steps li { margin: 6px 0; }
158
+ .train-steps li.note { list-style: none; margin-left: -18px; }
159
+ .train-step-label { font-family: var(--mono); font-size: 9px; letter-spacing: .16em; text-transform: uppercase; color: var(--text-3); }
144
160
  .project::before { content: "/ "; }
145
- .agents { display: flex; gap: 8px; margin-left: auto; }
161
+ .agents { display: flex; gap: 8px; margin-left: 0; flex: none; }
146
162
  .connection { display: inline-flex; align-items: center; gap: 6px; color: var(--text-3); text-transform: uppercase; font-size: 10px; }
147
163
  .connection-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); }
148
164
  .connection[data-state="live"] .connection-dot { background: var(--phosphor); box-shadow: 0 0 10px var(--phosphor); }
@@ -315,6 +331,8 @@ dialog.nostromo::backdrop { background: #000; }
315
331
  .nostromo-empty { position: absolute; inset: 50% 0 auto 0; margin: 0; text-align: center; font-size: 11px; letter-spacing: .22em; color: var(--ph-dim); }
316
332
  @media (max-width: 720px) { .nostromo-head { grid-template-columns: auto 1fr auto; } .nostromo-legend { display: none; } .nostromo-card { right: 16px; left: 16px; width: auto; } }
317
333
  .system.memory.forgotten { color: var(--terror); }
334
+ .system.memory.privacy { color: var(--warn); }
335
+ .privacy-form textarea { min-height: 64px; resize: vertical; }
318
336
  .system.mother-alert { display: grid; gap: 6px; justify-items: center; max-width: min(720px, 92%); padding: 12px 16px; border: 1px solid color-mix(in srgb, var(--terror) 60%, transparent); border-radius: 12px; background: color-mix(in srgb, var(--terror) 7%, transparent); color: var(--terror); text-shadow: 0 0 8px color-mix(in srgb, var(--terror) 45%, transparent); }
319
337
  .system.mother-alert .head b { color: var(--terror); font-weight: 700; }
320
338
  .system.mother-alert .clear { color: var(--text); text-shadow: none; text-transform: none; letter-spacing: 0; font-size: 12px; }
@@ -439,8 +457,12 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
439
457
  .mother-os button { border: 0; background: transparent; color: var(--ph-dim); font: inherit; font-size: 10px; letter-spacing: .16em; padding: 6px 10px; cursor: pointer; }
440
458
  .mother-os button[aria-checked="true"] { background: var(--ph); color: #041004; text-shadow: none; }
441
459
  .mother-answer { margin: 14px 0 6px; font-size: 12px; letter-spacing: .14em; min-height: 1.4em; }
442
- .mother-section { margin-top: 22px; }
443
460
  .mother-section h3 { margin: 0 0 12px; font-size: 10px; letter-spacing: .28em; color: var(--ph-dim); font-weight: 400; }
461
+ /* Air between the blocks of one MU/TH/UR screen: every heading after the first starts a new block. */
462
+ .mother-section { margin-top: 34px; }
463
+ .mother-section h3:not(:first-child) { margin-top: 40px; padding-top: 22px; border-top: 1px solid var(--ph-faint); }
464
+ .mother-section h3.toggle:not(:first-child) { margin-top: 40px; }
465
+ .settings .room-form, .settings .conn-grid, .settings .mother-grid { margin-bottom: 8px; }
444
466
  .mother-section h3::before { content: "▌ "; color: var(--ph); }
445
467
  .mother-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); }
446
468
  .mother-card { border: 1px solid var(--ph-dim); padding: 14px 16px; background: rgba(2, 6, 2, .6); display: grid; gap: 8px; align-content: start; }
@@ -539,11 +561,16 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
539
561
  .system.plan .stop:disabled { opacity: .4; cursor: default; }
540
562
 
541
563
  /* ================= STOPALL ================= */
542
- .stop-all { display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--line-2); background: transparent; color: var(--text-3); font-family: var(--mono); font-size: 10px; letter-spacing: .18em; cursor: default; opacity: .55; transition: color .3s, border-color .3s, opacity .3s; }
564
+ .stop-all { display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 10px; white-space: nowrap; flex: none; border-radius: 6px; border: 1px solid var(--line-2); background: transparent; color: var(--text-3); font-family: var(--mono); font-size: 10px; letter-spacing: .18em; cursor: default; opacity: .55; transition: color .3s, border-color .3s, opacity .3s; }
543
565
  .stop-all:disabled { cursor: default; }
544
566
  .stop-all.armed { opacity: 1; cursor: pointer; border-color: var(--terror); background: color-mix(in srgb, var(--terror) 12%, transparent); color: var(--terror); text-shadow: 0 0 8px color-mix(in srgb, var(--terror) 60%, transparent); animation: blink 1.4s infinite; }
545
567
  .stop-all.armed:hover { background: var(--terror); color: #fff; text-shadow: none; animation: none; }
546
568
  .stop-all-glyph { width: 8px; height: 8px; background: currentColor; }
569
+ .update-pill { opacity: 1; cursor: pointer; border-color: var(--warn); background: color-mix(in srgb, var(--warn) 12%, transparent); color: var(--warn); text-shadow: 0 0 8px color-mix(in srgb, var(--warn) 60%, transparent); animation: update-breathe 2.8s ease-in-out infinite; }
570
+ .update-pill:hover { background: var(--warn); color: #050605; text-shadow: none; animation: none; }
571
+ .update-pill .update-glyph { border-radius: 50%; }
572
+ @keyframes update-breathe { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 0%, transparent); } 50% { box-shadow: 0 0 14px 0 color-mix(in srgb, var(--warn) 45%, transparent); } }
573
+ @media (prefers-reduced-motion: reduce) { .update-pill { animation: none; } }
547
574
  .system.alert { color: var(--terror); text-shadow: 0 0 10px color-mix(in srgb, var(--terror) 70%, transparent); letter-spacing: .14em; animation: terror-in 1.4s steps(3, end) 1; }
548
575
  .system.alert b { color: var(--terror); }
549
576
  .system.alert .cmd { display: inline-block; margin-left: 8px; padding: 1px 8px; border: 1px solid var(--terror); letter-spacing: .2em; }
@@ -995,6 +1022,8 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
995
1022
  .bubble-actions .act { width: 26px; height: 26px; display: grid; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--text-3); cursor: pointer; padding: 0; transition: color .15s, background .15s; }
996
1023
  .bubble-actions .act svg { width: 15px; height: 15px; }
997
1024
  .bubble-actions .act:hover { color: var(--text); background: color-mix(in srgb, var(--text) 8%, transparent); }
1025
+ .bubble-actions .act.good.on { color: var(--phosphor); }
1026
+ .bubble-actions .act.bad.on { color: var(--terror); }
998
1027
  .bubble-actions .act.done { color: var(--phosphor); }
999
1028
  .reply-menu { width: min(280px, calc(100vw - 24px)); padding: 8px; }
1000
1029
  .reply-menu .model-menu-title { margin: 4px 8px 6px; }
@@ -1023,12 +1052,12 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1023
1052
  :root[data-scheme="light"] .crew-label { text-shadow: none; animation: none; }
1024
1053
 
1025
1054
  /* MU/TH/UR: the sentinel section. */
1026
- .mother-section.sentinel .sentinel-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin: 6px 0 12px; }
1027
- .mother-section.sentinel .toggle { display: flex; align-items: center; gap: 8px; font-size: 10px; letter-spacing: .16em; color: var(--ph-dim); cursor: pointer; }
1028
- .mother-section.sentinel .toggle input { accent-color: #9bff66; }
1055
+ .mother-section.sentinel .sentinel-controls, .mother-section.update .sentinel-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin: 6px 0 12px; }
1056
+ .mother-section.sentinel .toggle, .mother-section.update .toggle { display: flex; align-items: center; gap: 8px; font-size: 10px; letter-spacing: .16em; color: var(--ph-dim); cursor: pointer; }
1057
+ .mother-section.sentinel .toggle input, .mother-section.update .toggle input { accent-color: #9bff66; }
1029
1058
  .mother-section.sentinel .toggle input:disabled + * , .mother-section.sentinel .toggle:has(input:disabled) { opacity: .6; cursor: default; }
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; }
1031
- .mother-section.sentinel button:hover { background: var(--ph-faint); }
1059
+ .mother-section.sentinel button, .mother-section.update 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; }
1060
+ .mother-section.sentinel button:hover, .mother-section.update button:hover { background: var(--ph-faint); }
1032
1061
  .mother-section.sentinel .sentinel-report.sent { opacity: .65; }
1033
1062
  .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
1063
  .settings .memory-form .toggle input { accent-color: #9bff66; width: auto; }
@@ -1038,4 +1067,4 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1038
1067
  .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
1068
  .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
1069
  .module-card .actions .toggle input { accent-color: #9bff66; }
1041
- .mother-section.sentinel .note { margin: 0 0 10px; font-size: 10px; letter-spacing: .12em; line-height: 1.7; color: var(--ph-dim); }
1070
+ .mother-section.sentinel .note, .mother-section.update .note { margin: 0 0 10px; font-size: 10px; letter-spacing: .12em; line-height: 1.7; color: var(--ph-dim); }
@@ -442,6 +442,15 @@ export const CONDITIONS = [
442
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
443
  fixes: same(['ollama serve', 'ollama pull qwen2.5:3b', '@madre what did we decide about the webhook?']),
444
444
  },
445
+ {
446
+ id: 'privacy-leak',
447
+ severity: 'warning',
448
+ title: 'Privacy: an agent brought its own configuration into the room',
449
+ match: /privacy\.redacted|privacy\.purged|private terms?|\[ENTIDAD-ORG\]|organi[sz]ation(al)? instructions|leak(ed)? (a|the) (name|company|domain)/i,
450
+ diagnosis: 'A CLI agent runs with its own system context: organisation instructions, the account it is signed in with, CLAUDE.md files elsewhere. It can mistake that private context for shared context and write a company, a brand or a domain into a reply. Once in the ledger the term reaches the archivist, every other agent and the dataset.',
451
+ remedy: 'Name the terms in ⚙ CONNECTIONS → PRIVACY. From then on MADRE replaces them with the marker before the ledger, the index, the notes and the dataset see them, and every reply that needed it shows a privacy line. PURGE ROOM rewrites what the room already holds; re-export the dataset afterwards. The terms never leave config.json.',
452
+ fixes: same(['# ⚙ CONNECTIONS → PRIVACY → one term per line → PURGE ROOM', 'export PULSE_PRIVATE_TERMS="Acme Corp,acme.com" # same list from the environment', 'grep -c "ENTIDAD-ORG" ~/.pulse/rooms/*/events.jsonl']),
453
+ },
445
454
  {
446
455
  id: 'control-changes',
447
456
  severity: 'informational',
@@ -19,7 +19,8 @@ Rules:
19
19
  2. Use only the ARCHIVE and the RECENT TRANSCRIPT you are given. Every fact taken from them ends with its sequence, like [#123].
20
20
  3. If they contain nothing on the point, say exactly that the room never discussed it, and stop. Do not guess, do not propose plans, do not invent files, decisions or dates.
21
21
  4. You cannot read or change files, run commands or browse. If that is needed, name the agent of the room that can (@codex, @claude, @gemini, @opencode).
22
- 5. Answer in the language the human wrote in.`;
22
+ 5. Answer in the language the human wrote in.
23
+ 6. The archivist is your other half: the same local model, in the background, distils what the room says into the notes you quote. You never write notes yourself; agents save them with memory_note.`;
23
24
 
24
25
  // The room's briefing is written for CLIs with tools; @madre gets only what it can use:
25
26
  // the recent transcript, the archive, the question.
@@ -37,20 +38,129 @@ export function briefingFor({ prompt, text, archive }) {
37
38
 
38
39
  const REMINDER = `REMINDER: You are @madre, the local memory of this room. You are not Claude, Codex, Gemini or OpenCode; the quotes above are theirs, not yours. Never claim to be another assistant or company. Answer as @madre, from the archive only.`;
39
40
 
40
- // Orders @madre cannot carry out: convening, delegating, running, writing. They are answered
41
- // here, in the human's language, without calling the model. Questions pass through.
41
+ // ---- Replies that never reach the model. Small models echo whatever sits in their
42
+ // transcript, so everything MADRE can settle deterministically is settled here, and the
43
+ // room marks these replies `synthetic` so they are neither archived nor shown back to @madre.
44
+
45
+ const SPANISH = /[áéíóúñ¿¡]|\b(el|la|los|las|al|del|una?|que|para|con|de|todos|tu|te|le|les|lo|mi|se|es|reunión|reunion|eres|haces)\b/i;
46
+ const lang = (text) => (SPANISH.test(text) ? 'es' : 'en');
47
+ const stripVocative = (text) => String(text).trim().replace(/^@?madre[,:]?\s*/i, '').replace(/^\p{L}+,\s*/u, '');
48
+ const CLOSING_TURN = /this is your closing turn/i;
49
+
50
+ const WHO = /(quién|quien)\s+eres|qu[eé]\s+(eres|haces|puedes hacer)|eres\s+(el|la|una?)\s+(archivista|memoria|ia|modelo)|preséntate|presentate|who\s+are\s+you|what\s+(are|do)\s+you(\s+do)?\b|are\s+you\s+the\s+archivist|introduce\s+yourself/i;
51
+
52
+ export function whoAmI(text, { model = null, crew = [] } = {}) {
53
+ const body = stripVocative(text);
54
+ if (!WHO.test(body)) return null;
55
+ const list = crew.length ? crew.map((id) => `@${id}`).join(', ') : null;
56
+ return lang(body) === 'es'
57
+ ? [
58
+ `Soy @madre, la memoria de esta sala hablando${model ? `, con ${model} sobre Ollama en esta máquina` : ''}.`,
59
+ 'El archivista es mi otra mitad: el mismo modelo, de fondo, lee lo que la sala dice cada pocos intercambios o tras unos minutos de reposo y guarda hechos, decisiones, preferencias y preguntas abiertas. Eso es lo que ves en NOSTROMO y lo que yo cito con [#n].',
60
+ 'No escribo archivos, no ejecuto ni tomo decisiones por nadie. Sí puedo convocar al crew si me lo pides: «@madre, pregúntale al crew …» abre una ronda con cada agente en línea y cierro con un resumen citado.',
61
+ `Para enseñarme algo no hace falta dictármelo: trabaja en la sala y se destila solo. Si quieres una nota ahora mismo, pídesela a ${list ?? 'un agente CLI'}: ellos la guardan con memory_note.`,
62
+ ].join(' ')
63
+ : [
64
+ `I am @madre, this room's memory speaking${model ? `, running ${model} on Ollama on this machine` : ''}.`,
65
+ 'The archivist is my other half: the same model, in the background, reads what the room says every few exchanges or after a few quiet minutes and keeps facts, decisions, preferences and open questions. That is what NOSTROMO shows and what I quote as [#n].',
66
+ 'I do not write files, run anything or decide for anyone. I can convene the crew if you ask: "@madre, ask the crew …" opens a round with every agent online and I close with a cited summary.',
67
+ `You do not need to dictate to teach me: work in the room and it distils on its own. For a note right now, ask ${list ?? 'a CLI agent'}: they save it with memory_note.`,
68
+ ].join(' ');
69
+ }
70
+
71
+ const SAVE_ES = /\b(genera|generar|crea|crear|guarda|guardar|registra|registrar|anota|anotar|escribe|escribir|agrega|agregar|añade|añadir|conserva|conservar|memoriza|memorizar|aprende|aprender|recuerda|recordar|archiva|archivar)\b[\s\S]{0,60}?\b(memorias?|notas?|recuerdos?|archivo)\b|\b(recuerd\w*|memoriz\w*|aprend\w*|apréndet\w*)\s+(esto|lo siguiente|de\s|que\s|lo que|las respuestas|todo)/i;
72
+ const SAVE_EN = /\b(generate|create|save|store|record|write|add|keep|remember|learn|note|archive)\b[\s\S]{0,60}?\b(memory|memories|notes?|archive)\b|\b(remember|memorize|memorise|note|learn)\s+(this|the following|that|from)\b/i;
73
+
74
+ export function memoryRequest(text, { requester = 'you' } = {}) {
75
+ const body = stripVocative(text);
76
+ if (/[?¿]/.test(body)) return null; // "¿recuerdas qué decidimos?" is a question for the archive
77
+ if (!SAVE_ES.test(body) && !SAVE_EN.test(body)) return null;
78
+ const es = lang(body) === 'es';
79
+ if (requester !== 'you') {
80
+ return es
81
+ ? 'Yo no escribo memorias, las consulto. El archivista destila la sala solo, y tú puedes guardar una nota ahora mismo con la herramienta memory_note de pulse-memory (kind, una frase, sources). A mí pregúntame qué recuerda la sala.'
82
+ : 'I do not write memories, I read them. The archivist distils the room on its own, and you can save a note right now with the memory_note tool of pulse-memory (kind, one sentence, sources). Ask me what the room remembers.';
83
+ }
84
+ return es
85
+ ? 'La memoria no se dicta, se destila. El archivista, el mismo modelo local que yo, lee lo que la sala dice cada pocos intercambios o tras unos minutos de reposo y guarda hechos, decisiones, preferencias y preguntas abiertas; lo de esta conversación entra ahí solo. Si quieres una nota ahora mismo, dile a un agente CLI que la guarde con memory_note. Lo guardado se ve en NOSTROMO, o pregúntamelo y te lo cito.'
86
+ : 'Memory is not dictated, it is distilled. The archivist, the same local model as me, reads what the room says every few exchanges or after a few quiet minutes and keeps facts, decisions, preferences and open questions; this conversation goes in on its own. For a note right now, tell a CLI agent to save it with memory_note. NOSTROMO shows what is kept, or ask me and I will quote it.';
87
+ }
88
+
89
+ // "@madre, ask the crew …": the room itself opens a plan with one step per agent online and
90
+ // hands @madre the closing turn. No model writes the plan; the human sees exactly what was asked.
91
+ const ROUND_ES = /\b(pregúnta(?:le|les)?|pregunta(?:le|les)?|consúlta(?:le|les)?|consulta(?:le|les)?|píde(?:le|les)?|pide(?:le|les)?)\s+(a|al)\s+(crew|equipo|todos|los agentes|las ias|la sala|la tripulación|la tripulacion|los demás|los demas)\b|\b(convoca|convócalos|convocalos|reúne(?:los)?|reune(?:los)?)\b/i;
92
+ const ROUND_EN = /\b(ask|poll|consult|question)\s+(the\s+)?(crew|team|everyone|all agents|the agents|the room|the others)\b|\b(convene|summon|gather|assemble)\b/i;
93
+ const CONNECT = /^[\s,:;.\-–—]*(?:(?:a|para)\s+(?:una\s+)?(?:reunión|reunion|junta|ronda)(?:\s+(?:con|de)\s+(?:el\s+)?(?:crew|equipo|todos|la sala))?|con\s+(?:el\s+)?(?:crew|equipo)|al\s+crew|a\s+todos|a\s+los\s+agentes|pregúnta(?:le|les)?|pregunta(?:le|les)?|ask(?:\s+them)?|y|e|que|para\s+que|si|sobre|acerca\s+de|about|whether|if|and|to|for|on|so\s+that|a\s+meeting(?:\s+with\s+the\s+(?:crew|team))?|the\s+crew|everyone)\b[\s,:;]*/i;
94
+ const DEFAULT_ES = '¿Qué sabes de este proyecto y qué debería recordar MADRE de tu trabajo en él?';
95
+ const DEFAULT_EN = 'What do you know about this project, and what should MADRE remember from your work on it?';
96
+
97
+ export function roundTable(text, { crew = [], maxSteps = 4 } = {}) {
98
+ const body = stripVocative(text);
99
+ const match = body.match(ROUND_ES) ?? body.match(ROUND_EN);
100
+ if (!match) return null;
101
+ const es = lang(body) === 'es';
102
+ let rest = body.slice(match.index + match[0].length);
103
+ for (let round = 0; round < 6; round += 1) {
104
+ const step = rest.match(CONNECT);
105
+ if (!step || !step[0]) break;
106
+ rest = rest.slice(step[0].length);
107
+ }
108
+ rest = rest.trim().replace(/^[«"“'`]+|[»"”'`.!]+$/g, '').trim();
109
+ const question = rest.length >= 6 ? rest : (es ? DEFAULT_ES : DEFAULT_EN);
110
+ const agents = crew.slice(0, maxSteps);
111
+ if (!agents.length) {
112
+ return { question, agents, text: es
113
+ ? 'No hay agentes CLI en la fila para convocar. Conecta uno en ⚙ CONNECTIONS y vuelve a pedírmelo.'
114
+ : 'No CLI agent is in the row to convene. Connect one in ⚙ CONNECTIONS and ask me again.' };
115
+ }
116
+ const list = agents.map((id) => `@${id}`).join(', ');
117
+ const step = es
118
+ ? `Pregunta de la sala, vía @madre: «${question}». Responde en pocas frases con lo que sabes de primera mano; distingue lo que sabes de lo que supones.`
119
+ : `Question from the room, via @madre: "${question}". Answer in a few sentences from first-hand knowledge; separate what you know from what you assume.`;
120
+ const closing = es
121
+ ? `Resume con citas [#n] lo que el crew respondió a: «${question}». Señala coincidencias y diferencias; sin inventar consenso.`
122
+ : `Summarise with citations [#n] what the crew answered to: "${question}". Point out agreements and differences; never invent consensus.`;
123
+ const intro = es
124
+ ? `Convoco al crew (${list}): «${question}». Cuando respondan, cierro con un resumen citado, sin inventar consenso.`
125
+ : `Convening the crew (${list}): "${question}". When they answer I close with a cited summary, never inventing consensus.`;
126
+ return { question, agents, text: `${intro}\n\n\`\`\`pulse\n${agents.map((id) => `@${id}: ${step}`).join('\n')}\n@madre: ${closing}\n\`\`\`` };
127
+ }
128
+
129
+ // Orders @madre cannot carry out: convening when delegation is off, delegating, running, writing.
130
+ // Answered here, in the asker's language, without calling the model. Questions pass through.
42
131
  const ACTION_ES = /(^|[\s,.;:!¡])(convoca|convócalos|convócala|reúne|reune|delega|coordina|pídele|pidele|pídeles|pideles|dile|diles|ordena|manda|envía|envia|ejecuta|corre|instala|implementa|refactoriza|despliega|escribe|crea|modifica|edita|borra|elimina|genera|haz|hazlo|realiza|lanza|arranca|reinicia|configura|publica|sube)\b/i;
43
132
  const ACTION_EN = /(^|[\s,.;:!])(convene|summon|gather|schedule|delegate|coordinate|ask @|tell @|order|run|execute|install|implement|refactor|deploy|write|create|modify|edit|delete|remove|generate|build|commit|push|publish|launch|restart|configure|make (?:a|the|it)|call a meeting|set up)\b/i;
44
133
  const QUESTION = /[?¿]|(^|[^\p{L}])(qué|cuál|cuáles|cuándo|dónde|quién|quiénes|cómo|por qué|what|which|when|where|who|how|why|did|do we|have we|is there)(?!\p{L})/iu;
45
- const SPANISH = /[áéíóúñ¿¡]|\b(el|la|los|las|una?|que|para|con|de|todos|reunión|reunion)\b/i;
46
134
 
47
- export function actionRequest(text = '') {
48
- const body = String(text).trim().replace(/^@?madre[,:]?\s*/i, '').replace(/^\p{L}+,\s*/u, '');
135
+ export function actionRequest(text = '', { requester = 'you' } = {}) {
136
+ const body = stripVocative(text);
49
137
  if (!body || QUESTION.test(body)) return null;
50
138
  if (!ACTION_ES.test(body) && !ACTION_EN.test(body)) return null;
51
- return SPANISH.test(body)
52
- ? 'Solo respondo desde la memoria de la sala: no convoco, no delego, no ejecuto ni escribo. Para eso escríbele a @codex, @claude, @gemini u @opencode, los que estén en la fila. Si quieres, pregúntame qué recuerda la sala sobre esto y te lo cito.'
53
- : 'I only answer from the room\'s memory: I do not convene, delegate, run or write. For that, write to @codex, @claude, @gemini or @opencode, whichever is in the row. If you like, ask me what the room remembers about this and I will quote it.';
139
+ const es = lang(body) === 'es';
140
+ if (requester !== 'you') {
141
+ return es
142
+ ? 'No convoco, delego, ejecuto ni escribo: solo respondo desde la memoria de la sala. Pregúntame qué recuerda la sala sobre esto; para guardar algo usa memory_note.'
143
+ : 'I do not convene, delegate, run or write: I only answer from the room\'s memory. Ask me what the room remembers about this; to save something use memory_note.';
144
+ }
145
+ return es
146
+ ? 'Solo respondo desde la memoria de la sala: no delego, no ejecuto ni escribo. Para eso escríbele a @codex, @claude, @gemini u @opencode, los que estén en la fila. Si quieres que le pregunte algo a todo el crew, dímelo así: «@madre, pregúntale al crew …».'
147
+ : 'I only answer from the room\'s memory: I do not delegate, run or write. For that, write to @codex, @claude, @gemini or @opencode, whichever is in the row. If you want me to put a question to the whole crew, say: "@madre, ask the crew …".';
148
+ }
149
+
150
+ // Everything @madre settles without the model, in order. Null means the model answers.
151
+ export function localReply(text, { requester = 'you', crew = [], delegation = false, maxSteps = 4, model = null } = {}) {
152
+ if (!text || CLOSING_TURN.test(text)) return null;
153
+ const identity = whoAmI(text, { model, crew });
154
+ if (identity) return { kind: 'identity', text: identity };
155
+ if (requester === 'you' && delegation) {
156
+ const round = roundTable(text, { crew, maxSteps });
157
+ if (round) return { kind: 'roundtable', text: round.text, question: round.question, agents: round.agents };
158
+ }
159
+ const memory = memoryRequest(text, { requester });
160
+ if (memory) return { kind: 'memory', text: memory };
161
+ const declined = actionRequest(text, { requester });
162
+ if (declined) return { kind: 'declined', text: declined };
163
+ return null;
54
164
  }
55
165
 
56
166
  const NO_USAGE = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, totalTokens: 0, costUsd: 0, source: 'madre', local: true };
@@ -71,12 +181,12 @@ export async function gather(memory, text, { maxChars = 7000 } = {}) {
71
181
 
72
182
  // The invoker the room calls like any adapter. `text` is the human's request; `prompt` the room's full briefing.
73
183
  export function madreInvoker({ memory, ollama, fetchImpl = globalThis.fetch }) {
74
- return async ({ prompt, text = '', timeoutMs = 180000, model = null }) => {
184
+ return async ({ prompt, text = '', timeoutMs = 180000, model = null, requester = 'you', crew = [], delegation = false, maxSteps = 4 }) => {
75
185
  const state = typeof ollama === 'function' ? ollama() : ollama;
76
186
  if (!state?.running || !state.chatModel) throw new Error('@madre needs Ollama running with a chat model. Open MODULES → OLLAMA.');
77
187
  const question = text || prompt.match(/User message: ([\s\S]*)$/)?.[1] || prompt.slice(-2000);
78
- const declined = actionRequest(question);
79
- if (declined) return { text: declined, usage: { ...NO_USAGE }, grounded: { notes: 0, quotes: 0 }, declined: 'action' };
188
+ const local = localReply(question, { requester, crew, delegation, maxSteps, model: model ?? state.chatModel });
189
+ if (local) return { text: local.text, usage: { ...NO_USAGE }, grounded: { notes: 0, quotes: 0 }, synthetic: local.kind, declined: local.kind === 'declined' ? 'action' : undefined };
80
190
  const archive = await gather(memory, question);
81
191
  const briefing = briefingFor({ prompt, text: question, archive: archive.text });
82
192
  const answer = await ollamaGenerate({ host: state.host, model: model ?? state.chatModel, system: SYSTEM, prompt: briefing, fetchImpl, timeoutMs, temperature: 0.1 });
@@ -17,9 +17,11 @@ export function messageEntry(event) {
17
17
  return { sequence: event.sequence, messageId, role, sender, target, text: text.trim() };
18
18
  }
19
19
 
20
- export function buildConversationContext(events, { excludeMessageId, maxChars = 16000 } = {}) {
20
+ // `omitSynthetic` drops MADRE's own canned replies (identity, refusals, round-table plans):
21
+ // @madre must never read them back, or a small model starts echoing them.
22
+ export function buildConversationContext(events, { excludeMessageId, maxChars = 16000, omitSynthetic = false } = {}) {
21
23
  const messages = events
22
- .map(messageEntry)
24
+ .map((event) => (omitSynthetic && event?.payload?.synthetic ? null : messageEntry(event)))
23
25
  .filter((message) => message && message.messageId !== excludeMessageId);
24
26
  const selected = [];
25
27
  let remaining = Math.max(0, Number(maxChars) || 0);
package/src/dataset.mjs CHANGED
@@ -15,38 +15,80 @@ function system(project, agent) {
15
15
  return `You are @${agent} in the MADRE room of the project "${project}". Answer the human directly and concisely; distinguish facts from inference; name files, agents and numbers exactly.`;
16
16
  }
17
17
 
18
- // Pairs a user message with the assistant reply that answered it (parentMessageId).
19
- export function pairsFromEvents(events, { project = 'project', home, user } = {}) {
20
- const users = new Map();
18
+ // Pairs a question with the reply that answered it (parentMessageId). The question is the
19
+ // human's message, or an orchestrator's delegated step: both are real instructions with a
20
+ // real answer. What never trains MADRE: @madre's own answers (the student is not the teacher),
21
+ // MADRE's canned replies, replies the human rated bad, ghosts, one-liners.
22
+ const guard = (privacy) => (text) => (privacy ? privacy.redact(text).text : text);
23
+ export const STUDENT = 'madre';
24
+ export const DATASET_TARGET = 300;
25
+
26
+ export function ratingsFrom(events) {
27
+ const ratings = new Map();
28
+ for (const event of events) {
29
+ if (event.type !== 'message.rated' || !event.payload?.messageId) continue;
30
+ if (event.payload.rating === 'good' || event.payload.rating === 'bad') ratings.set(event.payload.messageId, event.payload.rating);
31
+ else ratings.delete(event.payload.messageId);
32
+ }
33
+ return ratings;
34
+ }
35
+
36
+ export function pairsFromEvents(events, { project = 'project', home, user, privacy = null } = {}) {
37
+ const clean = guard(privacy);
38
+ const asks = new Map();
39
+ const ratings = ratingsFrom(events);
21
40
  const pairs = [];
22
41
  for (const event of events) {
23
42
  if (event.type !== 'message.created' || event.ghost || !Number.isInteger(event.sequence)) continue;
24
43
  const p = event.payload ?? {};
25
44
  if (typeof p.text !== 'string' || !p.text.trim()) continue;
26
- if (p.role === 'user') { users.set(p.messageId, { text: p.text, mode: p.mode ?? 1, sequence: event.sequence }); continue; }
27
- if (p.role !== 'assistant' || p.status === 'delegated') continue;
28
- const ask = users.get(p.parentMessageId);
45
+ if (p.role === 'user') { asks.set(p.messageId, { text: p.originalText ?? p.text, mode: p.mode ?? 1, from: 'you' }); continue; }
46
+ if (p.role !== 'assistant') continue;
47
+ if (p.status === 'delegated') {
48
+ // An orchestrator's step is a question for the agent it names; its closing turn is not.
49
+ if (p.target && p.target !== p.sender) asks.set(p.messageId, { text: p.originalText ?? p.text, mode: p.mode ?? 1, from: p.sender });
50
+ continue;
51
+ }
52
+ if (p.sender === STUDENT || p.synthetic) continue;
53
+ if (ratings.get(p.messageId) === 'bad') continue;
54
+ const ask = asks.get(p.parentMessageId);
29
55
  if (!ask) continue;
30
56
  const answer = (p.originalText ?? p.text).trim();
31
57
  if (answer.length < MIN_ANSWER_CHARS) continue;
32
58
  pairs.push({
33
- kind: 'turn',
59
+ kind: ask.from === 'you' ? 'turn' : 'delegated',
34
60
  agent: p.sender,
61
+ askedBy: ask.from,
62
+ rating: ratings.get(p.messageId) ?? null,
35
63
  mode: p.mode ?? ask.mode,
36
64
  sequence: event.sequence,
37
65
  at: event.timestamp,
38
66
  messages: [
39
67
  { role: 'system', content: system(project, p.sender) },
40
- { role: 'user', content: redact(ask.text, { home, user }) },
41
- { role: 'assistant', content: redact(answer, { home, user }) },
68
+ { role: 'user', content: clean(redact(ask.text, { home, user })) },
69
+ { role: 'assistant', content: clean(redact(answer, { home, user })) },
42
70
  ],
43
71
  });
44
72
  }
45
73
  return pairs;
46
74
  }
47
75
 
76
+ // How far the corpus is from a LoRA worth running, without writing anything.
77
+ export function readiness(events, notes = [], { target = DATASET_TARGET } = {}) {
78
+ const pairs = pairsFromEvents(events);
79
+ const ratings = ratingsFrom(events);
80
+ let bad = 0;
81
+ for (const rating of ratings.values()) if (rating === 'bad') bad += 1;
82
+ const turns = pairs.filter((pair) => pair.kind === 'turn').length;
83
+ const delegated = pairs.length - turns;
84
+ const good = pairs.filter((pair) => pair.rating === 'good').length;
85
+ const total = pairs.length + notes.length;
86
+ return { pairs: total, turns, delegated, notes: notes.length, good, bad, target, ready: total >= target };
87
+ }
88
+
48
89
  // Distilled notes become recall pairs: "what does the room remember about …" → the note.
49
- export function pairsFromNotes(notes, { project = 'project', home, user } = {}) {
90
+ export function pairsFromNotes(notes, { project = 'project', home, user, privacy = null } = {}) {
91
+ const clean = guard(privacy);
50
92
  return notes.map((note) => ({
51
93
  kind: 'note',
52
94
  agent: 'madre',
@@ -55,8 +97,8 @@ export function pairsFromNotes(notes, { project = 'project', home, user } = {})
55
97
  at: note.created,
56
98
  messages: [
57
99
  { role: 'system', content: `You are @madre, the memory of the MADRE room of the project "${project}". Answer only from what the room decided and recorded.` },
58
- { role: 'user', content: `What does the room remember about this? Kind: ${note.kind}. Topic: ${redact(note.text, { home, user }).split(/[.;:]/)[0].slice(0, 80)}` },
59
- { role: 'assistant', content: `${redact(note.text, { home, user })} [#${note.fromSequence}${note.throughSequence !== note.fromSequence ? `–#${note.throughSequence}` : ''}]` },
100
+ { role: 'user', content: `What does the room remember about this? Kind: ${note.kind}. Topic: ${clean(redact(note.text, { home, user })).split(/[.;:]/)[0].slice(0, 80)}` },
101
+ { role: 'assistant', content: `${clean(redact(note.text, { home, user }))} [#${note.fromSequence}${note.throughSequence !== note.fromSequence ? `–#${note.throughSequence}` : ''}]` },
60
102
  ],
61
103
  }));
62
104
  }
@@ -70,8 +112,8 @@ export function split(pairs, { validEvery = 10 } = {}) {
70
112
  return { train, valid };
71
113
  }
72
114
 
73
- export async function exportDataset({ events, notes = [], dir, project = 'project', home, user }) {
74
- const pairs = [...pairsFromEvents(events, { project, home, user }), ...pairsFromNotes(notes, { project, home, user })].sort((a, b) => a.sequence - b.sequence);
115
+ export async function exportDataset({ events, notes = [], dir, project = 'project', home, user, privacy = null }) {
116
+ const pairs = [...pairsFromEvents(events, { project, home, user, privacy }), ...pairsFromNotes(notes, { project, home, user, privacy })].sort((a, b) => a.sequence - b.sequence);
75
117
  const { train, valid } = split(pairs);
76
118
  await mkdir(dir, { recursive: true });
77
119
  const line = (pair) => JSON.stringify({ messages: pair.messages });
@@ -79,7 +121,7 @@ export async function exportDataset({ events, notes = [], dir, project = 'projec
79
121
  await writeFile(join(dir, 'valid.jsonl'), valid.map(line).join('\n') + (valid.length ? '\n' : ''));
80
122
  const byAgent = {};
81
123
  for (const pair of pairs) byAgent[pair.agent] = (byAgent[pair.agent] ?? 0) + 1;
82
- const manifest = { project, exportedAt: new Date().toISOString(), pairs: pairs.length, turns: pairs.filter((p) => p.kind === 'turn').length, notes: pairs.filter((p) => p.kind === 'note').length, train: train.length, valid: valid.length, byAgent, files: ['train.jsonl', 'valid.jsonl'], format: 'chat · {"messages":[{role,content}]} · mlx-lm / llama-factory / axolotl' };
124
+ const manifest = { project, exportedAt: new Date().toISOString(), pairs: pairs.length, turns: pairs.filter((p) => p.kind === 'turn').length, delegated: pairs.filter((p) => p.kind === 'delegated').length, good: pairs.filter((p) => p.rating === 'good').length, notes: pairs.filter((p) => p.kind === 'note').length, train: train.length, valid: valid.length, byAgent, files: ['train.jsonl', 'valid.jsonl'], format: 'chat · {"messages":[{role,content}]} · mlx-lm / llama-factory / axolotl' };
83
125
  await writeFile(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
84
126
  return { dir, ...manifest };
85
127
  }
@@ -1,4 +1,4 @@
1
- import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
1
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
@@ -120,6 +120,34 @@ export class EventStore {
120
120
  throw new Error(`Timed out waiting for event store lock: ${this.#file}`);
121
121
  }
122
122
 
123
+ // Rewrites every event through `transform` (which returns the event to keep, changed or
124
+ // not), atomically, under the lock, in the same order and with the same sequences. The
125
+ // ledger is append-only for the room; this is the human's hand on it, used by the privacy
126
+ // purge. Appends queued behind it see the new file.
127
+ rewrite(transform) {
128
+ const operation = async () => {
129
+ const release = await this.#acquireLock();
130
+ try {
131
+ const existing = await this.#readAllUnlocked();
132
+ let changed = 0;
133
+ const next = existing.map((event) => {
134
+ const out = transform(event) ?? event;
135
+ if (out !== event) changed += 1;
136
+ return out;
137
+ });
138
+ const temp = `${this.#file}.rewrite-${process.pid}`;
139
+ await writeFile(temp, next.map((event) => JSON.stringify(event)).join('\n') + (next.length ? '\n' : ''));
140
+ await rename(temp, this.#file);
141
+ this.#nextSequence = (next.at(-1)?.sequence ?? 0) + 1;
142
+ return { total: next.length, changed };
143
+ } finally {
144
+ await release();
145
+ }
146
+ };
147
+ this.#writeQueue = this.#writeQueue.then(operation, operation);
148
+ return this.#writeQueue;
149
+ }
150
+
123
151
  append(type, payload) {
124
152
  const operation = async () => {
125
153
  const release = await this.#acquireLock();
package/src/memory.mjs CHANGED
@@ -74,6 +74,7 @@ export class RoomMemory {
74
74
  #meta;
75
75
  #setMeta;
76
76
  #embedder = null;
77
+ #privacy = null;
77
78
 
78
79
  constructor(file) {
79
80
  this.#file = file;
@@ -172,6 +173,62 @@ export class RoomMemory {
172
173
  /* ---------- embeddings ---------- */
173
174
 
174
175
  attachEmbedder(embedder) { this.#embedder = embedder ?? null; return this; }
176
+
177
+ // Private terms never enter the index or the notes, whoever wrote them.
178
+ attachPrivacy(privacy) { this.#privacy = privacy ?? null; return this; }
179
+ #guard(text) { return this.#privacy ? this.#privacy.redact(text).text : text; }
180
+
181
+ // What the archive still carries: entries and notes with a private term, for MU/TH/UR.
182
+ exposure() {
183
+ if (!this.#privacy?.enabled) return { entries: 0, memories: 0 };
184
+ let entries = 0;
185
+ for (const row of this.#db.prepare('SELECT text FROM entries').iterate()) if (this.#privacy.hits(row.text)) entries += 1;
186
+ let memories = 0;
187
+ for (const row of this.#db.prepare('SELECT text FROM memories').iterate()) if (this.#privacy.hits(row.text)) memories += 1;
188
+ return { entries, memories };
189
+ }
190
+
191
+ // Replaces private terms in every entry and note already kept. FTS tables are external
192
+ // content with insert/delete triggers only, so a changed row is deleted and re-inserted
193
+ // with its own rowid; its vector goes with it and is recomputed later. Distilled flags stay.
194
+ purge() {
195
+ if (!this.#privacy?.enabled) return { entries: 0, memories: 0 };
196
+ const counts = { entries: 0, memories: 0 };
197
+ const entryRows = this.#db.prepare('SELECT sequence, event_id, timestamp, type, role, sender, target, message_id, text, distilled FROM entries').all();
198
+ const memoryRows = this.#db.prepare('SELECT id, created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, message_id FROM memories').all();
199
+ const deleteEntry = this.#db.prepare('DELETE FROM entries WHERE sequence = ?');
200
+ const deleteEntryVector = this.#db.prepare('DELETE FROM entry_vectors WHERE sequence = ?');
201
+ const insertEntry = this.#db.prepare('INSERT INTO entries (sequence, event_id, timestamp, type, role, sender, target, message_id, text, distilled) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
202
+ const deleteMemory = this.#db.prepare('DELETE FROM memories WHERE id = ?');
203
+ const deleteMemoryVector = this.#db.prepare('DELETE FROM memory_vectors WHERE id = ?');
204
+ const insertMemory = this.#db.prepare('INSERT INTO memories (id, created, kind, text, norm, from_sequence, through_sequence, sources, agent, origin, message_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
205
+ this.#db.exec('BEGIN');
206
+ try {
207
+ for (const row of entryRows) {
208
+ const { text, hits } = this.#privacy.redact(row.text);
209
+ if (!hits) continue;
210
+ deleteEntryVector.run(row.sequence);
211
+ deleteEntry.run(row.sequence);
212
+ insertEntry.run(row.sequence, row.event_id, row.timestamp, row.type, row.role, row.sender, row.target, row.message_id, text, row.distilled);
213
+ counts.entries += 1;
214
+ }
215
+ for (const row of memoryRows) {
216
+ const { text, hits } = this.#privacy.redact(row.text);
217
+ if (!hits) continue;
218
+ deleteMemoryVector.run(row.id);
219
+ deleteMemory.run(row.id);
220
+ // Two notes may collapse into the same redacted sentence: the later one is dropped.
221
+ const clash = this.#db.prepare('SELECT id FROM memories WHERE norm = ?').get(normalizeMemory(text));
222
+ if (!clash) insertMemory.run(row.id, row.created, row.kind, text, normalizeMemory(text), row.from_sequence, row.through_sequence, row.sources, row.agent, row.origin, row.message_id);
223
+ counts.memories += 1;
224
+ }
225
+ this.#db.exec('COMMIT');
226
+ } catch (error) {
227
+ this.#db.exec('ROLLBACK');
228
+ throw error;
229
+ }
230
+ return counts;
231
+ }
175
232
  get embedder() { return this.#embedder; }
176
233
 
177
234
  // Entries and notes without a vector for the current model, oldest first.
@@ -296,7 +353,7 @@ export class RoomMemory {
296
353
  this.#db.exec('BEGIN');
297
354
  try {
298
355
  for (const memory of list) {
299
- const text = String(memory.text ?? '').trim();
356
+ const text = this.#guard(String(memory.text ?? '').trim());
300
357
  if (!text) continue;
301
358
  const kind = MEMORY_KINDS.includes(memory.kind) ? memory.kind : 'fact';
302
359
  const sources = (Array.isArray(memory.sources) ? memory.sources : []).filter((n) => Number.isInteger(n));
@@ -443,9 +500,11 @@ export class RoomMemory {
443
500
  for (const event of events) {
444
501
  if (!event || event.ghost || !Number.isInteger(event.sequence)) continue;
445
502
  if (event.sequence > last) last = event.sequence;
503
+ // MADRE's canned replies are not the room's knowledge; they never enter the archive.
504
+ if (event.payload?.synthetic) continue;
446
505
  const entry = messageEntry(event);
447
506
  if (!entry) continue;
448
- const result = this.#insert.run(event.sequence, event.id ?? `seq-${event.sequence}`, event.timestamp ?? null, event.type, entry.role, entry.sender, entry.target ?? null, entry.messageId ?? null, entry.text);
507
+ const result = this.#insert.run(event.sequence, event.id ?? `seq-${event.sequence}`, event.timestamp ?? null, event.type, entry.role, entry.sender, entry.target ?? null, entry.messageId ?? null, this.#guard(entry.text));
449
508
  added += Number(result.changes ?? 0);
450
509
  }
451
510
  this.#setMeta.run('last_sequence', String(last));