@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.
- package/CHANGELOG.md +44 -0
- package/CONTRIBUTING.md +6 -1
- package/README.md +146 -219
- package/bin/madre.mjs +8 -0
- package/docs/INTERNALS.md +81 -0
- package/docs/training/Modelfile +5 -0
- package/docs/training/README.md +47 -0
- package/docs/training/train.sh +17 -0
- package/package.json +4 -2
- package/public/app.js +270 -44
- package/public/index.html +2 -0
- package/public/styles.css +40 -11
- package/public/troubleshooting.js +13 -4
- package/src/adapters/claude.mjs +4 -1
- package/src/adapters/gemini.mjs +8 -5
- package/src/adapters/madre.mjs +122 -12
- package/src/adapters/opencode.mjs +23 -6
- package/src/capabilities.mjs +10 -8
- package/src/checkpoint.mjs +118 -21
- package/src/conversation-context.mjs +4 -2
- package/src/dataset.mjs +57 -15
- package/src/directives.mjs +7 -4
- package/src/event-store.mjs +29 -1
- package/src/lease.mjs +12 -7
- package/src/memory.mjs +61 -2
- package/src/privacy.mjs +109 -0
- package/src/room/context.mjs +3 -3
- package/src/room/control.mjs +23 -11
- package/src/room/prompt.mjs +8 -4
- package/src/room.mjs +148 -32
- package/src/server.mjs +81 -6
- package/src/updates.mjs +79 -0
|
@@ -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
|
|
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
|
|
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
|
|
315
|
-
fixes: same(['# once: composer → CREATE (lock) → send, or /create <request>', '# always: ⚙ CONNECTIONS → agent card →
|
|
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',
|
package/src/adapters/claude.mjs
CHANGED
|
@@ -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.
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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) {
|
package/src/adapters/madre.mjs
CHANGED
|
@@ -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
|
-
//
|
|
41
|
-
//
|
|
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 =
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
79
|
-
if (
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
:
|
|
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:
|
|
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,
|
package/src/capabilities.mjs
CHANGED
|
@@ -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
|
-
//
|
|
112
|
-
//
|
|
113
|
-
scopes.write.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
package/src/checkpoint.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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 {
|
|
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
|
-
|
|
62
|
-
const after = await worktreeTree(root);
|
|
63
|
-
if (after ===
|
|
64
|
-
const nameStatus = await git(root, ['diff', '--name-status', '-M',
|
|
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',
|
|
71
|
-
return {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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);
|