@jossuealcala/madre 0.3.0 → 0.3.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.
@@ -301,7 +301,7 @@ export const CONDITIONS = [
301
301
  severity: 'informational',
302
302
  title: 'Permission modes: #0 GHOST · #1 EXCHANGE · #2 CREATE · #3 CONTROL',
303
303
  match: /mode|ghost|exchange|control|override|designation|max mode|#[0-3]\b/i,
304
- diagnosis: 'Every message goes out at a mode, chosen in the chip after TO @agent (or typed as #2 in the text). #0 GHOST is off the record: nothing is saved, no other agent remembers it, gone on reload, no delegation. #1 EXCHANGE is the default: read and coordinate. #2 CREATE is the creation lease: files and images inside .pulse/out/. #3 CONTROL puts one agent in command of the project itself: a git checkpoint is taken first, every change is listed afterwards, writes into .git, .pulse or .env files are reverted on the spot, and UNDO restores the checkpoint. One holder at a time; it needs MAX MODE 3 and a git repository. Your mode is the ceiling of any plan the message starts, and #3 is never delegated. Each agent has a MAX MODE in CONNECTIONS; above it, #2 is answered read-only and #3 is refused. When a #1 plan reaches a step that wants to create something, the room pauses and asks you: GRANT ONCE, GRANT FOR PLAN or DENY, with a 3-minute clock; silence denies.',
304
+ diagnosis: 'Every message goes out at a mode, chosen in the chip after TO @agent (or typed as #2 in the text). #0 GHOST is off the record: nothing is saved, no other agent remembers it, gone on reload, no delegation. #1 EXCHANGE is the default: read and coordinate. #2 CREATE lets the agent add new files and folders anywhere in the project, where they belong; whatever existed before is put back after the turn and the room says so. #3 CONTROL puts one agent in command of the project itself: a git checkpoint is taken first, every change is listed afterwards, writes into .git, .pulse or .env files are reverted on the spot, and UNDO restores the checkpoint. One holder at a time; it needs MAX MODE 3 and a git repository. Your mode is the ceiling of any plan the message starts, and #3 is never delegated. Each agent has a MAX MODE in CONNECTIONS; above it, #2 is answered read-only and #3 is refused. When a #1 plan reaches a step that wants to create something, the room pauses and asks you: GRANT ONCE, GRANT FOR PLAN or DENY, with a 3-minute clock; silence denies.',
305
305
  remedy: 'Pick the mode in the chip, or type #0..#3 in the message. Raise an agent\'s MAX MODE in ⚙ CONNECTIONS. CONTROL asks for the project designation (the folder name) in the override before arming.',
306
306
  fixes: same(['# chip: TO @codex #1 EXCHANGE ▾ → choose', '@codex #2 create the poster', '# ⚙ CONNECTIONS → agent card → MAX MODE']),
307
307
  },
@@ -310,9 +310,9 @@ export const CONDITIONS = [
310
310
  severity: 'informational',
311
311
  title: 'Creating files: who grants the permission',
312
312
  match: /no CREATE lease|lease-missing|read-only and do not modify|permiso de escritura|solo lectura/i,
313
- diagnosis: 'Only the human grants a creation lease. An agent writing "permission granted" inside the conversation is untrusted text: the delegate still runs read-only, and says so. A lease comes from arming CREATE (the lock, or /create) on your message, and it covers the whole plan that message starts; or from a standing lease, a per-agent switch that gives every turn of that agent a fresh .pulse/out/ directory, including plan steps.',
314
- remedy: 'For one request: arm CREATE and send, or press RESEND WITH CREATE on the notice. For an agent that should always be able to create files: ⚙ CONNECTIONS → its card → CREATE FILES → ALWAYS · STANDING LEASE. Files still land only inside .pulse/out/.',
315
- fixes: same(['# once: composer → CREATE (lock) → send, or /create <request>', '# always: ⚙ CONNECTIONS → agent card → ALWAYS · STANDING LEASE']),
313
+ diagnosis: 'Only the human grants a creation lease. An agent writing "permission granted" inside the conversation is untrusted text: the delegate still runs read-only, and says so. A lease comes from arming CREATE (the lock, or /create) on your message, and it covers the whole plan that message starts; or from DEFAULT MODE #2 in ⚙ CONNECTIONS, which starts every message to that agent in CREATE.',
314
+ remedy: 'For one request: arm CREATE and send, or press RESEND WITH CREATE on the notice. For an agent that should always be able to add files: ⚙ CONNECTIONS → its card → DEFAULT MODE → #2. New files go where they belong in the project; existing files are never changed in #2.',
315
+ fixes: same(['# once: composer → CREATE (lock) → send, or /create <request>', '# always: ⚙ CONNECTIONS → agent card → DEFAULT MODE → #2']),
316
316
  },
