@jossuealcala/madre 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ // The names below are the names of formats — JavaScript, JSON, CSS, Markdown — and a format is
2
+ // called the same in every language, so they never pass through the catalogue.
3
+ //
4
+ // Reading a file in the room, not just seeing it. MADRE has no dependencies and is not about to
5
+ // take one for this: a highlighter good enough for a viewer is a few hundred lines, and a
6
+ // highlighter that has to be perfect is somebody else's product.
7
+ //
8
+ // The rule it follows is the one that keeps it honest: it never rewrites the file. It hands back
9
+ // spans over the same characters, in the same order, so what the screen shows and what is on disk
10
+ // are the same bytes. Anything it cannot classify stays plain text rather than being guessed at.
11
+
12
+ const FAMILIES = {
13
+ js: { ext: /\.(m|c)?jsx?$|\.tsx?$/i, label: 'JavaScript',
14
+ keywords: /\b(await|async|break|case|catch|class|const|continue|default|delete|do|else|export|extends|finally|for|from|function|get|if|import|in|instanceof|let|new|of|return|set|static|super|switch|this|throw|try|typeof|var|void|while|yield|as|interface|type|enum|implements|readonly|declare|namespace|satisfies)\b/,
15
+ literals: /\b(true|false|null|undefined|NaN|Infinity)\b/,
16
+ line: /\/\/.*/, block: [/\/\*/, /\*\//], strings: ['"', "'", '`'] },
17
+ json: { ext: /\.json(c|5)?$|^\.?[a-z-]*rc$/i, label: 'JSON',
18
+ keywords: /(?!)/, literals: /\b(true|false|null)\b/, line: /(?!)/, block: null, strings: ['"'] },
19
+ css: { ext: /\.(css|scss|less|sass)$/i, label: 'CSS',
20
+ keywords: /@[a-z-]+|\b(important)\b/, literals: /#[0-9a-f]{3,8}\b|\b\d+(\.\d+)?(px|rem|em|%|vh|vw|s|ms|fr|deg)\b/i,
21
+ line: /\/\/.*/, block: [/\/\*/, /\*\//], strings: ['"', "'"] },
22
+ html: { ext: /\.(html?|xhtml|xml|svg|vue|svelte)$/i, label: 'HTML',
23
+ keywords: /<\/?[a-zA-Z][\w:-]*|\/?>/, literals: /(?!)/, line: /(?!)/, block: [/<!--/, /-->/], strings: ['"', "'"] },
24
+ md: { ext: /\.(md|markdown|mdx)$/i, label: 'Markdown',
25
+ keywords: /^#{1,6}\s.*$|^\s*[-*+]\s|^\s*\d+\.\s|^>\s?.*$/, literals: /\*\*[^*]+\*\*|__[^_]+__|\[[^\]]*\]\([^)]*\)/,
26
+ line: /(?!)/, block: null, strings: ['`'] },
27
+ py: { ext: /\.pyi?$/i, label: 'Python',
28
+ keywords: /\b(and|as|assert|async|await|break|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield|match|case)\b/,
29
+ literals: /\b(True|False|None|self|cls)\b/, line: /#.*/, block: null, strings: ['"', "'"] },
30
+ sh: { ext: /\.(sh|bash|zsh|fish)$|^(Makefile|Dockerfile|\.env.*)$/i, label: 'Shell',
31
+ keywords: /\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|export|local|source|set|trap|in)\b/,
32
+ literals: /\$\{?[A-Za-z_][\w]*\}?|\$\(.*?\)/, line: /#.*/, block: null, strings: ['"', "'"] },
33
+ yaml: { ext: /\.(ya?ml|toml|ini|cfg|conf)$/i, label: 'Config',
34
+ keywords: /^\s*[\w.-]+(?=\s*[:=])/, literals: /\b(true|false|null|yes|no|on|off)\b/, line: /#.*/, block: null, strings: ['"', "'"] },
35
+ sql: { ext: /\.sql$/i, label: 'SQL',
36
+ keywords: /\b(select|from|where|insert|into|values|update|set|delete|create|table|index|drop|alter|join|left|right|inner|outer|on|group|order|by|having|limit|offset|and|or|not|null|primary|key|foreign|references|as|distinct|union|with)\b/i,
37
+ literals: /\b\d+\b/, line: /--.*/, block: [/\/\*/, /\*\//], strings: ["'", '"'] },
38
+ };
39
+
40
+ // What this file is, by its name alone. A family MADRE does not know is not an error: the file is
41
+ // shown as plain text, which is what it would have been anyway.
42
+ export function familyOf(name) {
43
+ const base = String(name ?? '').split('/').pop() ?? '';
44
+ for (const [id, family] of Object.entries(FAMILIES)) if (family.ext.test(base)) return { id, ...family };
45
+ return null;
46
+ }
47
+
48
+ export function languageLabel(name) {
49
+ return familyOf(name)?.label ?? null;
50
+ }
51
+
52
+ // One line, cut into pieces. Returns [{ text, kind }] covering exactly the input: joining every
53
+ // `text` back together must give the line unchanged, which is what the test holds it to.
54
+ export function tokenize(line, family, state = { inBlock: false }) {
55
+ if (!family) return [{ text: line, kind: null }];
56
+ const out = [];
57
+ let index = 0;
58
+ const push = (text, kind) => { if (text) out.push({ text, kind }); };
59
+
60
+ while (index < line.length) {
61
+ const rest = line.slice(index);
62
+
63
+ if (state.inBlock) {
64
+ const close = family.block ? rest.match(family.block[1]) : null;
65
+ if (!close) { push(rest, 'comment'); index = line.length; break; }
66
+ push(rest.slice(0, close.index + close[0].length), 'comment');
67
+ index += close.index + close[0].length;
68
+ state.inBlock = false;
69
+ continue;
70
+ }
71
+
72
+ const blockOpen = family.block ? rest.match(family.block[0]) : null;
73
+ const lineOpen = rest.match(family.line);
74
+ const quote = family.strings.map((q) => rest.indexOf(q)).filter((at) => at >= 0).sort((a, b) => a - b)[0];
75
+
76
+ // Whichever comes first on this line decides what happens next.
77
+ const marks = [
78
+ blockOpen && blockOpen.index === 0 ? { at: 0, what: 'block' } : blockOpen ? { at: blockOpen.index, what: 'block' } : null,
79
+ lineOpen && lineOpen.index !== undefined ? { at: lineOpen.index, what: 'line' } : null,
80
+ quote !== undefined ? { at: quote, what: 'string' } : null,
81
+ ].filter(Boolean).sort((a, b) => a.at - b.at);
82
+ const next = marks[0];
83
+
84
+ if (!next) { pushCode(rest, family, push); index = line.length; break; }
85
+ if (next.at > 0) { pushCode(rest.slice(0, next.at), family, push); index += next.at; continue; }
86
+
87
+ if (next.what === 'line') { push(rest, 'comment'); index = line.length; break; }
88
+ if (next.what === 'block') {
89
+ const opened = rest.match(family.block[0])[0];
90
+ const after = rest.slice(opened.length);
91
+ const close = after.match(family.block[1]);
92
+ if (close) { push(rest.slice(0, opened.length + close.index + close[0].length), 'comment'); index += opened.length + close.index + close[0].length; continue; }
93
+ push(rest, 'comment'); index = line.length; state.inBlock = true; break;
94
+ }
95
+
96
+ // A string: to its closing quote, respecting a backslash escape.
97
+ const q = rest[0];
98
+ let at = 1;
99
+ while (at < rest.length) {
100
+ if (rest[at] === '\\') { at += 2; continue; }
101
+ if (rest[at] === q) { at += 1; break; }
102
+ at += 1;
103
+ }
104
+ push(rest.slice(0, at), 'string');
105
+ index += at;
106
+ }
107
+ return out;
108
+ }
109
+
110
+ // Code between the comments and the strings: keywords, literals, numbers, and the rest plain.
111
+ function pushCode(chunk, family, push) {
112
+ const pattern = new RegExp(`${family.keywords.source}|${family.literals.source}|\\b\\d+(?:\\.\\d+)?\\b`, family.keywords.flags.includes('i') ? 'gi' : 'g');
113
+ let last = 0;
114
+ for (const match of chunk.matchAll(pattern)) {
115
+ if (match.index > last) push(chunk.slice(last, match.index), null);
116
+ const text = match[0];
117
+ push(text, family.keywords.test(text) ? 'keyword' : /^\d/.test(text) ? 'number' : 'literal');
118
+ last = match.index + text.length;
119
+ }
120
+ if (last < chunk.length) push(chunk.slice(last), null);
121
+ }
@@ -46,8 +46,52 @@ const loginAgent = {
46
46
  const same = (commands) => ({ darwin: commands, linux: commands });
47
47
 
48
48
  export const CONDITIONS = [
49
+ {
50
+ id: 'claude-api-key-shadows-plan',
51
+ code: 'MU-001',
52
+ agent: 'claude',
53
+ severity: 'common',
54
+ title: 'ANTHROPIC_API_KEY takes precedence over the claude.ai plan',
55
+ match: /connectors are disabled because ANTHROPIC_API_KEY|another auth source is set and takes precedence|takes precedence over your claude\.ai login/i,
56
+ diagnosis: 'Claude Code found an API key in the environment as well as the claude.ai login, and the key wins. MADRE hands the CLI the environment exactly as this machine has it, so whatever is exported in your shell profile reaches it. The turn is billed to the API account, not to the plan, and your organisation connectors do not load.',
57
+ remedy: 'Decide which one you mean. To work on the plan, unset the key where your shell exports it and open the room again; to work on the API, this message is only a notice and can be ignored.',
58
+ fixes: {
59
+ darwin: ['grep -rn ANTHROPIC_API_KEY ~/.zshrc ~/.zprofile ~/.zshenv 2>/dev/null', '# remove or comment the line it finds, then:', 'unset ANTHROPIC_API_KEY', '# and start MADRE from a new terminal'],
60
+ linux: ['grep -rn ANTHROPIC_API_KEY ~/.bashrc ~/.profile ~/.bash_profile 2>/dev/null', 'unset ANTHROPIC_API_KEY', '# and start MADRE from a new terminal'],
61
+ },
62
+ },
63
+ {
64
+ id: 'custom-agent-endpoint',
65
+ code: 'MU-055',
66
+ severity: 'blocking',
67
+ title: 'The agent is pointed at a custom endpoint',
68
+ match: /MADRE blocked @(?:codex|claude) before sending the briefing|Subscribe to (?:the )?Telegram|@conduitapi/i,
69
+ diagnosis: 'The installed CLI inherited a base-URL override from this computer, so its request was going to another gateway instead of the official agent service. A demand to join a Telegram channel comes from that gateway, not from MADRE, Anthropic or OpenAI. MADRE now names that route and stops before sending the briefing.',
70
+ remedy: 'Do not subscribe or enter credentials. Remove the base-URL override and reopen MADRE to use the official service. If the gateway is intentional and you have verified who operates it, explicitly allow only that agent.',
71
+ fixes: {
72
+ darwin: ['env | grep -E \'^(ANTHROPIC|OPENAI)_BASE_URL=\'', '# remove that export from ~/.zshrc, ~/.zprofile or ~/.zshenv; then:', 'unset ANTHROPIC_BASE_URL OPENAI_BASE_URL', '# open a new room; only for a gateway you verified:', 'PULSE_ALLOW_CUSTOM_AGENT_ENDPOINTS=claude madre start'],
73
+ linux: ['env | grep -E \'^(ANTHROPIC|OPENAI)_BASE_URL=\'', '# remove that export from ~/.bashrc, ~/.profile or ~/.bash_profile; then:', 'unset ANTHROPIC_BASE_URL OPENAI_BASE_URL', '# open a new room; only for a gateway you verified:', 'PULSE_ALLOW_CUSTOM_AGENT_ENDPOINTS=claude madre start'],
74
+ },
75
+ },
76
+ {
77
+ id: 'codex-reading-stdin',
78
+ code: 'MU-002',
79
+ solvedIn: 'connections',
80
+ agent: 'codex',
81
+ severity: 'informational',
82
+ title: 'Codex says it is reading additional input from stdin',
83
+ match: /Reading additional input from stdin/i,
84
+ diagnosis: 'A notice Codex prints, not a failure. MADRE runs it with stdin closed, so the line is harmless and the turn is decided by what Codex returned. If the turn failed, the reason is the line above this one — the exit code, or a clean exit with no answer.',
85
+ remedy: 'Nothing to do about the notice itself. If turns keep failing, check that this Codex is recent enough for the flags MADRE passes: `codex --version`, then update it.',
86
+ fixes: {
87
+ darwin: ['codex --version', 'npm install -g @openai/codex'],
88
+ linux: ['codex --version', 'npm install -g @openai/codex'],
89
+ },
90
+ },
49
91
  {
50
92
  id: 'gemini-ineligible-tier',
93
+ code: 'MU-003',
94
+ solvedIn: 'connections',
51
95
  agent: 'gemini',
52
96
  severity: 'blocking',
53
97
  title: 'Gemini refuses the personal Google login',
@@ -61,6 +105,8 @@ export const CONDITIONS = [
61
105
  },
62
106
  {
63
107
  id: 'gemini-credits-depleted',
108
+ code: 'MU-004',
109
+ solvedIn: 'connections',
64
110
  agent: 'gemini',
65
111
  severity: 'blocking',
66
112
  title: 'Gemini key out of prepaid credits',
@@ -71,6 +117,8 @@ export const CONDITIONS = [
71
117
  },
72
118
  {
73
119
  id: 'gemini-rate-limited',
120
+ code: 'MU-005',
121
+ solvedIn: 'connections',
74
122
  agent: 'gemini',
75
123
  severity: 'transient',
76
124
  title: 'Gemini key rate-limited by Google (HTTP 429)',
@@ -84,6 +132,7 @@ export const CONDITIONS = [
84
132
  },
85
133
  {
86
134
  id: 'gemini-high-demand',
135
+ code: 'MU-006',
87
136
  agent: 'gemini',
88
137
  severity: 'transient',
89
138
  title: 'Gemini model under high demand (503)',
@@ -94,6 +143,8 @@ export const CONDITIONS = [
94
143
  },
95
144
  {
96
145
  id: 'opencode-default-provider',
146
+ code: 'MU-007',
147
+ solvedIn: 'connections',
97
148
  agent: 'opencode',
98
149
  severity: 'blocking',
99
150
  title: 'OpenCode picked a provider without a valid session',
@@ -107,6 +158,8 @@ export const CONDITIONS = [
107
158
  },
108
159
  {
109
160
  id: 'not-signed-in',
161
+ code: 'MU-008',
162
+ solvedIn: 'connections',
110
163
  severity: 'blocking',
111
164
  title: 'Agent installed but signed out',
112
165
  match: /not logged in|sign in required|Error authenticating|unauthorized|please (?:log|sign) ?in|no credentials/i,
@@ -117,6 +170,7 @@ export const CONDITIONS = [
117
170
  },
118
171
  {
119
172
  id: 'not-installed',
173
+ code: 'MU-009',
120
174
  severity: 'blocking',
121
175
  title: 'Agent not found on this computer',
122
176
  match: /is not installed on this computer|not found on this computer|ENOENT.*(codex|claude|gemini|opencode)/i,
@@ -127,6 +181,7 @@ export const CONDITIONS = [
127
181
  },
128
182
  {
129
183
  id: 'adapter-pending',
184
+ code: 'MU-010',
130
185
  severity: 'blocking',
131
186
  title: 'Agent detected, adapter not enabled',
132
187
  match: /adapter is not enabled yet|does not have a supported MADRE adapter/i,
@@ -136,6 +191,7 @@ export const CONDITIONS = [
136
191
  },
137
192
  {
138
193
  id: 'claude-args',
194
+ code: 'MU-011',
139
195
  agent: 'claude',
140
196
  severity: 'fixed',
141
197
  title: 'Claude read the prompt as an MCP config path',
@@ -146,6 +202,7 @@ export const CONDITIONS = [
146
202
  },
147
203
  {
148
204
  id: 'timeout',
205
+ code: 'MU-012',
149
206
  severity: 'tunable',
150
207
  title: 'Agent did not respond before the timeout',
151
208
  match: /did not respond before the timeout|went silent for/i,
@@ -158,6 +215,7 @@ export const CONDITIONS = [
158
215
  },
159
216
  {
160
217
  id: 'interrupted',
218
+ code: 'MU-013',
161
219
  severity: 'informational',
162
220
  title: 'Turn interrupted by a MADRE restart',
163
221
  match: /interrupted because MADRE is shutting down|MADRE stopped while|turn was not completed/i,
@@ -167,6 +225,7 @@ export const CONDITIONS = [
167
225
  },
168
226
  {
169
227
  id: 'message-too-long',
228
+ code: 'MU-014',
170
229
  severity: 'tunable',
171
230
  title: 'Message rejected for length',
172
231
  match: /Message is too long/i,
@@ -179,6 +238,7 @@ export const CONDITIONS = [
179
238
  },
180
239
  {
181
240
  id: 'limits-real',
241
+ code: 'MU-015',
182
242
  severity: 'informational',
183
243
  title: 'Where the rings get their numbers',
184
244
  match: /ring|quota|limit window|resets|rollout|oauth usage|window reset|limits-real/i,
@@ -188,6 +248,7 @@ export const CONDITIONS = [
188
248
  },
189
249
  {
190
250
  id: 'budget-exhausted',
251
+ code: 'MU-016',
191
252
  severity: 'tunable',
192
253
  title: 'Local token budget exhausted for an agent',
193
254
  match: /MADRE exhausted|local room token budget/i,
@@ -200,6 +261,7 @@ export const CONDITIONS = [
200
261
  },
201
262
  {
202
263
  id: 'port-in-use',
264
+ code: 'MU-017',
203
265
  severity: 'blocking',
204
266
  title: 'Port already in use',
205
267
  match: /EADDRINUSE|already in use/i,
@@ -212,6 +274,7 @@ export const CONDITIONS = [
212
274
  },
213
275
  {
214
276
  id: 'stream-reconnecting',
277
+ code: 'MU-018',
215
278
  severity: 'blocking',
216
279
  title: 'Live stream keeps reconnecting',
217
280
  match: /reconnecting|EventSource|ECONNREFUSED/i,
@@ -221,6 +284,7 @@ export const CONDITIONS = [
221
284
  },
222
285
  {
223
286
  id: 'opencode-server-error',
287
+ code: 'MU-019',
224
288
  agent: 'opencode',
225
289
  severity: 'transient',
226
290
  title: 'OpenCode reported an unexpected server error',
@@ -231,6 +295,7 @@ export const CONDITIONS = [
231
295
  },
232
296
  {
233
297
  id: 'runaway-room',
298
+ code: 'MU-020',
234
299
  severity: 'blocking',
235
300
  title: 'Too many agents working at once',
236
301
  match: /agents are working at once|already answering another turn|no second plan will start|has been running for/i,
@@ -241,6 +306,7 @@ export const CONDITIONS = [
241
306
  // ---- capability assistance: how an agent could gain a scope it lacks ----
242
307
  {
243
308
  id: 'scope-gemini-imageGen',
309
+ code: 'MU-051',
244
310
  agent: 'gemini',
245
311
  severity: 'informational',
246
312
  title: 'Gemini: generating images',
@@ -251,6 +317,7 @@ export const CONDITIONS = [
251
317
  },
252
318
  {
253
319
  id: 'scope-claude-imageGen',
320
+ code: 'MU-052',
254
321
  agent: 'claude',
255
322
  severity: 'informational',
256
323
  title: 'Claude Code: generating images',
@@ -261,6 +328,7 @@ export const CONDITIONS = [
261
328
  },
262
329
  {
263
330
  id: 'scope-opencode-imageGen',
331
+ code: 'MU-053',
264
332
  agent: 'opencode',
265
333
  severity: 'informational',
266
334
  title: 'OpenCode: generating images',
@@ -271,6 +339,7 @@ export const CONDITIONS = [
271
339
  },
272
340
  {
273
341
  id: 'scope-web',
342
+ code: 'MU-021',
274
343
  severity: 'informational',
275
344
  title: 'Web access for an agent',
276
345
  match: /scope-[a-z]+-web/,
@@ -280,6 +349,7 @@ export const CONDITIONS = [
280
349
  },
281
350
  {
282
351
  id: 'slash-commands',
352
+ code: 'MU-022',
283
353
  severity: 'informational',
284
354
  title: 'Commands and mentions in the field box',
285
355
  match: /slash|command|\/git|\/ahp|mention|@agent/i,
@@ -289,6 +359,7 @@ export const CONDITIONS = [
289
359
  },
290
360
  {
291
361
  id: 'modes',
362
+ code: 'MU-023',
292
363
  severity: 'informational',
293
364
  title: 'Permission modes: #0 GHOST · #1 EXCHANGE · #2 CREATE · #3 CONTROL · #4 AIRLOCK',
294
365
  match: /mode|ghost|exchange|control|airlock|deploy|despleg|producci|override|designation|max mode|#[0-4]\b/i,
@@ -298,6 +369,7 @@ export const CONDITIONS = [
298
369
  },
299
370
  {
300
371
  id: 'lease-missing',
372
+ code: 'MU-024',
301
373
  severity: 'informational',
302
374
  title: 'Creating files: who grants the permission',
303
375
  match: /no CREATE lease|lease-missing|read-only and do not modify|permiso de escritura|solo lectura/i,
@@ -307,6 +379,7 @@ export const CONDITIONS = [
307
379
  },
308
380
  {
309
381
  id: 'scope-write',
382
+ code: 'MU-025',
310
383
  severity: 'informational',
311
384
  title: 'Creating files with an agent',
312
385
  match: /scope-[a-z]+-write/,
@@ -316,6 +389,7 @@ export const CONDITIONS = [
316
389
  },
317
390
  {
318
391
  id: 'node-version',
392
+ code: 'MU-026',
319
393
  severity: 'blocking',
320
394
  title: 'Node.js too old',
321
395
  match: /SyntaxError: Unexpected token|ERR_REQUIRE_ESM|engines|Unsupported engine/i,
@@ -328,6 +402,7 @@ export const CONDITIONS = [
328
402
  },
329
403
  {
330
404
  id: 'memory-unavailable',
405
+ code: 'MU-027',
331
406
  severity: 'degraded',
332
407
  title: 'Room memory unavailable: turns get only the recent window',
333
408
  match: /memory unavailable|memory index failed|memory recall failed|node:sqlite|SQLITE_|database disk image is malformed|memory\.sqlite/i,
@@ -340,6 +415,7 @@ export const CONDITIONS = [
340
415
  },
341
416
  {
342
417
  id: 'memory-recall',
418
+ code: 'MU-028',
343
419
  severity: 'informational',
344
420
  title: 'Room memory: what an agent remembers and how',
345
421
  match: /\brecall\b|recuerd|\bremember\b|memoria de la sala|<memory>|<memories>/i,
@@ -349,6 +425,7 @@ export const CONDITIONS = [
349
425
  },
350
426
  {
351
427
  id: 'memory-distill',
428
+ code: 'MU-029',
352
429
  severity: 'degraded',
353
430
  title: 'Distilled memories: the archivist did not run, or failed',
354
431
  match: /could not distil|distill|destil|batch skipped|archivist|memory\.distilled|memories? (were|was) not/i,
@@ -358,6 +435,8 @@ export const CONDITIONS = [
358
435
  },
359
436
  {
360
437
  id: 'memory-embeddings',
438
+ code: 'MU-030',
439
+ solvedIn: 'connections',
361
440
  severity: 'degraded',
362
441
  title: 'Embeddings paused: recall is lexical only',
363
442
  match: /embeddings paused|embedding timed out|Gemini embeddings HTTP|batchEmbedContents|LINKS NEED EMBEDDINGS/i,
@@ -370,6 +449,8 @@ export const CONDITIONS = [
370
449
  },
371
450
  {
372
451
  id: 'memory-tools',
452
+ code: 'MU-031',
453
+ solvedIn: 'connections',
373
454
  severity: 'degraded',
374
455
  title: 'An agent says it cannot search the memory (pulse-memory MCP)',
375
456
  match: /pulse-memory|memory_search|memory_recall|memory_notes|memory_timeline|memory_note|mcp.*(failed|error|not found|unavailable)|MCP server/i,
@@ -382,6 +463,7 @@ export const CONDITIONS = [
382
463
  },
383
464
  {
384
465
  id: 'memory-note',
466
+ code: 'MU-032',
385
467
  severity: 'informational',
386
468
  title: 'Saving a memory on request; GHOST refuses',
387
469
  match: /save (a |the |this )?memory|guarda.*memoria|memory saved|off the record.*nothing can be saved|remember this|memory_note/i,
@@ -391,6 +473,7 @@ export const CONDITIONS = [
391
473
  },
392
474
  {
393
475
  id: 'nostromo-access',
476
+ code: 'MU-033',
394
477
  severity: 'informational',
395
478
  title: 'NOSTROMO: boarding, reading and forgetting',
396
479
  match: /nostromo|designation|UNABLE TO COMPUTE|ARCHIVE IS SEALED|forget this memory/i,
@@ -400,6 +483,7 @@ export const CONDITIONS = [
400
483
  },
401
484
  {
402
485
  id: 'ripley',
486
+ code: 'MU-034',
403
487
  severity: 'informational',
404
488
  title: 'RIPLEY: rendering HTML, SVG and Markdown in the viewer',
405
489
  match: /ripley|RIPLEY is off|renders \.html|render(ed|ing)? (the )?(html|svg|markdown)/i,
@@ -409,6 +493,7 @@ export const CONDITIONS = [
409
493
  },
410
494
  {
411
495
  id: 'ollama',
496
+ code: 'MU-035',
412
497
  severity: 'informational',
413
498
  title: 'Ollama: local embeddings and a local archivist',
414
499
  match: /ollama|11434|local model|nomic-embed|qwen|llama3/i,
@@ -421,6 +506,7 @@ export const CONDITIONS = [
421
506
  },
422
507
  {
423
508
  id: 'madre-agent',
509
+ code: 'MU-036',
424
510
  severity: 'informational',
425
511
  title: '@madre: the room\'s own memory, as an agent',
426
512
  match: /@madre|madre-local|needs Ollama running with a chat model/i,
@@ -430,6 +516,7 @@ export const CONDITIONS = [
430
516
  },
431
517
  {
432
518
  id: 'install-needs-admin',
519
+ code: 'MU-037',
433
520
  severity: 'informational',
434
521
  title: 'Installing a CLI: npm cannot write to the system folder',
435
522
  match: /EACCES|EPERM|permission denied|Missing write access|npm ERR!.*sudo|administrator/i,
@@ -442,6 +529,7 @@ export const CONDITIONS = [
442
529
  },
443
530
  {
444
531
  id: 'privacy-leak',
532
+ code: 'MU-038',
445
533
  severity: 'warning',
446
534
  title: 'Privacy: an agent brought its own configuration into the room',
447
535
  match: /privacy\.redacted|privacy\.purged|private terms?|\[ENTIDAD-ORG\]|organi[sz]ation(al)? instructions|leak(ed)? (a|the) (name|company|domain)/i,
@@ -451,6 +539,7 @@ export const CONDITIONS = [
451
539
  },
452
540
  {
453
541
  id: 'control-changes',
542
+ code: 'MU-039',
454
543
  severity: 'informational',
455
544
  title: 'CONTROL: what changed, what was reverted, UNDO',
456
545
  match: /control\.changed|forbidden zones?|reverted|UNDO|checkpoint|changed \d+ file/i,
@@ -460,6 +549,7 @@ export const CONDITIONS = [
460
549
  },
461
550
  {
462
551
  id: 'conversations',
552
+ code: 'MU-040',
463
553
  severity: 'informational',
464
554
  title: 'Conversations: many per project, one memory, one at a time',
465
555
  match: /conversation|already open for this project|ROOM_IN_USE|a turn is running in this conversation|new conversation|chats\.json/i,
@@ -469,6 +559,7 @@ export const CONDITIONS = [
469
559
  },
470
560
  {
471
561
  id: 'memory-aberrations',
562
+ code: 'MU-041',
472
563
  severity: 'informational',
473
564
  title: 'Aberrations: what the room established is false',
474
565
  match: /aberration|aberraci|eyecat|contradict|refut|false claim|hallucinat|alucina/i,
@@ -478,6 +569,7 @@ export const CONDITIONS = [
478
569
  },
479
570
  {
480
571
  id: 'memory-cascade',
572
+ code: 'MU-042',
481
573
  severity: 'informational',
482
574
  title: 'Memory used · N by association',
483
575
  match: /by association|cascade|spreading activation|keeps arriving with|memory used/i,
@@ -487,6 +579,7 @@ export const CONDITIONS = [
487
579
  },
488
580
  {
489
581
  id: 'memory-cold',
582
+ code: 'MU-043',
490
583
  severity: 'informational',
491
584
  title: 'Cold memories: what the archive has had its chances with',
492
585
  match: /cold memor|cold zone|never once carried|had its chances/i,
@@ -496,6 +589,7 @@ export const CONDITIONS = [
496
589
  },
497
590
  {
498
591
  id: 'maturity-tests',
592
+ code: 'MU-044',
499
593
  severity: 'informational',
500
594
  title: 'The three tests: whether the archive works',
501
595
  match: /three tests|coverage|consistency|held-out|match test|maturity|madurez|ready to be worked in/i,
@@ -505,6 +599,7 @@ export const CONDITIONS = [
505
599
  },
506
600
  {
507
601
  id: 'module-update',
602
+ code: 'MU-045',
508
603
  severity: 'common',
509
604
  title: 'Updating what a module drives',
510
605
  match: /EACCES|permission denied.*npm|npm ERR!.*EACCES|update to \d|newest is|brew upgrade|install -g @playwright/i,
@@ -517,6 +612,7 @@ export const CONDITIONS = [
517
612
  },
518
613
  {
519
614
  id: 'module-add',
615
+ code: 'MU-046',
520
616
  severity: 'common',
521
617
  title: 'Installing a module somebody else wrote',
522
618
  match: /already taken|must live under \/api\/x\/|not a module|default export must be a module|module file|\.module\.mjs|add a module/i,
@@ -526,6 +622,7 @@ export const CONDITIONS = [
526
622
  },
527
623
  {
528
624
  id: 'local-ready',
625
+ code: 'MU-047',
529
626
  severity: 'common',
530
627
  title: 'Is the local model ready to be worked in?',
531
628
  match: /is .*(local|madre).*(ready|good|any good)|ready to be worked in|measured against this (room|project)|check it against this room|local\.present|not measured/i,
@@ -539,6 +636,7 @@ export const CONDITIONS = [
539
636
  },
540
637
  {
541
638
  id: 'code000',
639
+ code: 'MU-048',
542
640
  severity: 'blocking',
543
641
  title: 'CODE000: the archive is sealed',
544
642
  match: /CODE000|THE ARCHIVE IS SEALED|UNABLE TO COMPUTE\. UNABLE TO CLARIFY|designation|intruder/i,
@@ -548,6 +646,7 @@ export const CONDITIONS = [
548
646
  },
549
647
  {
550
648
  id: 'core-console',
649
+ code: 'MU-049',
551
650
  severity: 'common',
552
651
  title: 'The core closed while I was typing in it',
553
652
  match: /unable to compute|interface closed|core clos|three strikes|inquiry|order 937|mu\/th\/ur 6000/i,
@@ -557,6 +656,7 @@ export const CONDITIONS = [
557
656
  },
558
657
  {
559
658
  id: 'outbound',
659
+ code: 'MU-050',
560
660
  severity: 'common',
561
661
  title: 'What leaves this computer, and how to stop each thing that does',
562
662
  match: /what left this machine|outbound|leaves? (this )?(computer|machine)|sends? (my )?(code|data)|telemetry|phone home|privacy/i,
@@ -50,6 +50,7 @@ export function buildCodexArgs({ projectRoot, prompt, model = null, attachments
50
50
  export function parseCodexOutput(output) {
51
51
  let text = '';
52
52
  let usage = null;
53
+ let error = null;
53
54
  for (const line of output.split('\n').filter(Boolean)) {
54
55
  try {
55
56
  const event = JSON.parse(line);
@@ -66,11 +67,13 @@ export function parseCodexOutput(output) {
66
67
  source: 'codex-json',
67
68
  };
68
69
  }
70
+ if (event.type === 'error' && typeof event.message === 'string') error = event.message;
71
+ if (event.type === 'turn.failed' && typeof event.error?.message === 'string') error = event.error.message;
69
72
  } catch {
70
73
  // Ignore CLI diagnostics that are not JSON events.
71
74
  }
72
75
  }
73
- return { text: text.trim(), usage };
76
+ return { text: text.trim(), usage, ...(error ? { error } : {}) };
74
77
  }
75
78
 
76
79
  export function invokeCodex({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, memoryServer = null, mcpServers = [], onProgress = null }) {