@jossuealcala/madre 1.0.0 → 1.0.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/ADDITIONAL-TERMS.md +93 -0
- package/CHANGELOG.en.md +1945 -0
- package/CHANGELOG.md +78 -1
- package/CONTRIBUTING.md +5 -1
- package/LICENSE +661 -202
- package/NOTICE +34 -3
- package/README.en.md +245 -0
- package/README.md +23 -13
- package/TRADEMARKS.en.md +50 -0
- package/TRADEMARKS.md +82 -0
- package/bin/madre.mjs +2 -0
- package/docs/INTERNALS.md +8 -2
- package/docs/REFERENCE.md +17 -7
- package/docs/en/INTERNALS.md +103 -0
- package/docs/en/REFERENCE.md +339 -0
- package/docs/en/SDK.md +306 -0
- package/docs/en/training/README.md +59 -0
- package/docs/madre-banner.png +0 -0
- package/docs/training/README.md +20 -8
- package/docs/training/train.sh +2 -1
- package/package.json +14 -5
- package/public/abduction.js +2 -0
- package/public/app.js +2066 -488
- package/public/attribution.js +71 -0
- package/public/brands.js +3 -1
- package/public/editor.js +2 -0
- package/public/es.js +1706 -1226
- package/public/i18n.js +25 -1
- package/public/index.html +13 -10
- package/public/inquiry.js +2 -0
- package/public/loader.js +2 -0
- package/public/macroverso.js +24 -2
- package/public/nostromo-engine.js +2 -0
- package/public/resay.js +6 -0
- package/public/review-ask.js +2 -0
- package/public/routing.js +23 -1
- package/public/salas-mac.css +539 -28
- package/public/salas-mac.html +30 -22
- package/public/salas-mac.js +1498 -144
- package/public/salas.css +2 -0
- package/public/salas.html +1 -0
- package/public/salas.js +4 -2
- package/public/styles.css +1123 -20
- package/public/syntax.js +2 -0
- package/public/touch.js +2 -0
- package/public/tour-chapters.js +164 -52
- package/public/tour.js +85 -12
- package/public/troubleshooting.js +20 -18
- package/src/adapters/claude.mjs +2 -0
- package/src/adapters/codex.mjs +2 -0
- package/src/adapters/gemini.mjs +3 -1
- package/src/adapters/madre.mjs +3 -1
- package/src/adapters/opencode.mjs +7 -3
- package/src/adapters/process.mjs +2 -0
- package/src/asking.mjs +2 -0
- package/src/auth-probe.mjs +32 -7
- package/src/bridge/devices.mjs +2 -0
- package/src/bridge/handoff.mjs +2 -0
- package/src/bridge/host.mjs +2 -0
- package/src/bridge/network.mjs +2 -0
- package/src/bridge/policy.mjs +3 -1
- package/src/bridge/qr.mjs +2 -0
- package/src/bridge/server.mjs +2 -0
- package/src/capabilities.mjs +2 -0
- package/src/chats.mjs +2 -0
- package/src/checkpoint.mjs +2 -0
- package/src/choices.mjs +2 -0
- package/src/cli-args.mjs +2 -0
- package/src/cold.mjs +2 -0
- package/src/commands.mjs +2 -0
- package/src/config.mjs +2 -0
- package/src/conversation-context.mjs +15 -1
- package/src/credentials.mjs +112 -3
- package/src/dataset.mjs +2 -0
- package/src/directives.mjs +8 -6
- package/src/distiller.mjs +2 -0
- package/src/embeddings.mjs +2 -0
- package/src/enlaces.mjs +6 -4
- package/src/event-store.mjs +2 -0
- package/src/exam.mjs +6 -4
- package/src/extensions.mjs +33 -10
- package/src/eyecat-watch.mjs +2 -0
- package/src/eyecat.mjs +2 -0
- package/src/file-ops.mjs +2 -0
- package/src/files.mjs +32 -0
- package/src/github.mjs +2 -0
- package/src/heartbeat.mjs +57 -0
- package/src/i18n.mjs +15 -1
- package/src/image-studio.mjs +2 -0
- package/src/json-file.mjs +2 -0
- package/src/launch.mjs +2 -0
- package/src/lease.mjs +5 -1
- package/src/lobby-work.mjs +2 -0
- package/src/local-ai.mjs +50 -0
- package/src/looks.mjs +40 -5
- package/src/maturity.mjs +3 -1
- package/src/mcp/connect.mjs +345 -0
- package/src/mcp/github-server.mjs +2 -0
- package/src/mcp/image-server.mjs +2 -0
- package/src/mcp/memory-server.mjs +2 -0
- package/src/mcp/proxy.mjs +153 -0
- package/src/mcp/smtp-server.mjs +2 -0
- package/src/memory-dashboard.mjs +2 -0
- package/src/memory-tools.mjs +2 -0
- package/src/memory.mjs +2 -0
- package/src/models.mjs +83 -3
- package/src/modules/ahp.mjs +2 -0
- package/src/modules/ash-policy.mjs +2 -0
- package/src/modules/ash.mjs +3 -1
- package/src/modules/bishop-plan.mjs +5 -3
- package/src/modules/bishop.mjs +13 -11
- package/src/modules/choices.mjs +2 -0
- package/src/modules/correo.mjs +2 -0
- package/src/modules/enlaces.mjs +2 -0
- package/src/modules/git-pulse.mjs +2 -0
- package/src/modules/github.mjs +2 -0
- package/src/modules/helpers.mjs +2 -0
- package/src/modules/image-studio.mjs +3 -1
- package/src/modules/index.mjs +4 -1
- package/src/modules/intents-calibration.mjs +2 -0
- package/src/modules/intents-desk.mjs +4 -2
- package/src/modules/intents-observe.mjs +2 -0
- package/src/modules/intents-policy.mjs +4 -1
- package/src/modules/intents-try.mjs +2 -0
- package/src/modules/intents.mjs +8 -6
- package/src/modules/mcp-connect.mjs +293 -0
- package/src/modules/ollama.mjs +17 -3
- package/src/modules/playwright.mjs +25 -10
- package/src/modules/ripley.mjs +2 -0
- package/src/modules/runs.mjs +5 -3
- package/src/modules/sdk.mjs +2 -0
- package/src/modules/updates.mjs +2 -0
- package/src/mother.mjs +2 -0
- package/src/ollama.mjs +2 -0
- package/src/outbound.mjs +24 -5
- package/src/override.mjs +2 -0
- package/src/ports.mjs +2 -0
- package/src/prices.mjs +2 -0
- package/src/privacy.mjs +2 -0
- package/src/quota-monitor.mjs +2 -0
- package/src/quota-sources.mjs +2 -0
- package/src/request-guard.mjs +2 -0
- package/src/review-ask.mjs +2 -0
- package/src/room/archivist.mjs +2 -0
- package/src/room/attachments.mjs +2 -0
- package/src/room/budget.mjs +2 -0
- package/src/room/context.mjs +2 -0
- package/src/room/control.mjs +7 -1
- package/src/room/copilot.mjs +76 -0
- package/src/room/economy.mjs +2 -0
- package/src/room/escalation.mjs +3 -0
- package/src/room/ghost.mjs +2 -0
- package/src/room/guard.mjs +2 -0
- package/src/room/prompt.mjs +13 -4
- package/src/room/returns.mjs +82 -0
- package/src/room/runs.mjs +4 -0
- package/src/room/vectors.mjs +2 -0
- package/src/room.mjs +264 -45
- package/src/router.mjs +2 -0
- package/src/runs.mjs +2 -0
- package/src/runtime-detection.mjs +2 -0
- package/src/salas.mjs +2 -0
- package/src/sentinel-errors.mjs +2 -0
- package/src/server-sentinel.mjs +2 -0
- package/src/server.mjs +485 -55
- package/src/setup.mjs +2 -0
- package/src/smtp.mjs +2 -0
- package/src/tour.mjs +26 -9
- package/src/updates.mjs +2 -0
- package/src/usage-sentinel.mjs +2 -0
- package/src/vault.mjs +2 -0
- package/src/verdict.mjs +3 -1
- package/src/whats-new.mjs +12 -10
- package/docs/madre-banner.svg +0 -25
package/src/choices.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// An agent asking the human to choose.
|
|
2
4
|
//
|
|
3
5
|
// The room already has one fenced channel, ```pulse, and it puts other agents to work. This is
|
package/src/cli-args.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Tiny argv reader for the MADRE CLI. Accepts `--name value` and
|
|
2
4
|
// `--name=value`, and remembers which options the user set explicitly so the
|
|
3
5
|
// room can choose sensible defaults (like the next free port) only when they
|
package/src/cold.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Cold zones: the part of the archive nothing has ever reached for.
|
|
2
4
|
//
|
|
3
5
|
// A memory is cold when three things are true at once, and the three together are what make the
|
package/src/commands.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
import { execFile } from 'node:child_process';
|
|
2
4
|
import { access } from 'node:fs/promises';
|
|
3
5
|
import { join } from 'node:path';
|
package/src/config.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
import { randomUUID } from 'node:crypto';
|
|
2
4
|
import { copyFile, mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
5
|
import { homedir } from 'node:os';
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
3
|
+
// What a command's output may weigh in the shared window, and which part of it. It used to be the
|
|
4
|
+
// first 6000 characters: five outputs filled the 16 000 of the window, the request that asked for
|
|
5
|
+
// them went out of it, and the agent lost the thread (2026-10-14). Now it is the start and the end
|
|
6
|
+
// — a test run says what it is at the top and how it went at the bottom — and the agent that asked
|
|
7
|
+
// gets the fuller output in a turn of its own (src/room/returns.mjs).
|
|
8
|
+
const COMMAND_HEAD = 300;
|
|
9
|
+
const COMMAND_TAIL = 900;
|
|
10
|
+
function commandEnds(text) {
|
|
11
|
+
if (text.length <= COMMAND_HEAD + COMMAND_TAIL) return text;
|
|
12
|
+
return `${text.slice(0, COMMAND_HEAD)}\n… ${text.length - COMMAND_HEAD - COMMAND_TAIL} characters left out …\n${text.slice(-COMMAND_TAIL)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
1
15
|
export function messageEntry(event) {
|
|
2
16
|
if (event.type === 'message.failed') {
|
|
3
17
|
// Failures are part of the conversation: an orchestrator closing a plan
|
|
@@ -9,7 +23,7 @@ export function messageEntry(event) {
|
|
|
9
23
|
if (event.type === 'command.output') {
|
|
10
24
|
const { name, title, text } = event.payload;
|
|
11
25
|
if (typeof text !== 'string' || !text.trim()) return null;
|
|
12
|
-
return { sequence: event.sequence, messageId: `${event.id}:command`, role: 'command', sender: `/${name}`, target: 'room', text: `${title}\n${text.trim()}
|
|
26
|
+
return { sequence: event.sequence, messageId: `${event.id}:command`, role: 'command', sender: `/${name}`, target: 'room', text: `${title}\n${commandEnds(text.trim())}` };
|
|
13
27
|
}
|
|
14
28
|
if (event.type !== 'message.created') return null;
|
|
15
29
|
const { messageId, role, sender, target, text } = event.payload;
|
package/src/credentials.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Handing a provider key to a CLI that signs in from its own prompt.
|
|
2
4
|
//
|
|
3
5
|
// MADRE keeps its promise: it never stores a credential of its own. The key the human pastes is
|
|
@@ -36,8 +38,46 @@ export const OPENCODE_PROVIDERS = [
|
|
|
36
38
|
{ id: 'google', label: 'Google AI Studio', keyUrl: 'https://aistudio.google.com/app/apikey' },
|
|
37
39
|
];
|
|
38
40
|
|
|
41
|
+
// The rest of the providers OpenCode knows (2026-10-14): its own catalogue, models.dev, which it
|
|
42
|
+
// caches itself. Those a single key opens can be wired from MADRE; the ones that need an account
|
|
43
|
+
// id, a region or a second secret stay with `opencode auth login`. Read only, never fetched.
|
|
44
|
+
export const OPENCODE_FEATURED = ['anthropic', 'openai', 'google', 'openrouter', 'groq', 'deepseek', 'mistral', 'xai', 'togetherai', 'fireworks-ai', 'cerebras', 'perplexity', 'huggingface', 'deepinfra', 'moonshotai', 'zai', 'cohere', 'vercel'];
|
|
45
|
+
export async function readOpenCodeCatalog({ home = homedir(), env = process.env } = {}) {
|
|
46
|
+
const raw = await readIfThere(join(env.XDG_CACHE_HOME || join(home, '.cache'), 'opencode', 'models.json'));
|
|
47
|
+
let data = {};
|
|
48
|
+
try { data = JSON.parse(raw ?? '{}') ?? {}; } catch { data = {}; }
|
|
49
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return [];
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const [id, one] of Object.entries(data)) {
|
|
52
|
+
if (!/^[\w.-]{1,60}$/.test(id) || !one || typeof one !== 'object' || !Array.isArray(one.env) || one.env.length !== 1) continue;
|
|
53
|
+
out.push({ id, label: typeof one.name === 'string' && one.name.trim() ? one.name.trim().slice(0, 60) : id, docUrl: typeof one.doc === 'string' && /^https:\/\//.test(one.doc) ? one.doc : null });
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
// Every provider a key can be pasted for: MADRE's own four first (their key pages), then the
|
|
58
|
+
// catalogue's, each once, the popular ones marked.
|
|
59
|
+
export async function openCodeKeyProviders({ home = homedir(), env = process.env } = {}) {
|
|
60
|
+
const catalog = await readOpenCodeCatalog({ home, env });
|
|
61
|
+
const byId = new Map(OPENCODE_PROVIDERS.map((one) => [one.id, { ...one, featured: true }]));
|
|
62
|
+
for (const one of catalog) if (!byId.has(one.id)) byId.set(one.id, { id: one.id, label: one.label, keyUrl: one.docUrl, featured: OPENCODE_FEATURED.includes(one.id) });
|
|
63
|
+
const rank = (one) => { const at = OPENCODE_FEATURED.indexOf(one.id); return at < 0 ? Infinity : at; };
|
|
64
|
+
return [...byId.values()].sort((a, b) => Number(b.featured) - Number(a.featured) || rank(a) - rank(b));
|
|
65
|
+
}
|
|
66
|
+
|
|
39
67
|
// What the room offers for each agent that cannot be signed in with a click.
|
|
40
68
|
export function keyPlanFor(id) {
|
|
69
|
+
if (id === 'claude') {
|
|
70
|
+
return {
|
|
71
|
+
agent: 'claude',
|
|
72
|
+
label: 'Anthropic API key',
|
|
73
|
+
keyUrl: 'https://console.anthropic.com/settings/keys',
|
|
74
|
+
writesTo: '~/.claude/settings.json',
|
|
75
|
+
// Said plainly because it is true: this file is the Claude CLI's own, not MADRE's. A key
|
|
76
|
+
// there is the one every Claude Code on this computer will use, MADRE's turns included.
|
|
77
|
+
note: 'The key is written where the Claude CLI looks for it, readable only by you. MADRE keeps no copy. It becomes the account Claude Code uses on this computer, here and in your own terminal, until you remove it.',
|
|
78
|
+
providers: null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
41
81
|
if (id === 'gemini') {
|
|
42
82
|
return {
|
|
43
83
|
agent: 'gemini',
|
|
@@ -99,9 +139,36 @@ async function applyGeminiKey(key, { home, probe }) {
|
|
|
99
139
|
}
|
|
100
140
|
}
|
|
101
141
|
|
|
142
|
+
// ---- Claude: `env.ANTHROPIC_API_KEY` in its own settings, confirmed by `claude auth status`.
|
|
143
|
+
// The CLI reads that env block before it runs, which is why a key there is a session: its own
|
|
144
|
+
// status answers `api_key` afterwards. Everything else in the file is left as it was.
|
|
145
|
+
async function applyClaudeKey(key, { home, probe }) {
|
|
146
|
+
const dir = join(home, '.claude');
|
|
147
|
+
const file = join(dir, 'settings.json');
|
|
148
|
+
await mkdir(dir, { recursive: true });
|
|
149
|
+
const before = await readIfThere(file);
|
|
150
|
+
try {
|
|
151
|
+
let settings = {};
|
|
152
|
+
try { settings = JSON.parse(before ?? '{}') ?? {}; } catch { settings = {}; }
|
|
153
|
+
if (typeof settings !== 'object' || Array.isArray(settings)) settings = {};
|
|
154
|
+
settings.env = { ...(settings.env ?? {}), ANTHROPIC_API_KEY: key };
|
|
155
|
+
await writeFile(file, `${JSON.stringify(settings, null, 2)}\n`, { mode: OWNER_ONLY });
|
|
156
|
+
await chmod(file, OWNER_ONLY).catch(() => {});
|
|
157
|
+
const state = await probe();
|
|
158
|
+
if (state?.state !== 'signed-in') {
|
|
159
|
+
await restore(file, before);
|
|
160
|
+
return { ok: false, error: `The key was written but Claude still reads as ${state?.state ?? 'unknown'}${state?.detail ? ` (${state.detail})` : ''}. Nothing was changed.` };
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, detail: state.detail ?? 'via API key' };
|
|
163
|
+
} catch (error) {
|
|
164
|
+
await restore(file, before);
|
|
165
|
+
return { ok: false, error: `The key could not be written: ${error.message}` };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
102
169
|
// ---- OpenCode: an entry in its auth.json, confirmed by asking `opencode auth list`.
|
|
103
170
|
async function applyOpenCodeKey(key, { home, provider, executable, run }) {
|
|
104
|
-
const known =
|
|
171
|
+
const known = typeof provider === 'string' ? (await openCodeKeyProviders({ home })).find((item) => item.id === provider) : null;
|
|
105
172
|
if (!known) return { ok: false, error: 'Pick the provider this key belongs to.' };
|
|
106
173
|
const file = join(home, '.local', 'share', 'opencode', 'auth.json');
|
|
107
174
|
await mkdir(join(home, '.local', 'share', 'opencode'), { recursive: true });
|
|
@@ -114,8 +181,9 @@ async function applyOpenCodeKey(key, { home, provider, executable, run }) {
|
|
|
114
181
|
await chmod(file, OWNER_ONLY).catch(() => {});
|
|
115
182
|
// OpenCode itself is the judge: if its own listing does not show the provider, the shape
|
|
116
183
|
// MADRE wrote is not the shape it reads, and nothing should be left behind.
|
|
117
|
-
|
|
118
|
-
|
|
184
|
+
// It lists the provider by its name («Together AI»), sometimes by its id.
|
|
185
|
+
const listed = String(await run(executable, ['auth', 'list'])).toLowerCase();
|
|
186
|
+
if (!listed.includes(provider.toLowerCase()) && !listed.includes(String(known.label).toLowerCase())) {
|
|
119
187
|
await restore(file, before);
|
|
120
188
|
return { ok: false, error: `OpenCode does not list ${provider} after the key was written, so MADRE put the file back. Run \`opencode auth login\` in a terminal for this one.` };
|
|
121
189
|
}
|
|
@@ -139,7 +207,48 @@ const runOpenCode = async (executable, args) => {
|
|
|
139
207
|
export async function applyKey({ agent, key, provider = null, home = homedir(), probe, executable = 'opencode', run = runOpenCode }) {
|
|
140
208
|
const checked = normalizeKey(key);
|
|
141
209
|
if (!checked.ok) return checked;
|
|
210
|
+
if (agent === 'claude') return applyClaudeKey(checked.key, { home, probe });
|
|
142
211
|
if (agent === 'gemini') return applyGeminiKey(checked.key, { home, probe });
|
|
143
212
|
if (agent === 'opencode') return applyOpenCodeKey(checked.key, { home, provider, executable, run });
|
|
144
213
|
return { ok: false, error: `${agent} does not take a key here: it signs in with a click.` };
|
|
145
214
|
}
|
|
215
|
+
|
|
216
|
+
// ---- OpenCode's providers, as its auth.json says them: who is signed in and how, never the key.
|
|
217
|
+
// `api` is a key (MADRE's or one pasted in a terminal), `oauth` an account signed in from
|
|
218
|
+
// `opencode auth login`. Anything unreadable reads as nobody.
|
|
219
|
+
export async function readOpenCodeProviders({ home = homedir() } = {}) {
|
|
220
|
+
const raw = await readIfThere(join(home, '.local', 'share', 'opencode', 'auth.json'));
|
|
221
|
+
let auth = {};
|
|
222
|
+
try { auth = JSON.parse(raw ?? '{}') ?? {}; } catch { auth = {}; }
|
|
223
|
+
if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return [];
|
|
224
|
+
return Object.entries(auth)
|
|
225
|
+
.filter(([id, entry]) => /^[\w.-]{1,60}$/.test(id) && entry && typeof entry === 'object')
|
|
226
|
+
.map(([id, entry]) => ({ id, type: entry.type === 'api' || entry.type === 'oauth' ? entry.type : 'other' }));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Takes a provider's key out of OpenCode's auth.json, the other entries untouched. Only a key
|
|
230
|
+
// (`api`): an account signed in with OpenCode's own login is signed out where it was signed in.
|
|
231
|
+
// OpenCode is the judge again: if it still lists the provider, the file goes back as it was.
|
|
232
|
+
export async function removeOpenCodeKey({ provider, home = homedir(), executable = 'opencode', run = runOpenCode } = {}) {
|
|
233
|
+
if (typeof provider !== 'string' || !/^[\w.-]{1,60}$/.test(provider)) return { ok: false, error: 'Pick the provider whose key goes.' };
|
|
234
|
+
const file = join(home, '.local', 'share', 'opencode', 'auth.json');
|
|
235
|
+
const before = await readIfThere(file);
|
|
236
|
+
let auth = {};
|
|
237
|
+
try { auth = JSON.parse(before ?? '{}') ?? {}; } catch { return { ok: false, error: 'OpenCode\'s auth.json could not be read, so nothing was changed.' }; }
|
|
238
|
+
if (!auth[provider]) return { ok: false, error: `OpenCode has no key for ${provider}.` };
|
|
239
|
+
if (auth[provider].type !== 'api') return { ok: false, error: `${provider} is signed in with its account, not a key: sign it out with \`opencode auth logout\` in a terminal.` };
|
|
240
|
+
try {
|
|
241
|
+
delete auth[provider];
|
|
242
|
+
await writeFile(file, `${JSON.stringify(auth, null, 2)}\n`, { mode: OWNER_ONLY });
|
|
243
|
+
await chmod(file, OWNER_ONLY).catch(() => {});
|
|
244
|
+
const listed = await run(executable, ['auth', 'list']);
|
|
245
|
+
if (new RegExp(`\\b${provider.replace(/[.-]/g, '\\$&')}\\b`, 'i').test(listed.replace(/auth\.json/gi, ''))) {
|
|
246
|
+
await restore(file, before);
|
|
247
|
+
return { ok: false, error: `OpenCode still lists ${provider}, so MADRE put the file back. Remove it with \`opencode auth logout\` in a terminal.` };
|
|
248
|
+
}
|
|
249
|
+
return { ok: true };
|
|
250
|
+
} catch (error) {
|
|
251
|
+
await restore(file, before);
|
|
252
|
+
return { ok: false, error: `The key could not be removed: ${error.message}` };
|
|
253
|
+
}
|
|
254
|
+
}
|
package/src/dataset.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// The dataset behind MADRE AI: every exchange the room kept (never GHOST),
|
|
2
4
|
// redacted, as chat pairs a small model can learn from, plus the distilled
|
|
3
5
|
// notes. Written next to the room's ledger in the format mlx-lm and most
|
package/src/directives.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Agent-to-agent delegation. An agent answering in the room may end its reply
|
|
2
4
|
// with a fenced block that puts other agents to work, in order:
|
|
3
5
|
//
|
|
@@ -9,9 +11,9 @@
|
|
|
9
11
|
//
|
|
10
12
|
// MADRE runs the steps sequentially as regular room turns, each recorded,
|
|
11
13
|
// each subject to handoff, budget and timeout, and each stoppable by the
|
|
12
|
-
// human.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
14
|
+
// human. The orchestrator gets a closing turn after the others have answered.
|
|
15
|
+
// Delegated agents cannot delegate further (depth is capped at one); the
|
|
16
|
+
// orchestrator may start another bounded wave after reviewing their results.
|
|
15
17
|
|
|
16
18
|
export const DELEGATION_HELP = (self, others, maxSteps) => [
|
|
17
19
|
`You may put other agents to work: ${others.map((id) => `@${id}`).join(', ')}.`,
|
|
@@ -21,8 +23,8 @@ export const DELEGATION_HELP = (self, others, maxSteps) => [
|
|
|
21
23
|
`@${others[0] ?? 'codex'}: <question for that agent>`,
|
|
22
24
|
`@${self}: <what you will do with their answers, optional closing turn for you>`,
|
|
23
25
|
'```',
|
|
24
|
-
'A step may name the mode it needs, "@codex #2: create the page" or "@claude #3: fix the router"; MADRE
|
|
25
|
-
`MADRE runs the steps in order (at most ${maxSteps}), shows every answer in the room, then hands you
|
|
26
|
+
'A step may name the mode it needs, "@codex #2: create the page" or "@claude #3: fix the router"; MADRE uses the human permission for this turn as the ceiling. A higher mode pauses for the human to unlock it for that agent, and #4 always does. Without a number a step only reads, or creates files when the plan may create; ask for #3 by name.',
|
|
27
|
+
`MADRE runs the steps in order (at most ${maxSteps}), shows every answer in the room, then hands you a closing turn to review progress. If work remains within the human permission, you may end that turn with another pulse plan; at most three waves run from one request.`,
|
|
26
28
|
'The plan block must be the very last thing in your reply: nothing after it, not even a closing sentence. Say everything else before it.',
|
|
27
29
|
'Address each agent once. Do not delegate what you can answer yourself.',
|
|
28
30
|
].join('\n');
|
|
@@ -47,7 +49,7 @@ export function parseDirectives(text, { self, available = [], maxSteps = 4 } = {
|
|
|
47
49
|
const line = raw.trim();
|
|
48
50
|
if (!line || line.startsWith('#')) continue;
|
|
49
51
|
// "@agent: text" or "@agent #2: text": the mode is a request, capped by the plan's ceiling and the agent's MAX MODE.
|
|
50
|
-
const match = line.match(/^[-*]?\s*@([a-z0-9_-]+)\s*(?:#([0-
|
|
52
|
+
const match = line.match(/^[-*]?\s*@([a-z0-9_-]+)\s*(?:#([0-4]))?\s*[::]\s*(.+)$/i);
|
|
51
53
|
if (!match) { ignored.push({ line, reason: 'not a step (expected "@agent: text" or "@agent #2: text")' }); continue; }
|
|
52
54
|
const agent = match[1].toLowerCase();
|
|
53
55
|
const instruction = match[3].trim();
|
package/src/distiller.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// The archivist's turn: every so often the cheapest agent in the room reads
|
|
2
4
|
// the exchanges nobody has distilled yet and writes the few notes worth
|
|
3
5
|
// keeping for every later turn. Pure functions here; the room schedules and
|
package/src/embeddings.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Embeddings for the room memory: meaning-aware recall on top of the lexical
|
|
2
4
|
// index. Gemini's embedding model through the user's own key (the same key
|
|
3
5
|
// Image Studio and the Gemini CLI use); no key, no embeddings, and recall stays
|
package/src/enlaces.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Links between rooms (docs/MACROVERSO.md, phase 3b): the contract two rooms sign, the key that
|
|
2
4
|
// proves a caller is a MADRE process on this Mac, the side that asks, and the counters of what
|
|
3
5
|
// crossed. A link reads; nothing here writes in another room.
|
|
@@ -108,7 +110,7 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
|
|
|
108
110
|
if (!Array.isArray(raw.kinds) || !raw.kinds.length) fail('A connection names the kinds that may cross.');
|
|
109
111
|
const kinds = [...new Set(raw.kinds.map(String))];
|
|
110
112
|
for (const kind of kinds) {
|
|
111
|
-
if (kind === 'aberration') fail('
|
|
113
|
+
if (kind === 'aberration') fail('A doubtful memory never crosses a connection.');
|
|
112
114
|
if (!LINK_KINDS.includes(kind)) fail(`"${kind}" is not a kind of memory.`);
|
|
113
115
|
}
|
|
114
116
|
kinds.sort((x, y) => LINK_KINDS.indexOf(x) - LINK_KINDS.indexOf(y));
|
|
@@ -124,7 +126,7 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
|
|
|
124
126
|
const revoked = isoOrNull(raw.revoked, 'revoked');
|
|
125
127
|
// Fase 3e: the room that reads also gets, on each turn, up to RECALL_NOTES of these notes without
|
|
126
128
|
// asking. Off unless the human ticks it; a reading like any other, counted against `perHour`.
|
|
127
|
-
if (raw.recall !== undefined && raw.recall !== null && typeof raw.recall !== 'boolean') fail('recall
|
|
129
|
+
if (raw.recall !== undefined && raw.recall !== null && typeof raw.recall !== 'boolean') fail('automatic recall can only be on or off.');
|
|
128
130
|
const recall = raw.recall === true;
|
|
129
131
|
|
|
130
132
|
const orders = raw.orders && typeof raw.orders === 'object' ? raw.orders : {};
|
|
@@ -136,8 +138,8 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
|
|
|
136
138
|
const allowed = Boolean(orders.allowed);
|
|
137
139
|
if (salas) {
|
|
138
140
|
const serves = direction === 'both' ? [a, b] : direction === 'a→b' ? [a] : [b];
|
|
139
|
-
if (serves.includes(salas)) fail('The Coordinator
|
|
140
|
-
if (allowed && a !== salas && b !== salas) fail('Orders need the Coordinator
|
|
141
|
+
if (serves.includes(salas)) fail('The Coordinator never shares through a connection: it gathers every project and is nobody\'s source.');
|
|
142
|
+
if (allowed && a !== salas && b !== salas) fail('Orders need the Coordinator at one end of the connection.');
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
// Who signed it (D-S13): the human always may; BISHOP only a link to read, with no orders, that
|
package/src/event-store.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
4
|
import { dirname, join } from 'node:path';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
package/src/exam.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// The three tests that answer "is this room ready to be developed with?"
|
|
2
4
|
//
|
|
3
5
|
// The maturity reading counts what the archive is made of. It cannot tell you whether the archive
|
|
@@ -174,9 +176,9 @@ export function consistencyExam({ findings = [], notes = [], now = Date.now() }
|
|
|
174
176
|
open, refuted, standing: standing.length, aberrations: aberrations.length, trend,
|
|
175
177
|
passed,
|
|
176
178
|
says: open
|
|
177
|
-
? t('
|
|
179
|
+
? (open === 1 ? t('The memory reviewer found 1 contradiction nobody has settled.') : t('The memory reviewer found {n} contradictions nobody has settled.', { n: open }))
|
|
178
180
|
: trend === 'rising'
|
|
179
|
-
? t('Nothing is open, but
|
|
181
|
+
? t('Nothing is open, but doubtful memories are being filed more often lately than they used to be.')
|
|
180
182
|
: t('Nothing contradicts anything: {standing} notes stand, {refuted} were taken out of circulation.', { standing: standing.length, refuted }),
|
|
181
183
|
};
|
|
182
184
|
}
|
|
@@ -231,9 +233,9 @@ export function saysFor(result) {
|
|
|
231
233
|
}
|
|
232
234
|
if (result.id === 'consistency' && Number.isFinite(result.open)) {
|
|
233
235
|
return result.open
|
|
234
|
-
? t('
|
|
236
|
+
? (result.open === 1 ? t('The memory reviewer found 1 contradiction nobody has settled.') : t('The memory reviewer found {n} contradictions nobody has settled.', { n: result.open }))
|
|
235
237
|
: result.trend === 'rising'
|
|
236
|
-
? t('Nothing is open, but
|
|
238
|
+
? t('Nothing is open, but doubtful memories are being filed more often lately than they used to be.')
|
|
237
239
|
: t('Nothing contradicts anything: {standing} notes stand, {refuted} were taken out of circulation.', { standing: result.standing, refuted: result.refuted });
|
|
238
240
|
}
|
|
239
241
|
return result.says ?? null;
|
package/src/extensions.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
import { spawn } from 'node:child_process';
|
|
2
4
|
import { MODULES, moduleById, describeModules } from './modules/index.mjs';
|
|
3
5
|
export { gitToplevel, findOnPath } from './modules/helpers.mjs';
|
|
@@ -25,38 +27,59 @@ export async function listExtensions({ projectRoot, agents = [], config = {}, im
|
|
|
25
27
|
// Runs one installer inside the project, streaming output lines. `runner`
|
|
26
28
|
// lets tests substitute the real installer with a local script.
|
|
27
29
|
// `npm` and other Node CLIs are .cmd shims on Windows, which cannot be spawned directly.
|
|
28
|
-
|
|
30
|
+
//
|
|
31
|
+
// A sign-in is the same spawn with a human in the middle of it, so three knobs exist for it:
|
|
32
|
+
// `stdin: 'pipe'` opens a way back in (a CLI that asks for a pasted code reads it there),
|
|
33
|
+
// `onChild` hands the caller that way in, and `flushIdleMs` emits a line that never ends in a
|
|
34
|
+
// newline — a prompt like `Paste code here > ` — once the output has been still for that long.
|
|
35
|
+
// Without it the prompt sits in the buffer and the room shows a sign-in that says nothing.
|
|
36
|
+
export function runInstaller({ command, args, projectRoot, onLine, timeoutMs = 600000, heartbeatMs = 8000, env = process.env, stdin = 'ignore', onChild = null, flushIdleMs = 0, quietNote = 'installer is quiet (npm install or git status can take a while)' }) {
|
|
29
37
|
return new Promise((resolve) => {
|
|
30
|
-
const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', CI: '1' }, stdio: [
|
|
38
|
+
const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', CI: '1' }, stdio: [stdin, 'pipe', 'pipe'], shell: process.platform === 'win32', windowsHide: true });
|
|
31
39
|
const started = Date.now();
|
|
32
40
|
let lastOutput = started;
|
|
41
|
+
let buffer = '';
|
|
42
|
+
const emit = (line, stream) => { lastOutput = Date.now(); onLine?.(line, stream); };
|
|
33
43
|
// Installers go quiet for long stretches (npm install, git status); say so.
|
|
34
44
|
const heartbeat = setInterval(() => {
|
|
45
|
+
// A half-line waiting on a human is said before the silence is reported, so the room shows
|
|
46
|
+
// the question instead of the heartbeat that would otherwise be the only thing on screen.
|
|
47
|
+
if (flushIdleMs && buffer.trim() && Date.now() - lastOutput >= flushIdleMs) { const partial = buffer.trim(); buffer = ''; emit(partial, 'stdout'); return; }
|
|
35
48
|
if (Date.now() - lastOutput >= heartbeatMs) {
|
|
36
|
-
onLine?.(`… still running · ${Math.round((Date.now() - started) / 1000)}s ·
|
|
49
|
+
onLine?.(`… still running · ${Math.round((Date.now() - started) / 1000)}s · ${quietNote}`, 'heartbeat');
|
|
37
50
|
lastOutput = Date.now();
|
|
38
51
|
}
|
|
39
|
-
}, Math.min(heartbeatMs, 2000));
|
|
52
|
+
}, Math.min(flushIdleMs || heartbeatMs, heartbeatMs, 2000));
|
|
40
53
|
heartbeat.unref?.();
|
|
41
|
-
let buffer = '';
|
|
42
54
|
const feed = (chunk, stream) => {
|
|
43
55
|
buffer += chunk;
|
|
44
56
|
let index;
|
|
45
57
|
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
46
58
|
const line = buffer.slice(0, index).replace(/\r$/, '');
|
|
47
59
|
buffer = buffer.slice(index + 1);
|
|
48
|
-
if (line.trim())
|
|
60
|
+
if (line.trim()) emit(line, stream);
|
|
49
61
|
}
|
|
50
62
|
};
|
|
51
63
|
child.stdout.on('data', (chunk) => feed(String(chunk), 'stdout'));
|
|
52
64
|
child.stderr.on('data', (chunk) => feed(String(chunk), 'stderr'));
|
|
53
65
|
const timer = setTimeout(() => { child.kill('SIGKILL'); }, timeoutMs);
|
|
54
|
-
|
|
66
|
+
let done = false;
|
|
67
|
+
const settle = (result) => { if (done) return; done = true; clearTimeout(timer); clearInterval(heartbeat); resolve(result); };
|
|
68
|
+
onChild?.({
|
|
69
|
+
// What the room can do to a sign-in while it runs: answer it, or call it off.
|
|
70
|
+
write: (text) => { try { child.stdin?.write(text); return true; } catch { return false; } },
|
|
71
|
+
cancel: () => {
|
|
72
|
+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
73
|
+
// A terminal UI can ignore a polite ask; it does not get to ignore the second one.
|
|
74
|
+
const hard = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 1500);
|
|
75
|
+
hard.unref?.();
|
|
76
|
+
},
|
|
77
|
+
pid: child.pid ?? null,
|
|
78
|
+
});
|
|
79
|
+
child.on('error', (error) => settle({ code: 1, error: error.code === 'ENOENT' ? `${error.message} (is the project folder present and ${command} on PATH?)` : error.message }));
|
|
55
80
|
child.on('close', (code) => {
|
|
56
|
-
clearTimeout(timer);
|
|
57
|
-
clearInterval(heartbeat);
|
|
58
81
|
if (buffer.trim()) onLine?.(buffer.trim(), 'stdout');
|
|
59
|
-
|
|
82
|
+
settle({ code: code ?? 1 });
|
|
60
83
|
});
|
|
61
84
|
});
|
|
62
85
|
}
|
package/src/eyecat-watch.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// EYECAT, as the room runs it. The judgement lives in eyecat.mjs; this schedules it, picks who
|
|
2
4
|
// may answer, and puts what comes back in front of the human.
|
|
3
5
|
//
|
package/src/eyecat.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// EYECAT: the watcher that asks whether the room still believes what it wrote down.
|
|
2
4
|
//
|
|
3
5
|
// Pure functions here. Nothing in this file talks to a model, reads a file or touches the room;
|
package/src/file-ops.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Copying and moving a file, from the human's own hands.
|
|
2
4
|
//
|
|
3
5
|
// Everything else in MADRE that writes into a project is an agent under a permission mode. This
|
package/src/files.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
4
|
import { mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
3
5
|
import { dirname, extname, join, resolve, sep } from 'node:path';
|
|
@@ -96,6 +98,36 @@ export async function walkProject(root, { maxFiles = 20000, ttlMs = 15000 } = {}
|
|
|
96
98
|
return files;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
// What changed on disk since a moment, by modification time (2026-10-14). A restore point sees
|
|
102
|
+
// only what git sees, so a PDF a command wrote into an ignored `output/` was there and nobody said
|
|
103
|
+
// so: the Finder did not refresh and nothing told whose it was. This looks at the files
|
|
104
|
+
// themselves, skipping the same heavy folders the search skips and MADRE's own `.pulse`/`.madre`,
|
|
105
|
+
// bounded so a large project costs a fraction of a second. Newest first.
|
|
106
|
+
const UNWATCHED = new Set([...SHALLOW, '.pulse', '.madre']);
|
|
107
|
+
export async function filesChangedSince(root, since, { maxFiles = 8000, limit = 40 } = {}) {
|
|
108
|
+
const from = Number(since) - 250;
|
|
109
|
+
if (!Number.isFinite(from)) return [];
|
|
110
|
+
const found = [];
|
|
111
|
+
let seen = 0;
|
|
112
|
+
async function walk(dir, prefix, depth) {
|
|
113
|
+
if (seen >= maxFiles || depth > 14) return;
|
|
114
|
+
let entries = [];
|
|
115
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
116
|
+
const files = [];
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (SKIP.has(entry.name)) continue;
|
|
119
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
120
|
+
if (entry.isDirectory()) { if (!UNWATCHED.has(entry.name)) await walk(join(dir, entry.name), rel, depth + 1); continue; }
|
|
121
|
+
if (entry.isFile()) files.push(rel);
|
|
122
|
+
}
|
|
123
|
+
seen += files.length;
|
|
124
|
+
const stats = await Promise.all(files.map((rel) => stat(join(root, rel)).then((info) => [rel, info.mtimeMs], () => null)));
|
|
125
|
+
for (const one of stats) if (one && one[1] >= from) found.push({ path: one[0], at: one[1] });
|
|
126
|
+
}
|
|
127
|
+
await walk(root, '', 0);
|
|
128
|
+
return found.sort((a, b) => b.at - a.at).slice(0, limit).map((one) => one.path);
|
|
129
|
+
}
|
|
130
|
+
|
|
99
131
|
export function scoreFile(path, query) {
|
|
100
132
|
const q = query.toLowerCase();
|
|
101
133
|
if (!q) return 1;
|
package/src/github.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// GitHub, as the GITHUB module and its MCP server both speak to it.
|
|
2
4
|
//
|
|
3
5
|
// Two ways in, and the order matters. First the `gh` CLI, if this computer has it and it is
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
3
|
+
// «Still in use»: once a day, every copy of MADRE tells madre.run it is on (2026-10-13).
|
|
4
|
+
//
|
|
5
|
+
// What goes, and nothing else: a random identifier this copy made for itself once
|
|
6
|
+
// (~/.pulse/install-id, from crypto.randomUUID — derived from nothing about the person or the
|
|
7
|
+
// machine), the MADRE version, the system (darwin, linux, win32) and Node's major version. No
|
|
8
|
+
// name, no email, no project, no path, no agent, nothing said in a room. madre.run keeps no IP
|
|
9
|
+
// (functions/api/ping.js of the landing). It is how the author knows how many people use MADRE,
|
|
10
|
+
// which versions they run and whether they come back — what npm's download count cannot say.
|
|
11
|
+
//
|
|
12
|
+
// It is said in docs/REFERENCE.md › «Lo que sale de la máquina» and on madre.run, and it goes
|
|
13
|
+
// through the same fetch every other outbound request does, so the room's own list of what left
|
|
14
|
+
// the machine shows it. Offline or behind a firewall it fails in silence and is tried again
|
|
15
|
+
// later: MADRE never waits on it. PULSE_HEARTBEAT_URL points it elsewhere, for development and
|
|
16
|
+
// for the test suite, which must never reach madre.run.
|
|
17
|
+
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
export const HEARTBEAT_URL = 'https://madre.run/api/ping';
|
|
23
|
+
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
24
|
+
|
|
25
|
+
export async function installId(root) {
|
|
26
|
+
const file = join(root, 'install-id');
|
|
27
|
+
const kept = (await readFile(file, 'utf8').catch(() => '')).trim();
|
|
28
|
+
if (ID.test(kept)) return kept;
|
|
29
|
+
const made = randomUUID();
|
|
30
|
+
await mkdir(root, { recursive: true });
|
|
31
|
+
await writeFile(file, `${made}\n`, { mode: 0o600 });
|
|
32
|
+
return made;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// What the request carries: built here, in one place, so a test can say exactly what leaves.
|
|
36
|
+
export const heartbeatBody = ({ id, version, platform = process.platform, node = process.versions.node }) => ({
|
|
37
|
+
id, version, os: ['darwin', 'linux', 'win32'].includes(platform) ? platform : 'other', node: String(node).split('.')[0],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Once per UTC day per copy, whichever of its processes gets there first; a day that failed is
|
|
41
|
+
// tried again on the next call.
|
|
42
|
+
export async function heartbeat({ root, version, fetchImpl = globalThis.fetch, url = process.env.PULSE_HEARTBEAT_URL ?? HEARTBEAT_URL, now = new Date() }) {
|
|
43
|
+
const day = now.toISOString().slice(0, 10);
|
|
44
|
+
const stamp = join(root, 'heartbeat.json');
|
|
45
|
+
try {
|
|
46
|
+
if (JSON.parse(await readFile(stamp, 'utf8')).day === day) return { sent: false, reason: 'today' };
|
|
47
|
+
} catch { /* never sent, or unreadable: send */ }
|
|
48
|
+
try {
|
|
49
|
+
const body = heartbeatBody({ id: await installId(root), version });
|
|
50
|
+
const response = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(5000) });
|
|
51
|
+
if (!response.ok) return { sent: false, reason: `HTTP ${response.status}` };
|
|
52
|
+
await writeFile(stamp, `${JSON.stringify({ day })}\n`);
|
|
53
|
+
return { sent: true, body };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return { sent: false, reason: error?.message ?? 'unreachable' };
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/i18n.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// The room's language, on this side of the wire.
|
|
2
4
|
//
|
|
3
5
|
// The catalogue is the page's: public/es.js, one file for the whole product, because a sentence
|
|
@@ -24,9 +26,21 @@ export function setLanguage(id) {
|
|
|
24
26
|
return current;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
// «Mac» only on a Mac (2026-10-15): MADRE installs on Linux too, and a sentence about the machine
|
|
30
|
+
// it runs on says «computer» there. Same rule as public/i18n.js (hostWords); hardware named on
|
|
31
|
+
// purpose («a Mac with an M chip») has no article in front and is left alone.
|
|
32
|
+
let hostIsMac = process.platform === 'darwin';
|
|
33
|
+
export const setHostIsMac = (value) => { hostIsMac = Boolean(value); return hostIsMac; };
|
|
34
|
+
export function hostWords(text) {
|
|
35
|
+
if (hostIsMac || typeof text !== 'string' || !text.includes('Mac')) return text;
|
|
36
|
+
return text
|
|
37
|
+
.replace(/\b(this|your|the|This|Your|The) Mac\b/g, '$1 computer')
|
|
38
|
+
.replace(/(^|[^\p{L}])(esta|tu|la|Esta|Tu|La) Mac\b/gu, '$1$2 computadora');
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
export function t(text, vars = null) {
|
|
28
42
|
if (typeof text !== 'string' || !text) return text;
|
|
29
|
-
const said = CATALOGUES[current]?.[text] ?? text;
|
|
43
|
+
const said = hostWords(CATALOGUES[current]?.[text] ?? text);
|
|
30
44
|
if (!vars) return said;
|
|
31
45
|
return said.replace(/\{(\w+)\}/g, (whole, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : whole));
|
|
32
46
|
}
|
package/src/image-studio.mjs
CHANGED
package/src/json-file.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// Small files that more than one thing changes at once (2026-10-12, Lote 2 of the 1.0 review): the
|
|
2
4
|
// tour, the review ask, a project's record of where it is, the list of a room's conversations.
|
|
3
5
|
// Each was read, changed and written with nothing between two changes, so the second one wrote
|
package/src/launch.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
|
|
1
3
|
// What is shown of a command, and what is never shown.
|
|
2
4
|
//
|
|
3
5
|
// THE LAUNCH floor in the core prints the exact command MADRE would run, because a person who is
|