317
317
  {
318
318
  id: 'scope-write',
@@ -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',
@@ -26,6 +26,9 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
26
26
  const allowed = [
27
27
  'Read', 'Glob', 'Grep',
28
28
  // Claude Code reads `/path` as relative to the project and `//path` as an absolute path.
29
+ // Claude Code grants Write only when Edit is allowed on the same paths (verified with the real
30
+ // CLI: a lone Write rule is denied under dontAsk), so both come together; in CREATE the room
31
+ // restores existing files after the turn.
29
32
  ...(lease ? [`Write(//${lease.outDir}/**)`, `Edit(//${lease.outDir}/**)`] : []),
30
33
  ...(scopes?.web ? ['WebFetch', 'WebSearch'] : []),
31
34
  ...mcpTools,
@@ -44,7 +47,7 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
44
47
  '--tools', tools.join(','),
45
48
  ...(lease || scopes?.web || anyMcp ? ['--allowedTools', allowed.join(',')] : []),
46
49
  // CONTROL: the whole project is writable except MADRE's forbidden zones.
47
- ...(lease?.control ? ['--disallowedTools', ['.git/**', '.pulse/**', '.env', '.env.*', '**/.env', '**/.env.*'].flatMap((glob) => [`Write(//${lease.outDir}/${glob})`, `Edit(//${lease.outDir}/${glob})`]).join(',')] : []),
50
+ ...(lease?.control || lease?.create ? ['--disallowedTools', ['.git/**', '.pulse/**', '.madre/**', '.env', '.env.*', '**/.env', '**/.env.*', '.claude/settings.local.json'].flatMap((glob) => [`Write(//${lease.outDir}/${glob})`, `Edit(//${lease.outDir}/${glob})`]).join(',')] : []),
48
51
  // --safe-mode disables every MCP server, ours included. With a MADRE server
49
52
  // attached we drop it and instead load no setting sources at all: no user
50
53
  // hooks, plugins or MCP servers, only the project's CLAUDE.md and ours.
@@ -27,19 +27,22 @@ export const geminiCredentialFiles = ['oauth_creds.json', 'google_accounts.json'
27
27
  // whose file_path argument starts with the lease directory. Plan mode would
28
28
  // block every write regardless of policy, so a lease uses approval "default":
29
29
  // headless Gemini cannot prompt, so anything the policy does not allow fails.
30
- export function geminiLeasePolicy(outDir, { control = false } = {}) {
30
+ export function geminiLeasePolicy(outDir, { control = false, create = false } = {}) {
31
31
  const escaped = outDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
- const forbidden = control ? `
32
+ // The whole project is the lease in #2 and #3: MADRE's zones stay out of reach.
33
+ const forbidden = control || create ? `
33
34
  [[rule]]
34
35
  toolName = ["write_file", "replace", "edit", "run_shell_command"]
35
- argsPattern = '${escaped}/(\\.git|\\.pulse|\\.env)'
36
+ argsPattern = '${escaped}/(\\.git|\\.pulse|\\.madre|\\.env|\\.claude/settings\\.local\\.json)'
36
37
  decision = "deny"
37
38
  priority = 1100
38
39
  interactive = false
39
40
  ` : '';
41
+ // CREATE (#2) adds files: write_file only; CONTROL (#3) also replaces and edits.
42
+ const tools = create ? '["write_file"]' : '["write_file", "replace", "edit"]';
40
43
  return `${geminiReadonlyPolicy}
41
44
  [[rule]]
42
- toolName = ["write_file", "replace", "edit"]
45
+ toolName = ${tools}
43
46
  argsPattern = '"file_path"\\s*:\\s*"${escaped}/'
44
47
  decision = "allow"
45
48
  priority = 1000
@@ -56,7 +59,7 @@ interactive = false
56
59
  `;
57
60
 
58
61
  export function geminiPolicy({ lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
59
- return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control) }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}`;
62
+ return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create) }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}`;
60
63
  }
61
64
 
62
65
  export function geminiMemoryPolicy(memoryServer) {
@@ -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 });
@@ -34,19 +34,36 @@ export function buildOpenCodeArgs({ projectRoot, prompt, model = process.env.PUL
34
34
  ];
35
35
  }
36
36
 
37
- export function leaseConfig(outDir, { control = false } = {}) {
38
- const forbidden = control ? Object.fromEntries(['.git/**', '.pulse/**', '.env', '.env.*', '**/.env', '**/.env.*'].map((glob) => [`${outDir}/${glob}`, 'deny'])) : {};
37
+ // OpenCode matches permission patterns against paths relative to `--dir`, last matching rule
38
+ // wins, and creating a file is the `write` tool while changing one is `edit`: both need a rule.
39
+ // Verified against opencode 1.18.4: absolute patterns never match, so the lease is named by
40
+ // its directory relative to the project.
41
+ export const FORBIDDEN_GLOBS = ['.git/**', '.pulse/**', '.madre/**', '.env', '.env.*', '**/.env', '**/.env.*', '.claude/settings.local.json'];
42
+ export function writeRules(relativeDir, { control = false, create = false } = {}) {
43
+ if (control || create) return { '*': 'allow', ...Object.fromEntries(FORBIDDEN_GLOBS.map((glob) => [glob, 'deny'])) };
44
+ const dir = String(relativeDir ?? '.').replace(/^\.\/+/, '').replace(/\/+$/, '');
45
+ return { '*': 'deny', [dir && dir !== '.' ? `${dir}/**` : '**']: 'allow' };
46
+ }
47
+
48
+ export function leaseConfig(outDir, { control = false, create = false, relativeDir = null } = {}) {
49
+ const rules = writeRules(relativeDir ?? outDir, { control, create });
50
+ // OpenCode gates its `write` tool behind the `edit` permission as well (denying edit leaves "no
51
+ // file-writing tool"), so CREATE allows both and the room restores existing files afterwards.
52
+ const editRules = rules;
39
53
  return {
40
54
  ...readonlyConfig,
41
55
  agent: {
42
56
  'pulse-readonly': {
43
57
  ...readonlyConfig.agent['pulse-readonly'],
44
58
  prompt: control
45
- ? 'Answer the user directly. You are in CONTROL of this project: create and edit files anywhere inside it except .git, .pulse and .env files. Do not run commands, browse the web, or launch subagents.'
46
- : 'Answer the user directly. Inspect project files when necessary. You may create or edit files only inside the creation lease directory named in the request; never elsewhere. Do not run commands, browse the web, or launch subagents.',
59
+ ? 'Answer the user directly. You are in CONTROL of this project: create and edit files anywhere inside it except .git, .pulse, .madre and .env files. Do not run commands, browse the web, or launch subagents.'
60
+ : create
61
+ ? 'Answer the user directly. You may create new files and folders anywhere in this project where they belong; do not modify or delete existing files. Do not run commands, browse the web, or launch subagents.'
62
+ : 'Answer the user directly. Inspect project files when necessary. You may create or edit files only inside the creation lease directory named in the request; never elsewhere. Do not run commands, browse the web, or launch subagents.',
47
63
  permission: {
48
64
  ...readonlyConfig.agent['pulse-readonly'].permission,
49
- edit: { '*': 'deny', [`${outDir}/**`]: 'allow', ...forbidden },
65
+ edit: editRules,
66
+ write: rules,
50
67
  },
51
68
  },
52
69
  },
@@ -54,7 +71,7 @@ export function leaseConfig(outDir, { control = false } = {}) {
54
71
  }
55
72
 
56
73
  export function openCodeConfig({ lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
57
- let config = lease ? leaseConfig(lease.outDir, { control: Boolean(lease.control) }) : readonlyConfig;
74
+ let config = lease ? leaseConfig(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create), relativeDir: lease.relativeDir ?? null }) : readonlyConfig;
58
75
  if (scopes?.web) {
59
76
  config = {
60
77
  ...config,
@@ -108,14 +108,16 @@ export function resolveScopes(agentId, configured = {}) {
108
108
  const wanted = configured[scope] ?? (scope !== 'web');
109
109
  scopes[scope] = { capable, enabled: capable && wanted, wired: true };
110
110
  }
111
- // A standing lease: every turn of this agent may create files inside
112
- // .pulse/out/ without the human arming CREATE each time. Opt-in per agent.
113
- scopes.write.always = Boolean(configured.alwaysCreate) && scopes.write.enabled;
114
- // The ceiling: how far the composer may take this agent. Writing needs the
115
- // CLI to be able to write; CONTROL is capped at 2 until phase C wires it.
116
- const wanted = normalizeMode(configured.maxMode, scopes.write.enabled ? 2 : 1);
117
- scopes.maxMode = Math.min(wanted, scopes.write.capable ? 3 : 1);
118
- scopes.defaultMode = scopes.write.always && scopes.maxMode >= 2 ? 2 : 1;
111
+ // One ceiling per agent: MAX MODE. Writing follows from it: an agent capped at #1 never
112
+ // writes, one allowed to #2 or #3 does. A `write: false` from an older config reads as #1.
113
+ const capableMax = scopes.write.capable ? 3 : 1;
114
+ const legacyCap = configured.write === false ? 1 : 3;
115
+ scopes.maxMode = Math.min(normalizeMode(configured.maxMode, scopes.write.capable ? 2 : 1), legacyCap, capableMax);
116
+ scopes.write.enabled = scopes.write.capable && scopes.maxMode >= 2;
117
+ // One start per agent: DEFAULT MODE, #1 or #2, never above the ceiling. An older
118
+ // `alwaysCreate` reads as #2. `write.always` keeps its name for the room's code.
119
+ scopes.defaultMode = Math.min(normalizeMode(configured.defaultMode, configured.alwaysCreate ? 2 : 1), 2, Math.max(1, scopes.maxMode));
120
+ scopes.write.always = scopes.defaultMode >= 2 && scopes.write.enabled;
119
121
  return scopes;
120
122
  }
121
123
 
@@ -1,7 +1,8 @@
1
1
  import { execFile } from 'node:child_process';
2
- import { access, mkdtemp, rm, unlink } from 'node:fs/promises';
2
+ import { access, mkdir, mkdtemp, readdir, rm, unlink } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join, resolve, sep } from 'node:path';
5
+ import { createHash } from 'node:crypto';
5
6
  import { promisify } from 'node:util';
6
7
 
7
8
  const execFileAsync = promisify(execFile);
@@ -25,50 +26,143 @@ export const FORBIDDEN = [
25
26
  ];
26
27
  export const isForbidden = (path) => FORBIDDEN.some((pattern) => pattern.test(path));
27
28
 
28
- async function git(root, args, { env = {} } = {}) {
29
- const { stdout } = await execFileAsync('git', ['-c', 'core.quotepath=off', ...args], { cwd: root, env: { ...process.env, GIT_PAGER: 'cat', ...env }, maxBuffer: 16 * 1024 * 1024 });
30
- return stdout;
31
- }
32
-
33
29
  export async function isGitRepo(root) {
34
30
  return access(join(root, '.git')).then(() => true, () => false);
35
31
  }
36
32
 
33
+ // A project that is not a git repository still gets photographs: MADRE keeps a shadow
34
+ // repository of its own outside the project (one per project path) and runs git with the
35
+ // project as work tree. Nothing appears inside the project; UNDO and diffs work the same.
36
+ const shadows = new Map();
37
+ async function shadowFor(root) {
38
+ const canonical = resolve(root);
39
+ if (!shadows.has(canonical)) {
40
+ const dir = join(tmpdir(), 'madre-shadow', createHash('sha1').update(canonical).digest('hex').slice(0, 16));
41
+ await mkdir(dir, { recursive: true });
42
+ const isRepo = await access(join(dir, 'HEAD')).then(() => true, () => false);
43
+ if (!isRepo) await execFileAsync('git', ['init', '-q', '--bare', dir]);
44
+ shadows.set(canonical, dir);
45
+ }
46
+ return shadows.get(canonical);
47
+ }
48
+ async function gitArgs(root) {
49
+ if (await isGitRepo(root)) return [];
50
+ return ['--git-dir', await shadowFor(root), '--work-tree', root];
51
+ }
52
+
53
+ async function git(root, args, { env = {} } = {}) {
54
+ const { stdout } = await execFileAsync('git', ['-c', 'core.quotepath=off', ...(await gitArgs(root)), ...args], { cwd: root, env: { ...process.env, GIT_PAGER: 'cat', ...env }, maxBuffer: 16 * 1024 * 1024 });
55
+ return stdout;
56
+ }
57
+
37
58
  // A tree object of the working tree as it is right now: tracked and untracked
38
59
  // files, ignored ones left out. Built in a temporary index.
39
- export async function worktreeTree(root) {
60
+ export async function worktreeTree(root, { exclude = [] } = {}) {
40
61
  const dir = await mkdtemp(join(tmpdir(), 'madre-index-'));
41
62
  const index = join(dir, 'index');
42
63
  try {
43
64
  const env = { GIT_INDEX_FILE: index };
65
+ const extra = await gitArgs(root);
44
66
  await git(root, ['read-tree', '--empty'], { env });
45
- await git(root, ['add', '-A', '--', '.'], { env });
67
+ // Every file of the working tree that git would not ignore, listed against the empty
68
+ // index. Nested repositories come back as `dir/` entries and are left out: they are
69
+ // photographed on their own, and `git add` would refuse one without commits anyway.
70
+ const listed = await git(root, ['ls-files', '-z', '--others', '--exclude-standard', '--', '.'], { env });
71
+ const skip = new Set(exclude.map((path) => path.replace(/\/$/, '')));
72
+ // Lock folders (MADRE's own `*.lock/`) come and go between the listing and the indexing.
73
+ const files = listed.split('\0').filter((path) => path && !path.endsWith('/') && !/(^|\/)[^/]+\.lock\//.test(path) && ![...skip].some((dir) => path === dir || path.startsWith(`${dir}/`)));
74
+ if (files.length) {
75
+ // --remove: a file that vanished since the listing is dropped instead of aborting the photograph.
76
+ const index_ = (list) => new Promise((resolvePromise, reject) => {
77
+ const child = execFile('git', ['-c', 'core.quotepath=off', ...extra, 'update-index', '--add', '--remove', '-z', '--stdin'], { cwd: root, env: { ...process.env, GIT_PAGER: 'cat', ...env }, maxBuffer: 64 * 1024 * 1024 }, (error) => (error ? reject(error) : resolvePromise()));
78
+ child.stdin.end(list.join('\0') + '\0');
79
+ });
80
+ try { await index_(files); } catch {
81
+ // Something still raced us: photograph what exists right now.
82
+ const alive = [];
83
+ for (const path of files) if (await access(join(root, path)).then(() => true, () => false)) alive.push(path);
84
+ if (alive.length) await index_(alive);
85
+ }
86
+ }
46
87
  return (await git(root, ['write-tree'], { env })).trim();
47
88
  } finally {
48
89
  await rm(dir, { recursive: true, force: true });
49
90
  }
50
91
  }
51
92
 
52
- export async function createCheckpoint(root, { id, label = 'MADRE checkpoint' } = {}) {
53
- const tree = await worktreeTree(root);
93
+ // Repositories nested inside the project (a monorepo of sites, each with its own .git). The
94
+ // outer git sees them as a single entry and never notices what changes inside, so each one
95
+ // gets its own photograph. Ignored folders are skipped; depth is capped.
96
+ const NESTED_SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.pulse', '.madre', 'vendor']);
97
+ export async function nestedRepos(root, { maxDepth = 3 } = {}) {
98
+ const found = [];
99
+ const walk = async (dir, rel, depth) => {
100
+ let entries = [];
101
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
102
+ for (const entry of entries) {
103
+ if (!entry.isDirectory() || NESTED_SKIP.has(entry.name)) continue;
104
+ const path = join(dir, entry.name);
105
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
106
+ if (await isGitRepo(path)) { found.push(relPath); continue; } // a repo's own nested repos are its business
107
+ if (depth + 1 < maxDepth) await walk(path, relPath, depth + 1);
108
+ }
109
+ };
110
+ await walk(root, '', 0);
111
+ return found.sort();
112
+ }
113
+
114
+ async function checkpointOne(root, { id, label, exclude = [] }) {
115
+ const tree = await worktreeTree(root, { exclude });
54
116
  const head = (await git(root, ['rev-parse', '--verify', '-q', 'HEAD']).catch(() => '')).trim() || null;
55
117
  const commit = (await git(root, ['commit-tree', tree, ...(head ? ['-p', head] : []), '-m', `${label} ${id}`], { env: { GIT_AUTHOR_NAME: 'MADRE', GIT_AUTHOR_EMAIL: 'madre@localhost', GIT_COMMITTER_NAME: 'MADRE', GIT_COMMITTER_EMAIL: 'madre@localhost' } })).trim();
56
118
  await git(root, ['update-ref', `${CHECKPOINT_REF}/${id}`, commit]);
57
- return { id, commit, tree, head, createdAt: new Date().toISOString() };
119
+ return { commit, tree, head };
120
+ }
121
+
122
+ export async function createCheckpoint(root, { id, label = 'MADRE checkpoint' } = {}) {
123
+ const dirs = await nestedRepos(root);
124
+ const outer = await checkpointOne(root, { id, label, exclude: dirs });
125
+ const nested = [];
126
+ for (const dir of dirs) {
127
+ try { nested.push({ dir, ...(await checkpointOne(join(root, dir), { id, label })) }); } catch { /* a broken nested repo is left alone */ }
128
+ }
129
+ return { id, ...outer, nested, exclude: dirs, createdAt: new Date().toISOString() };
58
130
  }
