@jossuealcala/madre 0.2.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +5 -0
  4. package/README.md +281 -0
  5. package/bin/madre.mjs +134 -0
  6. package/docs/madre-banner.svg +25 -0
  7. package/package.json +63 -0
  8. package/public/app.js +4031 -0
  9. package/public/brands.js +59 -0
  10. package/public/index.html +231 -0
  11. package/public/styles.css +976 -0
  12. package/public/troubleshooting.js +460 -0
  13. package/src/adapters/claude.mjs +105 -0
  14. package/src/adapters/codex.mjs +84 -0
  15. package/src/adapters/gemini.mjs +335 -0
  16. package/src/adapters/opencode.mjs +148 -0
  17. package/src/adapters/process.mjs +132 -0
  18. package/src/ashcode.mjs +64 -0
  19. package/src/auth-probe.mjs +145 -0
  20. package/src/capabilities.mjs +126 -0
  21. package/src/checkpoint.mjs +106 -0
  22. package/src/cli-args.mjs +38 -0
  23. package/src/commands.mjs +98 -0
  24. package/src/config.mjs +59 -0
  25. package/src/conversation-context.mjs +59 -0
  26. package/src/directives.mjs +70 -0
  27. package/src/distiller.mjs +78 -0
  28. package/src/embeddings.mjs +80 -0
  29. package/src/event-store.mjs +152 -0
  30. package/src/extensions.mjs +263 -0
  31. package/src/files.mjs +177 -0
  32. package/src/image-studio.mjs +25 -0
  33. package/src/lease.mjs +83 -0
  34. package/src/mcp/image-server.mjs +169 -0
  35. package/src/mcp/memory-server.mjs +221 -0
  36. package/src/memory-tools.mjs +34 -0
  37. package/src/memory.mjs +543 -0
  38. package/src/models.mjs +78 -0
  39. package/src/mother.mjs +163 -0
  40. package/src/quota-monitor.mjs +119 -0
  41. package/src/quota-sources.mjs +161 -0
  42. package/src/room.mjs +1159 -0
  43. package/src/router.mjs +13 -0
  44. package/src/runtime-detection.mjs +85 -0
  45. package/src/server.mjs +693 -0
  46. package/src/setup.mjs +192 -0
  47. package/src/usage-sentinel.mjs +65 -0
