@jossuealcala/madre 1.0.1 → 1.0.3
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 +2013 -0
- package/CHANGELOG.md +102 -1
- package/CONTRIBUTING.md +1 -1
- package/LICENSE +661 -202
- package/NOTICE +26 -14
- package/README.en.md +241 -0
- package/README.md +21 -12
- package/TRADEMARKS.en.md +50 -0
- package/TRADEMARKS.md +33 -16
- package/bin/madre.cjs +40 -0
- package/bin/madre.mjs +7 -0
- package/docs/INTERNALS.md +6 -0
- package/docs/REFERENCE.md +40 -5
- package/docs/SDK.md +4 -2
- package/docs/en/INTERNALS.md +103 -0
- package/docs/en/REFERENCE.md +365 -0
- package/docs/en/SDK.md +308 -0
- package/docs/en/training/README.md +59 -0
- package/docs/training/README.md +48 -9
- package/docs/training/train.sh +7 -2
- package/package.json +13 -5
- package/public/abduction.js +2 -0
- package/public/app.js +3154 -539
- package/public/attribution.js +114 -0
- package/public/brands.js +23 -6
- package/public/code-tools.js +143 -0
- package/public/editor.js +22 -4
- package/public/es.js +1004 -416
- package/public/hardware.js +50 -0
- package/public/i18n.js +25 -1
- package/public/index.html +18 -11
- package/public/inquiry.js +2 -0
- package/public/loader.js +2 -0
- package/public/macroverso.js +189 -96
- package/public/market-art.js +161 -0
- package/public/market-view.js +529 -0
- package/public/market.js +189 -0
- package/public/neural.js +110 -0
- package/public/nostromo-engine.js +414 -47
- package/public/power.js +100 -0
- package/public/resay.js +11 -0
- package/public/review-ask.js +2 -0
- package/public/routing.js +23 -1
- package/public/salas-mac.css +119 -18
- package/public/salas-mac.html +14 -2
- package/public/salas-mac.js +358 -56
- package/public/salas.css +2 -0
- package/public/salas.html +1 -0
- package/public/salas.js +4 -2
- package/public/styles.css +1793 -77
- package/public/syntax.js +2 -0
- package/public/touch.js +2 -0
- package/public/tour-chapters.js +2 -0
- package/public/tour.js +84 -5
- package/public/troubleshooting.js +24 -17
- package/public/wallpaper.js +275 -0
- package/src/adapters/claude.mjs +2 -0
- package/src/adapters/codex.mjs +2 -0
- package/src/adapters/cursor.mjs +96 -0
- package/src/adapters/gemini.mjs +13 -4
- package/src/adapters/madre.mjs +5 -2
- 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 +145 -12
- 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 +11 -0
- package/src/chats.mjs +2 -0
- package/src/checkpoint.mjs +18 -1
- 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 +35 -3
- package/src/dataset.mjs +78 -4
- package/src/directives.mjs +2 -0
- package/src/distiller.mjs +2 -0
- package/src/embeddings.mjs +2 -0
- package/src/enlaces.mjs +214 -6
- package/src/event-store.mjs +2 -0
- package/src/exam.mjs +6 -4
- package/src/extensions.mjs +13 -3
- 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 +2 -0
- package/src/i18n.mjs +31 -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 +2 -0
- package/src/looks.mjs +127 -2
- package/src/market-key.mjs +8 -0
- package/src/market-remote.mjs +239 -0
- 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 +49 -3
- package/src/module-scope.mjs +68 -0
- package/src/modules/ahp.mjs +104 -7
- package/src/modules/ash-policy.mjs +2 -0
- package/src/modules/ash.mjs +3 -1
- package/src/modules/bishop-exam.mjs +122 -0
- package/src/modules/bishop-plan.mjs +5 -3
- package/src/modules/bishop.mjs +84 -23
- package/src/modules/choices.mjs +2 -0
- package/src/modules/correo.mjs +2 -0
- package/src/modules/enlaces.mjs +89 -6
- 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 +13 -2
- 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 +11 -7
- package/src/modules/local-models.mjs +124 -0
- package/src/modules/mcp-connect.mjs +293 -0
- package/src/modules/ollama.mjs +15 -4
- package/src/modules/playwright.mjs +88 -15
- package/src/modules/ripley.mjs +2 -0
- package/src/modules/runs.mjs +5 -3
- package/src/modules/sdk.mjs +63 -9
- package/src/modules/updates.mjs +2 -0
- package/src/mother.mjs +2 -0
- package/src/npm-launch.mjs +18 -0
- package/src/ollama.mjs +8 -5
- package/src/outbound.mjs +16 -6
- package/src/override.mjs +2 -0
- package/src/ports.mjs +6 -2
- 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 +2 -0
- package/src/room/ghost.mjs +2 -0
- package/src/room/guard.mjs +2 -0
- package/src/room/prompt.mjs +25 -5
- 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 +223 -35
- package/src/router.mjs +3 -1
- package/src/runs.mjs +6 -1
- package/src/runtime-detection.mjs +11 -0
- package/src/salas.mjs +28 -0
- package/src/sentinel-errors.mjs +2 -0
- package/src/server-sentinel.mjs +2 -0
- package/src/server.mjs +757 -81
- package/src/setup.mjs +3 -1
- package/src/smtp.mjs +2 -0
- package/src/tour.mjs +2 -0
- 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 +2 -0
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,6 +38,32 @@ 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) {
|
|
41
69
|
if (id === 'claude') {
|
|
@@ -58,6 +86,9 @@ export function keyPlanFor(id) {
|
|
|
58
86
|
writesTo: '~/.gemini/.env',
|
|
59
87
|
note: 'The key is written where the Gemini CLI looks for it, readable only by you. MADRE keeps no copy.',
|
|
60
88
|
providers: null,
|
|
89
|
+
// The way in, ahead of the Google account: Google refuses free personal accounts on this
|
|
90
|
+
// CLI (IneligibleTierError, 2026-10-17), and a key from AI Studio is what works for anyone.
|
|
91
|
+
first: true,
|
|
61
92
|
};
|
|
62
93
|
}
|
|
63
94
|
if (id === 'opencode') {
|
|
@@ -140,7 +171,7 @@ async function applyClaudeKey(key, { home, probe }) {
|
|
|
140
171
|
|
|
141
172
|
// ---- OpenCode: an entry in its auth.json, confirmed by asking `opencode auth list`.
|
|
142
173
|
async function applyOpenCodeKey(key, { home, provider, executable, run }) {
|
|
143
|
-
const known =
|
|
174
|
+
const known = typeof provider === 'string' ? (await openCodeKeyProviders({ home })).find((item) => item.id === provider) : null;
|
|
144
175
|
if (!known) return { ok: false, error: 'Pick the provider this key belongs to.' };
|
|
145
176
|
const file = join(home, '.local', 'share', 'opencode', 'auth.json');
|
|
146
177
|
await mkdir(join(home, '.local', 'share', 'opencode'), { recursive: true });
|
|
@@ -153,8 +184,9 @@ async function applyOpenCodeKey(key, { home, provider, executable, run }) {
|
|
|
153
184
|
await chmod(file, OWNER_ONLY).catch(() => {});
|
|
154
185
|
// OpenCode itself is the judge: if its own listing does not show the provider, the shape
|
|
155
186
|
// MADRE wrote is not the shape it reads, and nothing should be left behind.
|
|
156
|
-
|
|
157
|
-
|
|
187
|
+
// It lists the provider by its name («Together AI»), sometimes by its id.
|
|
188
|
+
const listed = String(await run(executable, ['auth', 'list'])).toLowerCase();
|
|
189
|
+
if (!listed.includes(provider.toLowerCase()) && !listed.includes(String(known.label).toLowerCase())) {
|
|
158
190
|
await restore(file, before);
|
|
159
191
|
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.` };
|
|
160
192
|
}
|
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
|
|
@@ -5,7 +7,7 @@
|
|
|
5
7
|
// Export is the first step of roadmap 2c; training runs outside MADRE, on the
|
|
6
8
|
// human's machine, from docs/training/.
|
|
7
9
|
|
|
8
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
9
11
|
import { join } from 'node:path';
|
|
10
12
|
import { redact } from './sentinel-errors.mjs';
|
|
11
13
|
import { ABERRATION, VERDICTS, NEGATIVE_VERDICTS, POSITIVE_VERDICTS } from './memory.mjs';
|
|
@@ -119,6 +121,78 @@ export function pairsFromNotes(notes, { project = 'project', home, user, privacy
|
|
|
119
121
|
}));
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
// D-S15 · what the Coordinator learns from the projects that let it. Each note of a linked project
|
|
125
|
+
// becomes a pair that names its project — in the question, in the answer and in the citation — so
|
|
126
|
+
// the model learns which project knows what and never blends two projects into one. The notes
|
|
127
|
+
// arrive already cleaned by the project that served them; they are cleaned again here, by Salas.
|
|
128
|
+
export function pairsFromLinked(corpora, { home, user, privacy = null } = {}) {
|
|
129
|
+
const clean = guard(privacy);
|
|
130
|
+
const pairs = [];
|
|
131
|
+
for (const corpus of corpora ?? []) {
|
|
132
|
+
const name = String(corpus?.name ?? corpus?.from ?? 'project');
|
|
133
|
+
for (const note of corpus?.notes ?? []) {
|
|
134
|
+
if (!note || typeof note.text !== 'string' || !note.text.trim() || note.kind === ABERRATION) continue;
|
|
135
|
+
const text = clean(redact(note.text, { home, user }));
|
|
136
|
+
const cite = `${name} #${note.fromSequence}${note.throughSequence && note.throughSequence !== note.fromSequence ? `–#${note.throughSequence}` : ''}`;
|
|
137
|
+
pairs.push({
|
|
138
|
+
kind: 'linked',
|
|
139
|
+
agent: 'madre',
|
|
140
|
+
from: corpus.from ?? null,
|
|
141
|
+
mode: 1,
|
|
142
|
+
sequence: Number.isInteger(note.throughSequence) ? note.throughSequence : 0,
|
|
143
|
+
at: note.created ?? null,
|
|
144
|
+
messages: [
|
|
145
|
+
{ role: 'system', content: LINKED_SYSTEM },
|
|
146
|
+
{ role: 'user', content: `What does the project "${name}" remember about this? Kind: ${note.kind}. Topic: ${text.split(/[.;:]/)[0].slice(0, 80)}` },
|
|
147
|
+
{ role: 'assistant', content: `In ${name}: ${text} [${cite}]` },
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// D-S16: the project's canonical state, as a photo with its date. The date is in the question
|
|
152
|
+
// and in the answer, so the model learns «as of», never «now»: for today it asks the project.
|
|
153
|
+
const state = corpus?.state;
|
|
154
|
+
if (state?.project) {
|
|
155
|
+
const date = String(state.project.updatedAt ?? corpus.at ?? '').slice(0, 10) || 'an unknown date';
|
|
156
|
+
const say = (value) => clean(redact(String(value ?? ''), { home, user }));
|
|
157
|
+
const base = Number.isInteger(corpus.notes?.at(-1)?.throughSequence) ? corpus.notes.at(-1).throughSequence : 0;
|
|
158
|
+
const pair = (offset, question, answer) => pairs.push({ kind: 'linked', agent: 'madre', from: corpus.from ?? null, mode: 1, sequence: base + offset, at: state.project.updatedAt ?? null, messages: [{ role: 'system', content: LINKED_SYSTEM }, { role: 'user', content: question }, { role: 'assistant', content: answer }] });
|
|
159
|
+
const where = [state.project.objective && `the objective is ${say(state.project.objective)}`, state.project.phase && `the phase is ${say(state.project.phase)}`, state.project.nextAction && `the next action is ${say(state.project.nextAction)}`, state.project.blockers?.length && `it is blocked by ${say(state.project.blockers.join('; '))}`].filter(Boolean);
|
|
160
|
+
if (where.length) pair(1, `Where did the project "${name}" stand on ${date}?`, `As of ${date}, in ${name}: ${where.join('; ')}. For today, ask ${name}. [${name} · state ${date}]`);
|
|
161
|
+
(state.decisions ?? []).forEach((decision, index) => {
|
|
162
|
+
if (!decision?.title) return;
|
|
163
|
+
pair(2 + index, `What did the project "${name}" decide about ${say(decision.title).slice(0, 80)}?`, `In ${name}, accepted decision as of ${String(decision.at ?? date).slice(0, 10)}: ${say(decision.title)}${decision.description ? `. ${say(decision.description)}` : ''} [${name} · Continuity]`);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return pairs;
|
|
168
|
+
}
|
|
169
|
+
const LINKED_SYSTEM = 'You are the Coordinator of MADRE. You know the projects connected to you and what each one decided and recorded. Say which project every fact comes from, and never mix two projects.';
|
|
170
|
+
|
|
171
|
+
// Salas keeps the last corpus each project handed over (D-S15), so a project asleep when the
|
|
172
|
+
// Coordinator exports is not lost from its training: what it shared last time is used, and said so.
|
|
173
|
+
// One file per project, beside the dataset; forgotten as soon as the link stops allowing it.
|
|
174
|
+
export const linkedCorpusDir = (datasetDir) => join(datasetDir, 'linked');
|
|
175
|
+
const corpusFile = (datasetDir, room) => join(linkedCorpusDir(datasetDir), `${String(room).replace(/[^\w.-]/g, '_')}.json`);
|
|
176
|
+
export async function keepCorpus(datasetDir, corpus, { now = new Date() } = {}) {
|
|
177
|
+
await mkdir(linkedCorpusDir(datasetDir), { recursive: true });
|
|
178
|
+
await writeFile(corpusFile(datasetDir, corpus.from), `${JSON.stringify({ contract: corpus.contract, from: corpus.from, name: corpus.name, at: new Date(now).toISOString(), notes: corpus.notes, ...(corpus.state ? { state: corpus.state } : {}) })}\n`, { mode: 0o600 });
|
|
179
|
+
}
|
|
180
|
+
export async function keptCorpus(datasetDir, room) {
|
|
181
|
+
try { return JSON.parse(await readFile(corpusFile(datasetDir, room), 'utf8')); } catch { return null; }
|
|
182
|
+
}
|
|
183
|
+
export async function forgetCorpus(datasetDir, room) {
|
|
184
|
+
await rm(corpusFile(datasetDir, room), { force: true });
|
|
185
|
+
}
|
|
186
|
+
// Every kept copy whose project no longer lets the Coordinator learn goes: `allowed` is the set of
|
|
187
|
+
// rooms that still do.
|
|
188
|
+
export async function pruneCorpora(datasetDir, allowed) {
|
|
189
|
+
const names = await readdir(linkedCorpusDir(datasetDir)).catch(() => []);
|
|
190
|
+
for (const name of names) {
|
|
191
|
+
const kept = await keptCorpus(datasetDir, name.replace(/\.json$/, ''));
|
|
192
|
+
if (!kept || !allowed.has(kept.from)) await rm(join(linkedCorpusDir(datasetDir), name), { force: true });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
122
196
|
// What the room got wrong, in the shape that trains against it. Each aberration becomes one
|
|
123
197
|
// preference pair: the same ask, the false claim as what to avoid, the correction as what to say
|
|
124
198
|
// instead. A negative on its own teaches a model very little; a pair tells it which of two
|
|
@@ -167,8 +241,8 @@ export function split(pairs, { validEvery = 10 } = {}) {
|
|
|
167
241
|
return { train, valid };
|
|
168
242
|
}
|
|
169
243
|
|
|
170
|
-
export async function exportDataset({ events, notes = [], dir, project = 'project', home, user, privacy = null }) {
|
|
171
|
-
const pairs = [...pairsFromEvents(events, { project, home, user, privacy }), ...pairsFromNotes(notes, { project, home, user, privacy })].sort((a, b) => a.sequence - b.sequence);
|
|
244
|
+
export async function exportDataset({ events, notes = [], dir, project = 'project', home, user, privacy = null, linked = [] }) {
|
|
245
|
+
const pairs = [...pairsFromEvents(events, { project, home, user, privacy }), ...pairsFromNotes(notes, { project, home, user, privacy }), ...pairsFromLinked(linked, { home, user, privacy })].sort((a, b) => a.sequence - b.sequence);
|
|
172
246
|
const preferences = preferencesFromAberrations(notes, { project, home, user, privacy });
|
|
173
247
|
const { train, valid } = split(pairs);
|
|
174
248
|
await mkdir(dir, { recursive: true });
|
|
@@ -180,7 +254,7 @@ export async function exportDataset({ events, notes = [], dir, project = 'projec
|
|
|
180
254
|
await writeFile(join(dir, 'preferences.jsonl'), preferences.map((pair) => JSON.stringify({ prompt: pair.prompt, chosen: pair.chosen, rejected: pair.rejected })).join('\n') + (preferences.length ? '\n' : ''));
|
|
181
255
|
const byAgent = {};
|
|
182
256
|
for (const pair of pairs) byAgent[pair.agent] = (byAgent[pair.agent] ?? 0) + 1;
|
|
183
|
-
const manifest = { project, exportedAt: new Date().toISOString(), pairs: pairs.length, turns: pairs.filter((p) => p.kind === 'turn').length, delegated: pairs.filter((p) => p.kind === 'delegated').length, good: pairs.filter((p) => POSITIVE_VERDICTS.includes(p.rating)).length, notes: pairs.filter((p) => p.kind === 'note').length, aberrations: preferences.length, train: train.length, valid: valid.length, byAgent, files: ['train.jsonl', 'valid.jsonl', 'preferences.jsonl'], format: 'chat · {"messages":[{role,content}]} · mlx-lm / llama-factory / axolotl', preferenceFormat: 'preference · {"prompt","chosen","rejected"} · DPO / ORPO' };
|
|
257
|
+
const manifest = { project, exportedAt: new Date().toISOString(), pairs: pairs.length, turns: pairs.filter((p) => p.kind === 'turn').length, delegated: pairs.filter((p) => p.kind === 'delegated').length, good: pairs.filter((p) => POSITIVE_VERDICTS.includes(p.rating)).length, notes: pairs.filter((p) => p.kind === 'note').length, linked: (linked ?? []).map((one) => ({ room: one.from, name: one.name, notes: pairs.filter((p) => p.kind === 'linked' && p.from === one.from).length, source: one.source ?? 'live', at: one.at ?? null })), aberrations: preferences.length, train: train.length, valid: valid.length, byAgent, files: ['train.jsonl', 'valid.jsonl', 'preferences.jsonl'], format: 'chat · {"messages":[{role,content}]} · mlx-lm / llama-factory / axolotl', preferenceFormat: 'preference · {"prompt","chosen","rejected"} · DPO / ORPO' };
|
|
184
258
|
await writeFile(join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
185
259
|
return { dir, ...manifest };
|
|
186
260
|
}
|
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
|
//
|
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.
|
|
@@ -15,7 +17,7 @@
|
|
|
15
17
|
// which room: that limit is written down, not papered over.
|
|
16
18
|
|
|
17
19
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
18
|
-
import { appendFile, chmod, link, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { appendFile, chmod, link, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
19
21
|
import { join } from 'node:path';
|
|
20
22
|
import { withConfig } from './config.mjs';
|
|
21
23
|
import { RECALLED_ZONES, STANDING_KINDS } from './memory.mjs';
|
|
@@ -38,7 +40,23 @@ export const NO_SUCH_LINK = 'No such link.';
|
|
|
38
40
|
// `order` is one of BISHOP's orders that reached this room (fase 4b). It is counted apart from the
|
|
39
41
|
// readings, each kind against the contract's own `perHour`: asking BISHOP a question, which reads,
|
|
40
42
|
// never uses up the orders, and an order never uses up a reading.
|
|
41
|
-
|
|
43
|
+
// `corpus` is a whole reading for the Coordinator's training (D-S15), counted apart from the
|
|
44
|
+
// readings of a turn and against its own ceiling; `learned` is the same crossing seen from Salas.
|
|
45
|
+
// `state` is the project's canonical state read across a link (D-S16), counted against `perHour`
|
|
46
|
+
// apart from the readings of notes. `delivered` is a note another project handed to this one, kept
|
|
47
|
+
// pending until this project accepts it (D-S17); `sent` is the same crossing seen from the sender.
|
|
48
|
+
export const CROSSINGS = ['read', 'received', 'order', 'corpus', 'learned', 'state', 'delivered', 'sent'];
|
|
49
|
+
// D-S15: what one training export may take from one project, and how many such exports an hour.
|
|
50
|
+
export const CORPUS_MAX = 5000;
|
|
51
|
+
export const CORPUS_PER_HOUR = 6;
|
|
52
|
+
// D-S17: what one delivery may carry, how long each note may be, and how many may wait at once in
|
|
53
|
+
// the project that receives them before it accepts or declines some.
|
|
54
|
+
export const DELIVER_MAX = 5;
|
|
55
|
+
export const DELIVER_CHARS = 600;
|
|
56
|
+
export const INBOX_MAX = 50;
|
|
57
|
+
// The permissions a link carries besides reading, each ticked only by the human from Home: D-S14
|
|
58
|
+
// (recall), D-S15 (train), D-S16 (state) and D-S17 (deliver). BISHOP never signs any of them.
|
|
59
|
+
export const LINK_FLAGS = ['recall', 'train', 'state', 'deliver'];
|
|
42
60
|
// What one room may tell another about a link, by the channel: that it was signed or revoked.
|
|
43
61
|
// Each lands in the other room's ledger as a line, never as a message (phase 3c).
|
|
44
62
|
export const NOTICES = ['enlace.signed', 'enlace.revoked'];
|
|
@@ -108,7 +126,7 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
|
|
|
108
126
|
if (!Array.isArray(raw.kinds) || !raw.kinds.length) fail('A connection names the kinds that may cross.');
|
|
109
127
|
const kinds = [...new Set(raw.kinds.map(String))];
|
|
110
128
|
for (const kind of kinds) {
|
|
111
|
-
if (kind === 'aberration') fail('
|
|
129
|
+
if (kind === 'aberration') fail('A doubtful memory never crosses a connection.');
|
|
112
130
|
if (!LINK_KINDS.includes(kind)) fail(`"${kind}" is not a kind of memory.`);
|
|
113
131
|
}
|
|
114
132
|
kinds.sort((x, y) => LINK_KINDS.indexOf(x) - LINK_KINDS.indexOf(y));
|
|
@@ -124,8 +142,20 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
|
|
|
124
142
|
const revoked = isoOrNull(raw.revoked, 'revoked');
|
|
125
143
|
// Fase 3e: the room that reads also gets, on each turn, up to RECALL_NOTES of these notes without
|
|
126
144
|
// 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
|
|
145
|
+
if (raw.recall !== undefined && raw.recall !== null && typeof raw.recall !== 'boolean') fail('automatic recall can only be on or off.');
|
|
128
146
|
const recall = raw.recall === true;
|
|
147
|
+
// D-S15: the Coordinator may learn from this project — its notes enter the Coordinator's training
|
|
148
|
+
// export (src/dataset.mjs) and, once trained, its local model. Off unless the human ticks it, and
|
|
149
|
+
// only on a link the Coordinator reads; checked against the Coordinator below.
|
|
150
|
+
if (raw.train !== undefined && raw.train !== null && typeof raw.train !== 'boolean') fail('learning can only be on or off.');
|
|
151
|
+
const train = raw.train === true;
|
|
152
|
+
// D-S16: the room that reads may also read the project's canonical state (Continuity, AHP+).
|
|
153
|
+
if (raw.state !== undefined && raw.state !== null && typeof raw.state !== 'boolean') fail('sharing the project state can only be on or off.');
|
|
154
|
+
const state = raw.state === true;
|
|
155
|
+
// D-S17: the room that reads may hand notes back to the room that serves, where they wait until
|
|
156
|
+
// that project accepts them. A delivery never writes in the other project's memory by itself.
|
|
157
|
+
if (raw.deliver !== undefined && raw.deliver !== null && typeof raw.deliver !== 'boolean') fail('delivering notes can only be on or off.');
|
|
158
|
+
const deliver = raw.deliver === true;
|
|
129
159
|
|
|
130
160
|
const orders = raw.orders && typeof raw.orders === 'object' ? raw.orders : {};
|
|
131
161
|
const maxMode = orders.maxMode === undefined ? 1 : Number(orders.maxMode);
|
|
@@ -136,8 +166,9 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
|
|
|
136
166
|
const allowed = Boolean(orders.allowed);
|
|
137
167
|
if (salas) {
|
|
138
168
|
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
|
|
169
|
+
if (serves.includes(salas)) fail('The Coordinator never shares through a connection: it gathers every project and is nobody\'s source.');
|
|
170
|
+
if (allowed && a !== salas && b !== salas) fail('Orders need the Coordinator at one end of the connection.');
|
|
171
|
+
if (train && a !== salas && b !== salas) fail('Only the Coordinator learns from a connection.');
|
|
141
172
|
}
|
|
142
173
|
|
|
143
174
|
// Who signed it (D-S13): the human always may; BISHOP only a link to read, with no orders, that
|
|
@@ -149,6 +180,9 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
|
|
|
149
180
|
const at = signed.at === undefined || signed.at === null ? new Date(now).toISOString() : isoOrNull(signed.at, 'signed.at');
|
|
150
181
|
if (by === 'bishop') {
|
|
151
182
|
if (allowed) fail('The Coordinator signs only connections to read; a connection with orders is signed by you.');
|
|
183
|
+
if (train) fail('The Coordinator never signs what it learns from: you do.');
|
|
184
|
+
if (state) fail('The Coordinator never signs a connection that shares the project state: you do.');
|
|
185
|
+
if (deliver) fail('The Coordinator never signs a connection that delivers notes: you do.');
|
|
152
186
|
if (expires === null || Date.parse(expires) - Date.parse(at) > BISHOP_SIGN_DAYS * DAY_MS + 60000) fail('The Coordinator signs only connections that expire within 7 days.'); // BISHOP_SIGN_DAYS, said literally for the catalogue
|
|
153
187
|
}
|
|
154
188
|
|
|
@@ -163,6 +197,9 @@ export function normalizeContract(raw, { now = new Date(), salas = null } = {})
|
|
|
163
197
|
orders: { allowed, maxMode, perHour },
|
|
164
198
|
signed: { at, by },
|
|
165
199
|
recall,
|
|
200
|
+
train,
|
|
201
|
+
state,
|
|
202
|
+
deliver,
|
|
166
203
|
};
|
|
167
204
|
}
|
|
168
205
|
|
|
@@ -272,6 +309,46 @@ export async function recallContract(stateRoot, id, recall, { now = new Date(),
|
|
|
272
309
|
return changed;
|
|
273
310
|
}
|
|
274
311
|
|
|
312
|
+
// «The Coordinator may learn from this project» (D-S15), ticked or cleared by the human: the same
|
|
313
|
+
// one-step read and write as recall, on a link still in force. Cleared, nothing more of that
|
|
314
|
+
// project enters the Coordinator's next export, and Salas forgets the copy it kept for it.
|
|
315
|
+
export async function trainContract(stateRoot, id, train, { now = new Date(), salas = null } = {}) {
|
|
316
|
+
if (typeof id !== 'string' || !ID.test(id)) return null;
|
|
317
|
+
let changed = null;
|
|
318
|
+
await withConfig(stateRoot, (config) => {
|
|
319
|
+
if (!activeContracts(config, { now, salas }).some((one) => one.id === id)) return null;
|
|
320
|
+
const next = { ...config.enlaces[id], train: train === true };
|
|
321
|
+
normalizeContract({ ...next, id }, { now, salas });
|
|
322
|
+
config.enlaces[id] = next;
|
|
323
|
+
changed = activeContracts(config, { now, salas }).find((one) => one.id === id) ?? null;
|
|
324
|
+
return config;
|
|
325
|
+
});
|
|
326
|
+
return changed;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// D-S16 and D-S17: «it shares its state» or «notes can be delivered to it», ticked or cleared by
|
|
330
|
+
// the human from Home. The same one-step read and write as recall and train, on a link in force.
|
|
331
|
+
export async function allowContract(stateRoot, id, field, on, { now = new Date(), salas = null } = {}) {
|
|
332
|
+
if (!['state', 'deliver'].includes(field)) fail('That is not something a connection can allow.');
|
|
333
|
+
if (typeof id !== 'string' || !ID.test(id)) return null;
|
|
334
|
+
let changed = null;
|
|
335
|
+
await withConfig(stateRoot, (config) => {
|
|
336
|
+
if (!activeContracts(config, { now, salas }).some((one) => one.id === id)) return null;
|
|
337
|
+
const next = { ...config.enlaces[id], [field]: on === true };
|
|
338
|
+
normalizeContract({ ...next, id }, { now, salas });
|
|
339
|
+
config.enlaces[id] = next;
|
|
340
|
+
changed = activeContracts(config, { now, salas }).find((one) => one.id === id) ?? null;
|
|
341
|
+
return config;
|
|
342
|
+
});
|
|
343
|
+
return changed;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The rooms `sender` may deliver notes to (D-S17): the rooms it reads from under a link that says
|
|
347
|
+
// `deliver`. A delivery goes against the arrow — to the project whose notes this one reads.
|
|
348
|
+
export function deliversTo(contracts, sender, { now = new Date() } = {}) {
|
|
349
|
+
return readsFrom(contracts, sender, { now }).filter(({ contract }) => contract.deliver).map(({ contract, from }) => ({ contract, to: from }));
|
|
350
|
+
}
|
|
351
|
+
|
|
275
352
|
/* ---------- the key of the channel ---------- */
|
|
276
353
|
|
|
277
354
|
export const channelKeyFile = (stateRoot) => join(stateRoot, 'credentials', 'enlaces.json');
|
|
@@ -371,6 +448,137 @@ export async function askEnlace({ port, contract, asking, q = '', kinds = null,
|
|
|
371
448
|
return { contract: id, from, notes };
|
|
372
449
|
}
|
|
373
450
|
|
|
451
|
+
// D-S15: the whole of what a project lets the Coordinator learn — every note that travels, of the
|
|
452
|
+
// kinds signed, cleaned by that project's privacy guard — asked by Salas when it exports its
|
|
453
|
+
// training data. The same doors as a reading (no Origin, no device, the key), its own route and its
|
|
454
|
+
// own ceiling. Returns `{ contract, from, name, notes }`, or `refused`, `outdated`, `unreachable`.
|
|
455
|
+
export async function askCorpus({ port, contract, asking, key, fetchImpl = globalThis.fetch, timeoutMs = 15000, record = null, roomDir = null, now = new Date() }) {
|
|
456
|
+
const id = typeof contract === 'string' ? contract : contract?.id;
|
|
457
|
+
if (!id || !asking) return { refused: NO_SUCH_LINK, status: 404 };
|
|
458
|
+
const params = new URLSearchParams({ contract: id, asking });
|
|
459
|
+
let response;
|
|
460
|
+
try {
|
|
461
|
+
response = await fetchImpl(`http://127.0.0.1:${port}/api/enlace/corpus?${params}`, {
|
|
462
|
+
method: 'GET',
|
|
463
|
+
headers: { [CHANNEL_HEADER]: typeof key === 'string' ? key : '' },
|
|
464
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
465
|
+
});
|
|
466
|
+
} catch {
|
|
467
|
+
return { unreachable: true };
|
|
468
|
+
}
|
|
469
|
+
const answer = await response.json().catch(() => ({}));
|
|
470
|
+
if (response.status === 404) return answer?.error === NO_SUCH_LINK ? { refused: NO_SUCH_LINK, status: 404 } : { outdated: true };
|
|
471
|
+
if (!response.ok) return { refused: typeof answer?.error === 'string' ? answer.error : `HTTP ${response.status}`, status: response.status };
|
|
472
|
+
const notes = Array.isArray(answer?.notes) ? answer.notes.slice(0, CORPUS_MAX) : [];
|
|
473
|
+
const from = typeof answer?.from === 'string' ? answer.from : null;
|
|
474
|
+
const name = typeof answer?.name === 'string' ? answer.name : from;
|
|
475
|
+
if (record) await record('enlace.learned', { contract: id, from, count: notes.length });
|
|
476
|
+
if (roomDir) await noteCrossing(roomDir, { kind: 'learned', contract: id, other: from, count: notes.length, now });
|
|
477
|
+
return { contract: id, from, name, notes };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// D-S16: the project's canonical state, asked by the room that reads under a link that says
|
|
481
|
+
// `state`. The same doors as a reading. Returns `{ contract, from, name, state }` — `state` is null
|
|
482
|
+
// when that project keeps no Continuity state — or `refused`, `outdated`, `unreachable`.
|
|
483
|
+
export async function askState({ port, contract, asking, key, fetchImpl = globalThis.fetch, timeoutMs = 6000, agent = null }) {
|
|
484
|
+
const id = typeof contract === 'string' ? contract : contract?.id;
|
|
485
|
+
if (!id || !asking) return { refused: NO_SUCH_LINK, status: 404 };
|
|
486
|
+
const params = new URLSearchParams({ contract: id, asking });
|
|
487
|
+
if (agent) params.set('agent', String(agent));
|
|
488
|
+
let response;
|
|
489
|
+
try {
|
|
490
|
+
response = await fetchImpl(`http://127.0.0.1:${port}/api/enlace/state?${params}`, { method: 'GET', headers: { [CHANNEL_HEADER]: typeof key === 'string' ? key : '' }, signal: AbortSignal.timeout(timeoutMs) });
|
|
491
|
+
} catch {
|
|
492
|
+
return { unreachable: true };
|
|
493
|
+
}
|
|
494
|
+
const answer = await response.json().catch(() => ({}));
|
|
495
|
+
if (response.status === 404) return answer?.error === NO_SUCH_LINK ? { refused: NO_SUCH_LINK, status: 404 } : { outdated: true };
|
|
496
|
+
if (!response.ok) return { refused: typeof answer?.error === 'string' ? answer.error : `HTTP ${response.status}`, status: response.status };
|
|
497
|
+
return { contract: id, from: typeof answer?.from === 'string' ? answer.from : null, name: typeof answer?.name === 'string' ? answer.name : null, state: answer?.state && typeof answer.state === 'object' ? answer.state : null };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// D-S17: notes handed to the project this room reads from, under a link that says `deliver`. They
|
|
501
|
+
// land there as pending; nothing enters that project's memory until it accepts them. Returns
|
|
502
|
+
// `{ accepted: n, ids }`, or `refused`, `outdated`, `unreachable`.
|
|
503
|
+
export async function deliverNotes({ port, contract, from, notes, key, agent = null, fetchImpl = globalThis.fetch, timeoutMs = 5000 }) {
|
|
504
|
+
const id = typeof contract === 'string' ? contract : contract?.id;
|
|
505
|
+
if (!id || !from) return { refused: NO_SUCH_LINK, status: 404 };
|
|
506
|
+
let response;
|
|
507
|
+
try {
|
|
508
|
+
response = await fetchImpl(`http://127.0.0.1:${port}/api/enlace/deliver`, {
|
|
509
|
+
method: 'POST',
|
|
510
|
+
headers: { 'content-type': 'application/json', [CHANNEL_HEADER]: typeof key === 'string' ? key : '' },
|
|
511
|
+
body: JSON.stringify({ contract: id, from, agent, notes }),
|
|
512
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
513
|
+
});
|
|
514
|
+
} catch {
|
|
515
|
+
return { unreachable: true };
|
|
516
|
+
}
|
|
517
|
+
const answer = await response.json().catch(() => ({}));
|
|
518
|
+
if (response.status === 404) return answer?.error === NO_SUCH_LINK ? { refused: NO_SUCH_LINK, status: 404 } : { outdated: true };
|
|
519
|
+
if (!response.ok) return { refused: typeof answer?.error === 'string' ? answer.error : `HTTP ${response.status}`, status: response.status };
|
|
520
|
+
return { accepted: Number(answer?.accepted) || 0, ids: Array.isArray(answer?.ids) ? answer.ids : [] };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// A note as a delivery may carry it: a kind that stands, a text of DELIVER_CHARS at most, and the
|
|
524
|
+
// reason in one line. Anything else is refused whole, so a sender never believes a note went.
|
|
525
|
+
export function deliverable(raw) {
|
|
526
|
+
const kind = String(raw?.kind ?? '');
|
|
527
|
+
if (!LINK_KINDS.includes(kind)) fail('A delivered note is a decision, a fact, a preference or a question.');
|
|
528
|
+
const text = String(raw?.text ?? '').replace(/\s+/g, ' ').trim();
|
|
529
|
+
if (!text || text.length > DELIVER_CHARS) fail('A delivered note has between 1 and 600 characters.'); // DELIVER_CHARS, said literally for the catalogue
|
|
530
|
+
const why = String(raw?.why ?? '').replace(/\s+/g, ' ').trim().slice(0, 200) || null;
|
|
531
|
+
return { kind, text, why };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/* ---------- the inbox of delivered notes (D-S17) ---------- */
|
|
535
|
+
|
|
536
|
+
// Beside the room's record, one file: every note another project delivered here, with what became
|
|
537
|
+
// of it. Pending until this project accepts or declines it; accepted, it enters the memory the way
|
|
538
|
+
// a distilled note does (held first, like any new note); declined, it is kept as a fact of the record.
|
|
539
|
+
export const inboxFile = (roomDir) => join(roomDir, 'enlaces-inbox.json');
|
|
540
|
+
const inboxLocks = new Map();
|
|
541
|
+
async function changeInbox(roomDir, change) {
|
|
542
|
+
const file = inboxFile(roomDir);
|
|
543
|
+
const before = inboxLocks.get(file) ?? Promise.resolve();
|
|
544
|
+
let result;
|
|
545
|
+
const run = before.then(async () => {
|
|
546
|
+
let data = { notes: [] };
|
|
547
|
+
try { const parsed = JSON.parse(await readFile(file, 'utf8')); if (Array.isArray(parsed?.notes)) data = parsed; } catch { /* a missing or torn inbox is empty */ }
|
|
548
|
+
result = await change(data);
|
|
549
|
+
await mkdir(roomDir, { recursive: true });
|
|
550
|
+
const draft = `${file}.${process.pid}.tmp`;
|
|
551
|
+
await writeFile(draft, `${JSON.stringify(data, null, 2)}\n`, { mode: OWNER_ONLY });
|
|
552
|
+
await rename(draft, file);
|
|
553
|
+
});
|
|
554
|
+
inboxLocks.set(file, run.catch(() => null));
|
|
555
|
+
await run;
|
|
556
|
+
return result;
|
|
557
|
+
}
|
|
558
|
+
export async function readInbox(roomDir) {
|
|
559
|
+
try { const parsed = JSON.parse(await readFile(inboxFile(roomDir), 'utf8')); return Array.isArray(parsed?.notes) ? parsed.notes : []; } catch { return []; }
|
|
560
|
+
}
|
|
561
|
+
export async function receiveDelivery(roomDir, { contract, from, name = null, agent = null, notes, now = new Date() }) {
|
|
562
|
+
return changeInbox(roomDir, (data) => {
|
|
563
|
+
const pending = data.notes.filter((note) => note.status === 'pending').length;
|
|
564
|
+
if (pending + notes.length > INBOX_MAX) fail('This project has too many delivered notes waiting. Accept or decline some first.', 'FULL');
|
|
565
|
+
const at = new Date(now).toISOString();
|
|
566
|
+
const added = notes.map((note) => ({ id: `d-${randomBytes(4).toString('hex')}`, contract, from, name: name ?? from, agent, kind: note.kind, text: note.text, why: note.why ?? null, at, status: 'pending', decidedBy: null, decidedAt: null }));
|
|
567
|
+
data.notes.push(...added);
|
|
568
|
+
// The record keeps what was decided; only the last 500 lines stay in the file.
|
|
569
|
+
if (data.notes.length > 500) data.notes = [...data.notes.filter((note) => note.status === 'pending'), ...data.notes.filter((note) => note.status !== 'pending').slice(-(500 - INBOX_MAX))];
|
|
570
|
+
return added;
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
export async function decideDelivery(roomDir, id, { accept, by = 'you', now = new Date() }) {
|
|
574
|
+
return changeInbox(roomDir, (data) => {
|
|
575
|
+
const note = data.notes.find((one) => one.id === id);
|
|
576
|
+
if (!note || note.status !== 'pending') return null;
|
|
577
|
+
Object.assign(note, { status: accept ? 'accepted' : 'declined', decidedBy: by, decidedAt: new Date(now).toISOString() });
|
|
578
|
+
return { ...note };
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
374
582
|
// Tells another room, by the channel, that a link of its was signed or revoked. The same call as
|
|
375
583
|
// `askEnlace` makes, the other way: no `Origin`, no device, the key in its header, a short wait.
|
|
376
584
|
// What comes back is `{ ok }`, `{ outdated }` (that room runs a MADRE without the route),
|
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';
|
|
@@ -31,9 +33,16 @@ export async function listExtensions({ projectRoot, agents = [], config = {}, im
|
|
|
31
33
|
// `onChild` hands the caller that way in, and `flushIdleMs` emits a line that never ends in a
|
|
32
34
|
// newline — a prompt like `Paste code here > ` — once the output has been still for that long.
|
|
33
35
|
// Without it the prompt sits in the buffer and the room shows a sign-in that says nothing.
|
|
34
|
-
|
|
36
|
+
// `ci: false` leaves CI out: Gemini reads it as "no browser here" and will not open one.
|
|
37
|
+
// A spinner redraws its line in place (back to the first column, then the next frame) and never
|
|
38
|
+
// ends it: only the last frame is the line, or a sign-in that waits fifteen minutes piles up
|
|
39
|
+
// thousands of them in one.
|
|
40
|
+
const REDRAW = '\u001b[999D';
|
|
41
|
+
const redrawn = (text) => { const at = text.lastIndexOf(REDRAW); return at < 0 ? text : text.slice(at + REDRAW.length).replace(/^\u001b\[J/, ''); };
|
|
42
|
+
|
|
43
|
+
export function runInstaller({ command, args, projectRoot, onLine, timeoutMs = 600000, heartbeatMs = 8000, env = process.env, stdin = 'ignore', onChild = null, flushIdleMs = 0, ci = true, quietNote = 'installer is quiet (npm install or git status can take a while)' }) {
|
|
35
44
|
return new Promise((resolve) => {
|
|
36
|
-
const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', CI: '1' }, stdio: [stdin, 'pipe', 'pipe'], shell: process.platform === 'win32', windowsHide: true });
|
|
45
|
+
const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', ...(ci ? { CI: '1' } : {}) }, stdio: [stdin, 'pipe', 'pipe'], shell: process.platform === 'win32', windowsHide: true });
|
|
37
46
|
const started = Date.now();
|
|
38
47
|
let lastOutput = started;
|
|
39
48
|
let buffer = '';
|
|
@@ -53,10 +62,11 @@ export function runInstaller({ command, args, projectRoot, onLine, timeoutMs = 6
|
|
|
53
62
|
buffer += chunk;
|
|
54
63
|
let index;
|
|
55
64
|
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
56
|
-
const line = buffer.slice(0, index).replace(/\r$/, '');
|
|
65
|
+
const line = redrawn(buffer.slice(0, index)).replace(/\r$/, '');
|
|
57
66
|
buffer = buffer.slice(index + 1);
|
|
58
67
|
if (line.trim()) emit(line, stream);
|
|
59
68
|
}
|
|
69
|
+
buffer = redrawn(buffer);
|
|
60
70
|
};
|
|
61
71
|
child.stdout.on('data', (chunk) => feed(String(chunk), 'stdout'));
|
|
62
72
|
child.stderr.on('data', (chunk) => feed(String(chunk), 'stderr'));
|
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
|