59
131
 
60
132
  // What changed since the checkpoint: name-status per file and git's --stat.
61
- export async function diffCheckpoint(root, checkpoint) {
62
- const after = await worktreeTree(root);
63
- if (after === checkpoint.tree) return { afterTree: after, files: [], stat: '', forbidden: [] };
64
- const nameStatus = await git(root, ['diff', '--name-status', '-M', checkpoint.tree, after]);
133
+ async function diffOne(root, before, prefix = '', exclude = []) {
134
+ const after = await worktreeTree(root, { exclude });
135
+ if (after === before.tree) return { files: [], stat: '' };
136
+ const nameStatus = await git(root, ['diff', '--name-status', '-M', before.tree, after]);
65
137
  const files = nameStatus.split('\n').filter(Boolean).map((line) => {
66
138
  const [status, ...rest] = line.split('\t');
67
- const path = rest.at(-1);
68
- return { status: status[0], path, from: status[0] === 'R' ? rest[0] : undefined };
139
+ const path = prefix + rest.at(-1);
140
+ return { status: status[0], path, from: status[0] === 'R' ? prefix + rest[0] : undefined };
69
141
  });
70
- const stat = (await git(root, ['diff', '--stat=100', checkpoint.tree, after])).trim();
71
- return { afterTree: after, files, stat, forbidden: files.filter((file) => isForbidden(file.path)).map((file) => file.path) };
142
+ const stat = (await git(root, ['diff', '--stat=100', before.tree, after])).trim();
143
+ return { files, stat: prefix && stat ? stat.split('\n').map((line) => (line.startsWith(' ') ? ` ${prefix}${line.trimStart()}` : line)).join('\n') : stat };
144
+ }
145
+
146
+ // What changed since the checkpoint, in the project and in every nested repository, paths
147
+ // relative to the project. Forbidden zones are judged on the path inside each repository too.
148
+ export async function diffCheckpoint(root, checkpoint) {
149
+ const outer = await diffOne(root, checkpoint, '', checkpoint.exclude ?? []);
150
+ const parts = [outer];
151
+ for (const part of checkpoint.nested ?? []) {
152
+ try { parts.push(await diffOne(join(root, part.dir), part, `${part.dir}/`)); } catch { /* left alone */ }
153
+ }
154
+ const files = parts.flatMap((part) => part.files);
155
+ const stat = parts.map((part) => part.stat).filter(Boolean).join('\n');
156
+ const forbiddenIn = (path) => isForbidden(path) || (checkpoint.nested ?? []).some((part) => path.startsWith(`${part.dir}/`) && isForbidden(path.slice(part.dir.length + 1)));
157
+ return { afterTree: outer.afterTree ?? null, files, stat, forbidden: files.filter((file) => forbiddenIn(file.path)).map((file) => file.path) };
158
+ }
159
+
160
+ // Which repository a project-relative path belongs to, and the path inside it.
161
+ function repoFor(root, checkpoint, path) {
162
+ for (const part of checkpoint.nested ?? []) {
163
+ if (path.startsWith(`${part.dir}/`)) return { dir: join(root, part.dir), commit: part.commit, inner: path.slice(part.dir.length + 1) };
164
+ }
165
+ return { dir: root, commit: checkpoint.commit, inner: path };
72
166
  }
73
167
 
74
168
  // Put the working tree back to the checkpoint: files added since are removed,
@@ -83,18 +177,20 @@ export async function restoreCheckpoint(root, checkpoint, { paths = null } = {})
83
177
  for (const file of chosen) {
84
178
  const absolute = resolve(root, file.path);
85
179
  if (!absolute.startsWith(canonicalRoot + sep)) continue;
180
+ const repo = repoFor(root, checkpoint, file.path);
86
181
  if (file.status === 'A') {
87
182
  await unlink(absolute).catch(() => {});
88
183
  removed.push(file.path);
89
184
  } else {
90
185
  if (file.status === 'R' && file.from) {
91
186
  await unlink(absolute).catch(() => {});
92
- await git(root, ['restore', '--source', checkpoint.commit, '--worktree', '--', file.from]);
187
+ const origin = repoFor(root, checkpoint, file.from);
188
+ await git(origin.dir, ['restore', '--source', origin.commit, '--worktree', '--', origin.inner]);
93
189
  restored.push(file.from);
94
190
  removed.push(file.path);
95
191
  continue;
96
192
  }
97
- await git(root, ['restore', '--source', checkpoint.commit, '--worktree', '--', file.path]);
193
+ await git(repo.dir, ['restore', '--source', repo.commit, '--worktree', '--', repo.inner]);
98
194
  restored.push(file.path);
99
195
  }
100
196
  }
@@ -103,4 +199,5 @@ export async function restoreCheckpoint(root, checkpoint, { paths = null } = {})
103
199
 
104
200
  export async function dropCheckpoint(root, checkpoint) {
105
201
  await git(root, ['update-ref', '-d', `${CHECKPOINT_REF}/${checkpoint.id}`]).catch(() => {});
202
+ for (const part of checkpoint.nested ?? []) await git(join(root, part.dir), ['update-ref', '-d', `${CHECKPOINT_REF}/${checkpoint.id}`]).catch(() => {});
106
203
  }
@@ -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);