package/src/setup.mjs ADDED
@@ -0,0 +1,192 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { stdin, stdout } from 'node:process';
4
+ import { BRANDS } from '../public/brands.js';
5
+ import { AGENT_SETUP, probeAll } from './auth-probe.mjs';
6
+ import { detectAgents } from './runtime-detection.mjs';
7
+ import { loadConfig, updateConfig } from './config.mjs';
8
+
9
+ /* ---------- MOTHER palette for the terminal ---------- */
10
+
11
+ export function makePalette({ colors = stdout.isTTY && !process.env.NO_COLOR, depth = stdout.getColorDepth?.() ?? 4 } = {}) {
12
+ if (!colors) {
13
+ const plain = (text) => String(text);
14
+ return { phosphor: plain, dim: plain, bold: plain, agent: () => plain, danger: plain, warn: plain, reset: '' };
15
+ }
16
+ const truecolor = depth >= 24;
17
+ const hex = (value) => {
18
+ const n = parseInt(value.slice(1), 16);
19
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
20
+ };
21
+ const fg = (value) => {
22
+ if (!truecolor) return '';
23
+ const [r, g, b] = hex(value);
24
+ return `[38;2;${r};${g};${b}m`;
25
+ };
26
+ const wrap = (open) => (text) => `${open}${text}`;
27
+ return {
28
+ phosphor: wrap(fg('#9bff66')),
29
+ dim: wrap(''),
30
+ bold: wrap(''),
31
+ danger: wrap(truecolor ? fg('#ff7b72') : ''),
32
+ warn: wrap(truecolor ? fg('#ffd166') : ''),
33
+ agent: (id) => wrap(truecolor ? fg(BRANDS[id]?.color ?? '#8a8f98') : ''),
34
+ reset: '',
35
+ };
36
+ }
37
+
38
+ const SESSION_LABEL = {
39
+ 'signed-in': 'SESSION OK',
40
+ 'signed-out': 'NO SESSION',
41
+ unknown: 'SESSION ?',
42
+ 'not-installed': 'NOT FOUND',
43
+ };
44
+
45
+ const glyphFor = (agent, probe) => {
46
+ if (!agent.detected) return '○';
47
+ if (agent.ready && probe.state === 'signed-in') return '◉';
48
+ return '◌';
49
+ };
50
+
51
+ export function isOnline(agent, probe) {
52
+ return Boolean(agent.ready && probe?.state === 'signed-in');
53
+ }
54
+
55
+ /* ---------- report (pure, testable) ---------- */
56
+
57
+ export function renderReport({ agents, probes, projectRoot, config = {}, palette = makePalette({ colors: false }), width = 72 }) {
58
+ const p = palette;
59
+ const bar = p.phosphor('▌');
60
+ const lines = [];
61
+ lines.push('');
62
+ lines.push(`${bar} ${p.bold(p.phosphor('MADRE'))}${' '.repeat(Math.max(1, width - 30))}${p.dim('INTERFACE · SETUP')}`);
63
+ lines.push(`${bar} ${p.dim(projectRoot)}`);
64
+ lines.push('');
65
+ lines.push(` ${p.dim('┌ AGENTS ' + '─'.repeat(Math.max(4, width - 12)))}`);
66
+ for (const agent of agents) {
67
+ const probe = probes[agent.id] ?? { state: 'unknown', detail: '' };
68
+ const paint = p.agent(agent.id);
69
+ const glyph = paint(glyphFor(agent, probe));
70
+ const name = paint(agent.id.toUpperCase().padEnd(9));
71
+ const vendor = p.dim((BRANDS[agent.id]?.vendor ?? '').padEnd(10));
72
+ const status = !agent.detected
73
+ ? p.dim('NOT FOUND ')
74
+ : agent.ready ? p.phosphor('READY ') : p.warn('DETECTED ');
75
+ const session = !agent.detected
76
+ ? ''
77
+ : probe.state === 'signed-in' ? p.phosphor(SESSION_LABEL[probe.state])
78
+ : probe.state === 'signed-out' ? p.danger(SESSION_LABEL[probe.state]) : p.warn(SESSION_LABEL[probe.state] ?? 'SESSION ?');
79
+ const detail = [probe.detail, agent.version].filter(Boolean).join(' · ');
80
+ lines.push(` ${p.dim('│')} ${glyph} ${name} ${vendor} ${status} ${session}${detail ? p.dim(` ${detail}`) : ''}`);
81
+ if (agent.id === 'opencode' && agent.detected) {
82
+ const model = process.env.PULSE_OPENCODE_MODEL ?? config.opencode?.model;
83
+ lines.push(` ${p.dim('│')} ${p.dim(model ? `model ${model}` : 'model: provider default (set one if the default provider has no session)')}`);
84
+ }
85
+ }
86
+ lines.push(` ${p.dim('└' + '─'.repeat(Math.max(4, width - 4)))}`);
87
+ const online = agents.filter((agent) => isOnline(agent, probes[agent.id]));
88
+ const verdict = online.length
89
+ ? `${online.length} OF ${agents.length} AGENTS ONLINE. ROOM CAN OPEN.`
90
+ : `NO AGENT ONLINE. CONFIGURE ONE TO OPEN THE ROOM.`;
91
+ lines.push('');
92
+ lines.push(` ${p.phosphor('MOTHER')}${p.dim(' ›')} ${p.bold(verdict)}`);
93
+ return lines.join('\n');
94
+ }
95
+
96
+ /* ---------- interactive wizard ---------- */
97
+
98
+ function runInTerminal(path, args) {
99
+ return new Promise((resolve) => {
100
+ const child = spawn(path, args, { stdio: 'inherit' });
101
+ child.on('close', (code) => resolve(code ?? 1));
102
+ child.on('error', () => resolve(1));
103
+ });
104
+ }
105
+
106
+ async function listOpenCodeModels(agent) {
107
+ return new Promise((resolve) => {
108
+ const child = spawn(agent.path, ['models'], { stdio: ['ignore', 'pipe', 'ignore'] });
109
+ let out = '';
110
+ child.stdout.on('data', (chunk) => { out += chunk; });
111
+ const timer = setTimeout(() => child.kill('SIGKILL'), 15000);
112
+ child.on('close', () => { clearTimeout(timer); resolve(out.split('\n').map((line) => line.trim()).filter((line) => /^[\w.-]+\/[\w.:-]+$/.test(line))); });
113
+ child.on('error', () => resolve([]));
114
+ });
115
+ }
116
+
117
+ export async function runSetup({ projectRoot, stateRoot, interactive = stdin.isTTY && stdout.isTTY, palette = makePalette(), log = console.log } = {}) {
118
+ const p = palette;
119
+ let agents = await detectAgents();
120
+ let probes = await probeAll(agents);
121
+ let config = await loadConfig(stateRoot);
122
+ log(renderReport({ agents, probes, projectRoot, config, palette: p }));
123
+
124
+ if (!interactive) {
125
+ // Without a terminal there is nobody to walk through; report and hand
126
+ // back. `online` lets the caller decide (exit codes, CI checks).
127
+ return { action: 'report', online: agents.some((agent) => isOnline(agent, probes[agent.id])), agents, probes };
128
+ }
129
+
130
+ const rl = createInterface({ input: stdin, output: stdout });
131
+ const ask = (question) => rl.question(`\n ${p.phosphor('›')} ${question} `);
132
+ try {
133
+ for (;;) {
134
+ const menu = agents.map((agent, index) => `${p.dim(`[${index + 1}]`)} ${p.agent(agent.id)(agent.id)}`).join(' ');
135
+ log(`\n ${menu} ${p.dim('[m]')} opencode model ${p.dim('[r]')} rescan ${p.dim('[s]')} start room ${p.dim('[q]')} quit`);
136
+ const answer = (await ask('select')).trim().toLowerCase();
137
+ if (answer === 'q') return { action: 'quit', agents, probes };
138
+ if (answer === 's') {
139
+ if (!agents.some((agent) => isOnline(agent, probes[agent.id]))) {
140
+ log(` ${p.warn('MOTHER ›')} no agent online; the room would open empty. Configure one first or press s again to open anyway.`);
141
+ const again = (await ask('start anyway? [y/N]')).trim().toLowerCase();
142
+ if (again !== 'y') continue;
143
+ }
144
+ return { action: 'start', agents, probes };
145
+ }
146
+ if (answer === 'r') {
147
+ agents = await detectAgents();
148
+ probes = await probeAll(agents);
149
+ config = await loadConfig(stateRoot);
150
+ log(renderReport({ agents, probes, projectRoot, config, palette: p }));
151
+ continue;
152
+ }
153
+ if (answer === 'm') {
154
+ const opencode = agents.find((agent) => agent.id === 'opencode');
155
+ if (!opencode?.detected) { log(` ${p.dim('OpenCode is not installed.')}`); continue; }
156
+ log(` ${p.dim('Reading models from opencode…')}`);
157
+ const models = await listOpenCodeModels(opencode);
158
+ if (models.length) log(` ${p.dim(models.slice(0, 24).join('\n '))}${models.length > 24 ? p.dim(`\n … ${models.length - 24} more`) : ''}`);
159
+ const model = (await ask('provider/model for MADRE (empty to clear)')).trim();
160
+ config = await updateConfig(stateRoot, { opencode: { model: model || undefined } });
161
+ if (model) process.env.PULSE_OPENCODE_MODEL = model; else delete process.env.PULSE_OPENCODE_MODEL;
162
+ log(` ${p.phosphor('saved')} ${p.dim(model ? `opencode.model = ${model}` : 'opencode.model cleared')}`);
163
+ continue;
164
+ }
165
+ const index = Number(answer) - 1;
166
+ const agent = agents[index];
167
+ if (!agent) { log(` ${p.dim('unknown option')}`); continue; }
168
+ const setup = AGENT_SETUP[agent.id];
169
+ const probe = probes[agent.id];
170
+ if (!agent.detected) {
171
+ log(` ${p.agent(agent.id)(agent.id)} ${p.dim('is not installed. Install it, then press r to rescan:')}`);
172
+ for (const hint of setup.install) log(` ${p.phosphor('$')} ${hint}`);
173
+ continue;
174
+ }
175
+ if (probe.state === 'signed-in') {
176
+ log(` ${p.agent(agent.id)(agent.id)} ${p.dim(`already has a session (${probe.detail}). Sign in again? [y/N]`)}`);
177
+ if ((await ask('')).trim().toLowerCase() !== 'y') continue;
178
+ }
179
+ log(` ${p.dim(setup.loginNote)}`);
180
+ if (setup.interactive || !setup.login.length) {
181
+ log(` ${p.dim(`Handing the terminal to ${agent.id}. Exit it when you are done and MADRE will rescan.`)}`);
182
+ await runInTerminal(agent.path, []);
183
+ } else {
184
+ await runInTerminal(agent.path, setup.login);
185
+ }
186
+ probes = await probeAll(agents);
187
+ log(renderReport({ agents, probes, projectRoot, config, palette: p }));
188
+ }
189
+ } finally {
190
+ rl.close();
191
+ }
192
+ }
@@ -0,0 +1,65 @@
1
+ const severity = { normal: 0, warning: 1, critical: 2, exhausted: 3 };
2
+
3
+ export function classifyUsagePercent(value) {
4
+ const percent = Number(value);
5
+ if (!Number.isFinite(percent) || percent < 0) return 'normal';
6
+ if (percent >= 100) return 'exhausted';
7
+ if (percent >= 90) return 'critical';
8
+ if (percent >= 80) return 'warning';
9
+ return 'normal';
10
+ }
11
+
12
+ export class UsageSentinel {
13
+ #lastLevel = new Map();
14
+
15
+ seed({ agent, usedPercent, source }) {
16
+ const percent = Number(usedPercent);
17
+ if (!agent || !source || !Number.isFinite(percent)) return;
18
+ this.#lastLevel.set(`${agent}:${source}`, classifyUsagePercent(percent));
19
+ }
20
+
21
+ // `projectedPercent` is where usage would land if the next turn were as
22
+ // large as the last one. A single large turn can jump from well below 80%
23
+ // straight past 100%, so the projection raises the alarm one turn earlier.
24
+ evaluate({ agent, usedPercent, source, resetAt = null, alternatives = [], projectedPercent = null }) {
25
+ const value = Number(usedPercent);
26
+ if (!agent || !source || !Number.isFinite(value)) return null;
27
+ const percent = Math.max(0, Math.min(100, value));
28
+ const projected = Number.isFinite(Number(projectedPercent)) ? Math.max(percent, Number(projectedPercent)) : null;
29
+ const currentLevel = classifyUsagePercent(percent);
30
+ const projectedLevel = projected === null ? 'normal' : classifyUsagePercent(Math.min(100, projected));
31
+ const level = severity[projectedLevel] > severity[currentLevel] ? projectedLevel : currentLevel;
32
+ const byProjection = level !== currentLevel;
33
+ const key = `${agent}:${source}`;
34
+ const previous = this.#lastLevel.get(key) ?? 'normal';
35
+ this.#lastLevel.set(key, level);
36
+ // Back below the warning line after having crossed it: the window reset
37
+ // (or the provider forgave). Worth a word, so full rings can empty.
38
+ if (level === 'normal' && severity[previous] >= severity.warning) {
39
+ return { agent, usedPercent: percent, projectedPercent: projected, level, source, resetAt, alternatives, cleared: true, message: `MADRE clear: @${agent} is back at ${Math.round(percent)}% of its ${source === 'room-soft-budget' ? 'local window' : 'provider window'}.` };
40
+ }
41
+ if (level === 'normal' || severity[level] <= severity[previous]) return null;
42
+
43
+ const destination = alternatives.length
44
+ ? ` Continue with ${alternatives.map((item) => `@${item}`).join(' or ')}.`
45
+ : ' Prepare a handoff before the current agent becomes unavailable.';
46
+ const label = source === 'room-soft-budget'
47
+ ? 'local room token budget (MADRE\'s own soft limit, not the provider\'s quota; cache reads count a tenth)'
48
+ : source === 'test-simulation'
49
+ ? 'simulated provider usage window'
50
+ : 'provider usage window';
51
+ const usage = byProjection
52
+ ? `has used ${Math.round(percent)}% of its ${label} and another turn like the last one would reach ${Math.round(projected)}%.`
53
+ : `has used ${Math.round(percent)}% of its ${label}.`;
54
+ return {
55
+ agent,
56
+ usedPercent: percent,
57
+ projectedPercent: projected,
58
+ level,
59
+ source,
60
+ resetAt,
61
+ alternatives,
62
+ message: `MADRE ${level}: @${agent} ${usage}${destination}`,
63
+ };
64
+ }
65
+ }