@jossuealcala/madre 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +425 -0
  2. package/CONTRIBUTING.md +6 -1
  3. package/README.md +67 -183
  4. package/SECURITY.md +2 -1
  5. package/bin/madre.mjs +56 -13
  6. package/docs/INTERNALS.md +16 -0
  7. package/docs/REFERENCE.md +249 -0
  8. package/docs/SDK.md +121 -0
  9. package/docs/room.png +0 -0
  10. package/docs/sdk/hello-module.mjs +51 -0
  11. package/package.json +9 -1
  12. package/public/app.js +3880 -849
  13. package/public/es.js +2050 -0
  14. package/public/i18n.js +66 -0
  15. package/public/index.html +95 -14
  16. package/public/inquiry.js +220 -0
  17. package/public/resay.js +77 -0
  18. package/public/styles.css +626 -68
  19. package/public/troubleshooting.js +173 -51
  20. package/src/adapters/claude.mjs +13 -6
  21. package/src/adapters/codex.mjs +17 -13
  22. package/src/adapters/gemini.mjs +27 -16
  23. package/src/adapters/opencode.mjs +16 -12
  24. package/src/adapters/process.mjs +17 -5
  25. package/src/asking.mjs +128 -0
  26. package/src/auth-probe.mjs +58 -1
  27. package/src/capabilities.mjs +4 -3
  28. package/src/chats.mjs +193 -0
  29. package/src/checkpoint.mjs +1 -1
  30. package/src/cold.mjs +56 -0
  31. package/src/commands.mjs +31 -3
  32. package/src/conversation-context.mjs +35 -3
  33. package/src/credentials.mjs +145 -0
  34. package/src/dataset.mjs +56 -4
  35. package/src/distiller.mjs +12 -5
  36. package/src/event-store.mjs +14 -8
  37. package/src/exam.mjs +240 -0
  38. package/src/extensions.mjs +3 -2
  39. package/src/eyecat-watch.mjs +100 -0
  40. package/src/eyecat.mjs +169 -0
  41. package/src/i18n.mjs +47 -0
  42. package/src/image-studio.mjs +2 -0
  43. package/src/launch.mjs +61 -0
  44. package/src/lease.mjs +5 -3
  45. package/src/maturity.mjs +94 -0
  46. package/src/mcp/image-server.mjs +12 -1
  47. package/src/mcp/memory-server.mjs +1 -1
  48. package/src/memory.mjs +325 -17
  49. package/src/modules/ahp.mjs +9 -7
  50. package/src/modules/ash.mjs +36 -0
  51. package/src/modules/git-pulse.mjs +6 -4
  52. package/src/modules/helpers.mjs +31 -0
  53. package/src/modules/image-studio.mjs +9 -4
  54. package/src/modules/index.mjs +143 -5
  55. package/src/modules/ollama.mjs +66 -10
  56. package/src/modules/playwright.mjs +90 -0
  57. package/src/modules/ripley.mjs +5 -3
  58. package/src/modules/sdk.mjs +104 -2
  59. package/src/modules/updates.mjs +81 -0
  60. package/src/ollama.mjs +5 -2
  61. package/src/outbound.mjs +292 -0
  62. package/src/privacy.mjs +54 -7
  63. package/src/room/context.mjs +4 -4
  64. package/src/room/control.mjs +4 -4
  65. package/src/room/economy.mjs +161 -0
  66. package/src/room/prompt.mjs +118 -43
  67. package/src/room.mjs +465 -58
  68. package/src/runtime-detection.mjs +27 -8
  69. package/src/server.mjs +724 -68
  70. package/src/setup.mjs +1 -1
  71. package/src/updates.mjs +17 -2
  72. package/src/usage-sentinel.mjs +13 -8
  73. package/src/verdict.mjs +74 -0
  74. package/src/ashcode.mjs +0 -64
  75. package/src/modules/ashcode.mjs +0 -28
package/src/eyecat.mjs ADDED
@@ -0,0 +1,169 @@
1
+ // EYECAT: the watcher that asks whether the room still believes what it wrote down.
2
+ //
3
+ // Pure functions here. Nothing in this file talks to a model, reads a file or touches the room;
4
+ // the server schedules it and hands it what it needs, the way it does with the distiller.
5
+ //
6
+ // What it watches for is not error but drift. Two notes that are about the same thing and cannot
7
+ // both be true; a note whose own citations do not say what it says. Either one quietly moves the
8
+ // project's context away from what was actually settled, and by the time a person notices, every
9
+ // turn since has been built on it.
10
+ //
11
+ // Independence is the whole design. A model that ratifies its own claim is worth nothing here,
12
+ // so a claim is never judged by whoever wrote it, nor by whoever wrote what it clashes with.
13
+ // The judge is given the two statements and nothing else: no transcript, no project history and
14
+ // no names, so it cannot be told who to believe and cannot be argued with. It runs after the
15
+ // turn, out of band, so no agent can address it or see its verdict.
16
+
17
+ const NEGATORS = /\b(no|not|never|non|sin|nunca|ningun[ao]?|jamas|isn't|aren't|doesn't|don't|won't|cannot|can't)\b/gi;
18
+ const ORDERINGS = [['before', 'after'], ['antes', 'despues'], ['first', 'last'], ['primero', 'ultimo'], ['above', 'below'], ['enabled', 'disabled'], ['on', 'off'], ['encendido', 'apagado']];
19
+
20
+ const fold = (text) => String(text ?? '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
21
+ const words = (text) => fold(text).match(/[a-z0-9][a-z0-9_./:-]*/g) ?? [];
22
+ // Terms worth comparing: the ones that carry meaning rather than grammar.
23
+ const TRIVIAL = new Set(['the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'is', 'are', 'was', 'were', 'be', 'it', 'its', 'this', 'that', 'with', 'by', 'at', 'as', 'from', 'el', 'la', 'los', 'las', 'de', 'del', 'y', 'o', 'en', 'un', 'una', 'que', 'se', 'su', 'es', 'son', 'por', 'para', 'con', 'al', 'lo']);
24
+ const meaningful = (text) => new Set(words(text).filter((word) => word.length > 2 && !TRIVIAL.has(word)));
25
+
26
+ const countNegations = (text) => (fold(text).match(NEGATORS) ?? []).length;
27
+ const numbersIn = (text) => (fold(text).match(/\b\d+(?:\.\d+)?\b/g) ?? []);
28
+ // Things a project is specific about: paths, flags, dotted names, ports.
29
+ const identifiersIn = (text) => (fold(text).match(/\b[a-z0-9_-]+(?:[./][a-z0-9_.-]+)+\b|--[a-z][a-z0-9-]*/g) ?? []);
30
+
31
+ // Why two statements about the same thing might not both be true. Each signal is a cheap,
32
+ // deterministic reason to look closer; none of them is a verdict.
33
+ export function contradictionSignals(a, b) {
34
+ const signals = [];
35
+ if (countNegations(a) !== countNegations(b)) signals.push('negation');
36
+ const foldedA = fold(a);
37
+ const foldedB = fold(b);
38
+ for (const [one, other] of ORDERINGS) {
39
+ const hasA = new RegExp(`\\b${one}\\b`).test(foldedA) && !new RegExp(`\\b${other}\\b`).test(foldedA);
40
+ const hasB = new RegExp(`\\b${other}\\b`).test(foldedB) && !new RegExp(`\\b${one}\\b`).test(foldedB);
41
+ const flipped = new RegExp(`\\b${other}\\b`).test(foldedA) && new RegExp(`\\b${one}\\b`).test(foldedB);
42
+ if ((hasA && hasB) || flipped) { signals.push('order'); break; }
43
+ }
44
+ const [numbersA, numbersB] = [numbersIn(a), numbersIn(b)];
45
+ if (numbersA.length && numbersB.length && numbersA.join() !== numbersB.join()) signals.push('number');
46
+ const [idsA, idsB] = [identifiersIn(a), identifiersIn(b)];
47
+ if (idsA.length && idsB.length && !idsA.some((id) => idsB.includes(id))) signals.push('identifier');
48
+ return signals;
49
+ }
50
+
51
+ // Pairs worth a second look. The links are already computed for the constellation: a link says
52
+ // two notes are about the same thing. EYECAT asks the next question, whether they agree, and
53
+ // only where something cheap says they might not.
54
+ export function suspectPairs(links, notes, { floor = 0.72, settled = new Set() } = {}) {
55
+ const byId = new Map(notes.map((note) => [note.id, note]));
56
+ const found = [];
57
+ for (const link of links ?? []) {
58
+ if ((link.weight ?? 0) < floor) continue;
59
+ const a = byId.get(link.a);
60
+ const b = byId.get(link.b);
61
+ if (!a || !b) continue;
62
+ // A note already taken out of circulation is not news, and neither is a pair a person settled.
63
+ if (a.kind === 'aberration' || b.kind === 'aberration' || a.refutedBy || b.refutedBy) continue;
64
+ const key = pairKey(a.id, b.id);
65
+ if (settled.has(key)) continue;
66
+ const signals = contradictionSignals(a.text, b.text);
67
+ if (!signals.length) continue;
68
+ // The newer of the two is the one on trial: drift moves forward, so the later claim is the
69
+ // one that changed what the project had settled.
70
+ const [older, newer] = a.id <= b.id ? [a, b] : [b, a];
71
+ found.push({ key, kind: 'contradiction', claim: newer, against: older, weight: link.weight, signals });
72
+ }
73
+ return found.sort((x, y) => y.signals.length - x.signals.length || y.weight - x.weight);
74
+ }
75
+
76
+ // A note is supposed to come from the exchanges it cites. When almost none of what makes it
77
+ // distinctive appears in any of them, either it was invented or it was distilled from somewhere
78
+ // it does not name, and both are worth asking about.
79
+ export function unsupportedNotes(notes, entries, { share = 0.2, settled = new Set() } = {}) {
80
+ const text = new Map((entries ?? []).map((entry) => [entry.sequence, entry.text ?? '']));
81
+ const found = [];
82
+ for (const note of notes ?? []) {
83
+ if (note.kind === 'aberration' || note.refutedBy) continue;
84
+ if (note.origin && note.origin !== 'distilled') continue; // a person's own note cites nothing
85
+ const cites = (note.sources ?? []).filter((sequence) => text.has(sequence));
86
+ if (!cites.length) continue;
87
+ const key = `note:${note.id}`;
88
+ if (settled.has(key)) continue;
89
+ const terms = meaningful(note.text);
90
+ if (terms.size < 3) continue;
91
+ const backing = meaningful(cites.map((sequence) => text.get(sequence)).join(' '));
92
+ let held = 0;
93
+ for (const term of terms) if (backing.has(term)) held += 1;
94
+ const support = held / terms.size;
95
+ if (support >= share) continue;
96
+ found.push({ key, kind: 'unsupported', claim: note, against: null, support, cites });
97
+ }
98
+ return found.sort((a, b) => a.support - b.support);
99
+ }
100
+
101
+ export const pairKey = (a, b) => `pair:${Math.min(a, b)}:${Math.max(a, b)}`;
102
+
103
+ // Who may judge. Never whoever wrote the claim, never whoever wrote what it clashes with: a
104
+ // model that ratifies its own work is worth nothing here. The local model comes first because it
105
+ // is free and because it owes nothing to any of the accounts in the room.
106
+ export const EYECAT_ORDER = ['ollama', 'gemini', 'opencode', 'codex', 'claude'];
107
+ export function judgeFor(candidate, { agents = [], invokers = {}, busy = new Set(), benched = new Set() } = {}) {
108
+ const conflicted = new Set([candidate?.claim?.agent, candidate?.against?.agent].filter(Boolean));
109
+ const usable = agents.filter((agent) => agent.detected && agent.ready && !busy.has(agent.id) && !benched.has(agent.id) && invokers[agent.adapter]);
110
+ const impartial = usable.filter((agent) => !conflicted.has(agent.id));
111
+ for (const id of EYECAT_ORDER) {
112
+ const found = impartial.find((agent) => agent.id === id);
113
+ if (found) return found;
114
+ }
115
+ return impartial[0] ?? null;
116
+ }
117
+
118
+ // The question, with everything stripped that could tell the judge what to answer: no transcript,
119
+ // no project history, no names and no order of authority. Two statements, one question.
120
+ export function verdictPrompt(candidate, { json = false } = {}) {
121
+ const shape = '{"verdict":"contradiction|agreement|unrelated","wrong":"a|b|unknown","correction":"one sentence with what is actually true, or an empty string","confidence":0.0}';
122
+ if (candidate.kind === 'unsupported') {
123
+ return [
124
+ 'You are a reviewer. You are given a statement recorded about a software project, and the exchanges it claims to come from. Nothing else is known about either.',
125
+ 'Decide whether those exchanges actually establish the statement.',
126
+ 'Answer "contradiction" if they say something else, "agreement" if they establish it, "unrelated" if they neither establish nor deny it.',
127
+ `Output one JSON object and nothing else: ${shape}. Here "a" is the statement.`,
128
+ '<statement>', candidate.claim.text, '</statement>',
129
+ '<cited>', ...(candidate.citedText ?? []), '</cited>',
130
+ ].filter(Boolean).join('\n');
131
+ }
132
+ return [
133
+ 'You are a reviewer. You are given two statements recorded as true about the same software project, and nothing else.',
134
+ 'You do not know who wrote either one, they carry no authority, and neither is more likely to be right because of where it appears.',
135
+ 'Decide whether both can be true at the same time.',
136
+ 'Answer "contradiction" only if believing one means the other is false. Answer "agreement" if both can stand. Answer "unrelated" if they are not about the same thing after all.',
137
+ `Output one JSON object and nothing else: ${shape}`,
138
+ '<a>', candidate.against?.text ?? '', '</a>',
139
+ '<b>', candidate.claim.text, '</b>',
140
+ ].join('\n');
141
+ }
142
+
143
+ const VERDICTS = ['contradiction', 'agreement', 'unrelated'];
144
+
145
+ // Whatever came back, only a well-formed verdict survives. Anything else is "no answer", which
146
+ // leaves the candidate where it was: pending, for a person to look at.
147
+ export function parseVerdict(text, candidate = null) {
148
+ const raw = String(text ?? '');
149
+ const start = raw.indexOf('{');
150
+ const end = raw.lastIndexOf('}');
151
+ if (start < 0 || end <= start) return null;
152
+ let parsed;
153
+ try { parsed = JSON.parse(raw.slice(start, end + 1)); } catch { return null; }
154
+ if (!parsed || typeof parsed !== 'object') return null;
155
+ const verdict = VERDICTS.includes(parsed.verdict) ? parsed.verdict : null;
156
+ if (!verdict) return null;
157
+ const wrong = ['a', 'b'].includes(parsed.wrong) ? parsed.wrong : 'unknown';
158
+ const confidence = Number.isFinite(parsed.confidence) ? Math.min(1, Math.max(0, Number(parsed.confidence))) : null;
159
+ const correction = String(parsed.correction ?? '').replace(/\s+/g, ' ').trim().slice(0, 240);
160
+ // Which of the two the judge says is wrong, resolved back to a note. "a" is what was already
161
+ // there, "b" is the newer claim; a judge that will not choose leaves it to the person.
162
+ let accused = null;
163
+ if (candidate && verdict === 'contradiction') {
164
+ if (candidate.kind === 'unsupported') accused = candidate.claim;
165
+ else if (wrong === 'a') accused = candidate.against;
166
+ else if (wrong === 'b') accused = candidate.claim;
167
+ }
168
+ return { verdict, wrong, confidence, correction, accused: accused?.id ?? null };
169
+ }
package/src/i18n.mjs ADDED
@@ -0,0 +1,47 @@
1
+ // The room's language, on this side of the wire.
2
+ //
3
+ // The catalogue is the page's: public/es.js, one file for the whole product, because a sentence
4
+ // that appears in a card and in a toast should not be translated twice and drift. What differs
5
+ // is only where the choice comes from — here it is ~/.pulse/config.json, read once when the room
6
+ // opens and again whenever it is changed — and what must never go through it:
7
+ //
8
+ // · The prompt the agents read. It is English on purpose; see src/room/prompt.mjs.
9
+ // · Anything written INTO the ledger. Those lines are the record of what happened, in the
10
+ // language they happened in; translating the past is not translating, it is rewriting.
11
+ //
12
+ // So this is for text the server COMPUTES for a screen: what a module is, what it would create,
13
+ // what a reading means. Same rule as the page: the key is the English sentence, and a sentence
14
+ // nobody has translated comes back in English, whole.
15
+
16
+ import { ES } from '../public/es.js';
17
+
18
+ const CATALOGUES = { es: ES, en: {} };
19
+ let current = 'es';
20
+
21
+ export const language = () => current;
22
+ export function setLanguage(id) {
23
+ current = id === 'en' ? 'en' : 'es';
24
+ return current;
25
+ }
26
+
27
+ export function t(text, vars = null) {
28
+ if (typeof text !== 'string' || !text) return text;
29
+ const said = CATALOGUES[current]?.[text] ?? text;
30
+ if (!vars) return said;
31
+ return said.replace(/\{(\w+)\}/g, (whole, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : whole));
32
+ }
33
+
34
+ // A card, a list, a note: whatever a screen is handed, with only the fields that are prose put
35
+ // through the catalogue. Everything else — ids, versions, paths, commands — is left alone,
36
+ // because those are not sentences and a translated id is a bug.
37
+ const PROSE = new Set(['summary', 'detail', 'note', 'says', 'brief', 'what', 'when', 'where', 'why', 'hint', 'title', 'label', 'remedy', 'diagnosis', 'creates', 'requires']);
38
+
39
+ export function translate(value, key = null) {
40
+ if (Array.isArray(value)) return value.map((one) => translate(one, key));
41
+ if (value && typeof value === 'object') {
42
+ const out = {};
43
+ for (const [name, inner] of Object.entries(value)) out[name] = translate(inner, name);
44
+ return out;
45
+ }
46
+ return typeof value === 'string' && key && PROSE.has(key) ? t(value) : value;
47
+ }
@@ -18,6 +18,8 @@ export function imageStudioFor({ enabled, model, outDir, env = process.env }) {
18
18
  PULSE_IMAGE_MODEL: model ?? 'gemini-2.5-flash-image',
19
19
  ...(env.GEMINI_API_KEY ? { GEMINI_API_KEY: env.GEMINI_API_KEY } : {}),
20
20
  ...(env.PULSE_IMAGE_FAKE ? { PULSE_IMAGE_FAKE: env.PULSE_IMAGE_FAKE } : {}),
21
+ // So the one request that carries a prompt to Google lands in the same log as the rest.
22
+ ...(env.PULSE_OUTBOUND_LOG ? { PULSE_OUTBOUND_LOG: env.PULSE_OUTBOUND_LOG } : {}),
21
23
  },
22
24
  };
23
25
  }
package/src/launch.mjs ADDED
@@ -0,0 +1,61 @@
1
+ // What is shown of a command, and what is never shown.
2
+ //
3
+ // THE LAUNCH floor in the core prints the exact command MADRE would run, because a person who is
4
+ // handing their codebase to an agent deserves to read the line rather than trust a description of
5
+ // it. But a command line is not only a command line: adapters pass MCP server settings inline, and
6
+ // a server's environment is where keys live. A module somebody installs tomorrow can add one.
7
+ //
8
+ // So the arguments are shown whole except for environment VALUES, which are replaced everywhere
9
+ // they appear, whatever shape the adapter wrote them in. The names stay — knowing that
10
+ // GEMINI_API_KEY is set is the useful half, and the other half belongs in the keychain, not on a
11
+ // screen somebody may be sharing.
12
+ //
13
+ // This runs over what the adapters' own arg builders produced, so the core can never print a
14
+ // value the adapters did not print themselves — and cannot drift from them either.
15
+
16
+ export const HIDDEN = '<set for this turn, not shown>';
17
+
18
+ // Anything named like a credential has its value hidden even outside an env block.
19
+ const SECRETISH = /(TOKEN|KEY|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|BEARER|SESSION)/i;
20
+
21
+ // env objects anywhere in a parsed JSON argument, however deep an adapter nested them.
22
+ function hideEnv(value) {
23
+ if (Array.isArray(value)) return value.map(hideEnv);
24
+ if (!value || typeof value !== 'object') return value;
25
+ const out = {};
26
+ for (const [key, inner] of Object.entries(value)) {
27
+ if (key === 'env' && inner && typeof inner === 'object' && !Array.isArray(inner)) {
28
+ out[key] = Object.fromEntries(Object.keys(inner).map((name) => [name, HIDDEN]));
29
+ } else out[key] = hideEnv(inner);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ // `mcp_servers.<name>.env={ NAME = "value", … }`, which is how Codex takes them.
35
+ const TOML_ENV = /^((?:[\w-]+\.)*env)=\{(.*)\}\s*$/s;
36
+ const TOML_PAIR = /([\w.-]+)\s*=\s*("(?:[^"\\]|\\.)*"|'[^']*')/g;
37
+
38
+ function hideTomlEnv(arg) {
39
+ const found = TOML_ENV.exec(arg);
40
+ if (!found) return null;
41
+ const body = found[2].replace(TOML_PAIR, (_, name) => `${name} = ${HIDDEN}`);
42
+ return `${found[1]}={${body}}`;
43
+ }
44
+
45
+ // `NAME=value` passed as one argument, when the name reads like a credential.
46
+ const PLAIN = /^([A-Z][A-Z0-9_]*)=(.+)$/s;
47
+
48
+ export function redactArgs(args = []) {
49
+ return args.map((arg) => {
50
+ if (typeof arg !== 'string') return arg;
51
+ const toml = hideTomlEnv(arg);
52
+ if (toml) return toml;
53
+ const plain = PLAIN.exec(arg);
54
+ if (plain && SECRETISH.test(plain[1])) return `${plain[1]}=${HIDDEN}`;
55
+ const trimmed = arg.trim();
56
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
57
+ try { return JSON.stringify(hideEnv(JSON.parse(trimmed))); } catch { /* not JSON after all: it is shown as the adapter wrote it */ }
58
+ }
59
+ return arg;
60
+ });
61
+ }
package/src/lease.mjs CHANGED
@@ -63,12 +63,14 @@ export function diffSnapshots(before, after, { relativeDir }) {
63
63
  return artifacts.sort((a, b) => a.path.localeCompare(b.path));
64
64
  }
65
65
 
66
- export function leaseInstructions({ outDir, agentId, scopes = { write: true, imageGen: agentId === 'codex' }, capable = null, imageStudio = null, control = false, create = false, scratchDir = null }) {
66
+ export function leaseInstructions({ outDir, agentId, scopes = { write: true, imageGen: agentId === 'codex' }, capable = null, imageStudio = null, control = false, create = false, airlock = false, scratchDir = null }) {
67
67
  const canImage = Boolean(scopes.imageGen);
68
68
  const couldImage = capable ? Boolean(capable.imageGen?.capable) : canImage;
69
69
  const scratch = scratchDir ? `${outDir}/${scratchDir}` : null;
70
70
  return [
71
- control
71
+ airlock
72
+ ? `AIRLOCK (#4): the human opened the airlock for you on this project at ${outDir}. Everything CONTROL allows, and you may run commands inside it: tests, builds, git commit and push, deploys with the CLIs and sessions already on this machine. Files are checkpointed and UNDO restores them; what leaves the machine (a push, a deploy, an API call) does not come back. Before anything leaves, state in one line exactly what goes out and where, then do it. Never print, copy or move secrets. Use git commands, never .git internals.`
73
+ : control
72
74
  ? `CONTROL (#3): the human put you in command of this project at ${outDir}. You may read, create and modify its files without asking, one change at a time, minimal and reversible. MADRE took a checkpoint before this turn; everything you change is listed to the human afterwards and can be undone in one click.`
73
75
  : create
74
76
  ? `CREATE (#2): the human allows you to create new files and folders anywhere in this project, at ${outDir}, where they belong by the project's own conventions (a page next to the other pages, a component with the components, a document with the documents). Create folders when the structure calls for it.${scratch ? ` If something has no natural place, put it in the scratch folder ${scratch}.` : ''}`
@@ -78,7 +80,7 @@ export function leaseInstructions({ outDir, agentId, scopes = { write: true, ima
78
80
  : create
79
81
  ? 'Do not modify, overwrite, rename or delete files that already exist: MADRE restores them after your turn and tells the human. If a change to an existing file is truly needed, say so and stop; the human can grant #3 CONTROL. Never touch .git, .pulse, .madre, .env files or credentials.'
80
82
  : 'Write every file you produce there (images, code, documents); paths elsewhere are denied.',
81
- control ? 'End with a short list of the files you changed and why.' : create ? 'End with a short list of the files you created, with their paths, and why there.' : 'Reading the project stays allowed. Do not modify project files.',
83
+ airlock ? 'End with the commands you ran, what left the machine, and the files you changed.' : control ? 'End with a short list of the files you changed and why.' : create ? 'End with a short list of the files you created, with their paths, and why there.' : 'Reading the project stays allowed. Do not modify project files.',
82
84
  imageStudio ? `You can generate images with the MCP tool ${imageStudio.tool} (server ${imageStudio.name}): pass a detailed prompt and a file_name; it saves the PNG${scratch ? ` into ${scratch}` : ' into the lease directory'} and returns the path.`
83
85
  : canImage ? `You can generate images; save them${create ? ' where images live in this project, or in the scratch folder,' : ' into the lease directory'} with a descriptive file name.`
84
86
  : couldImage ? 'Image generation is switched off for this request; if asked for an image, say so and do not attempt it.'
@@ -0,0 +1,94 @@
1
+ // How grown a room is, and how far from being worth training on.
2
+ //
3
+ // Pure functions. The room hands in what it has; nothing here reads a file or calls a model.
4
+ //
5
+ // This replaces a bar that filled toward three hundred. That number was a rule of thumb from
6
+ // somebody else's paper, not a measurement of this room, and a corpus of three hundred pairs all
7
+ // about the same afternoon teaches less than eighty that are not. What is measured here is what
8
+ // can actually be counted about this archive, and each reading says plainly what would raise it.
9
+
10
+ // How much of the whole each reading is worth. Volume counts, but it is one voice of six: a room
11
+ // can be large and still be narrow, unjudged and lopsided.
12
+ import { t } from './i18n.mjs';
13
+
14
+ export const WEIGHTS = { volume: 0.2, coverage: 0.2, weave: 0.15, judgement: 0.2, balance: 0.15, upkeep: 0.1 };
15
+ export const VOLUME_TARGET = 300; // the usual floor for a small adapter, and nothing more than that
16
+
17
+ const share = (part, whole) => (whole > 0 ? Math.min(1, Math.max(0, part / whole)) : 0);
18
+ const round = (value) => Number(value.toFixed(3));
19
+
20
+ // Six readings, each 0 to 1, each with the one thing that would raise it.
21
+ export function maturity({ readiness = null, notes = [], links = [], stats = null } = {}) {
22
+ const standing = notes.filter((note) => note.kind !== 'aberration' && !note.refutedBy);
23
+ const pairs = Number(readiness?.pairs ?? 0);
24
+ const rated = Number(readiness?.good ?? 0) + Number(readiness?.bad ?? 0);
25
+ const turns = Number(readiness?.turns ?? 0) + Number(readiness?.delegated ?? 0);
26
+ const noteShare = pairs > 0 ? Number(readiness?.notes ?? 0) / pairs : 0;
27
+
28
+ const recalled = standing.filter((note) => Number(note.recalled ?? 0) > 0).length;
29
+ const linked = new Set(links.flatMap((link) => [link.a, link.b]));
30
+ const connected = standing.filter((note) => linked.has(note.id)).length;
31
+ const pending = Number(stats?.pending ?? 0);
32
+ const entries = Number(stats?.entries ?? 0);
33
+
34
+ const signals = [
35
+ {
36
+ id: 'volume', label: t('HOW MUCH THERE IS'),
37
+ value: round(share(pairs, VOLUME_TARGET)),
38
+ detail: t('{pairs} of about {target} exchanges worth training on', { pairs, target: VOLUME_TARGET }),
39
+ next: t('Use the room. Nothing else fills this.'),
40
+ },
41
+ {
42
+ id: 'coverage', label: t('HOW MUCH OF IT GETS USED'),
43
+ value: round(share(recalled, standing.length)),
44
+ detail: t('{recalled} of {total} memories have been reached for at least once', { recalled, total: standing.length }),
45
+ next: t('Memories nobody has needed may be noise, or may simply not have come up yet. Ask the room about older decisions and see which ones answer.'),
46
+ },
47
+ {
48
+ id: 'weave', label: t('HOW WOVEN IT IS'),
49
+ value: round(share(connected, standing.length)),
50
+ detail: t('{connected} of {total} memories share a subject with another', { connected, total: standing.length }),
51
+ next: t('An archive of unrelated notes is a list. Depth comes from returning to the same subjects.'),
52
+ },
53
+ {
54
+ id: 'judgement', label: t('HOW MUCH OF IT YOU JUDGED'),
55
+ // A tenth rated is enough to steer a small adapter; asking for all of it would never be met.
56
+ value: round(share(rated, Math.max(1, pairs * 0.1))),
57
+ detail: t('{rated} of {pairs} replies rated', { rated, pairs }),
58
+ next: t('Rate replies with the thumbs on a bubble. A corpus nobody judged teaches what the agents said, not what you approved.'),
59
+ },
60
+ {
61
+ id: 'balance', label: t('HOW MUCH OF IT IS REAL WORK'),
62
+ // Half recall pairs is healthy; a corpus that is mostly recall teaches recitation. A room
63
+ // with no corpus at all is not balanced, it is empty, and saying otherwise would show
64
+ // progress where there is none.
65
+ value: pairs > 0 ? round(1 - Math.min(1, Math.max(0, (noteShare - 0.5) / 0.5))) : 0,
66
+ detail: t('{share}% of the corpus is recall questions, {turns} exchanges are real work', { share: Math.round(noteShare * 100), turns }),
67
+ next: t('Recall pairs are made from notes and cost nothing, so they pile up. Work in the room to balance them.'),
68
+ },
69
+ {
70
+ id: 'upkeep', label: t('HOW CURRENT IT IS'),
71
+ value: round(entries > 0 ? 1 - share(pending, Math.max(1, entries * 0.15)) : 0),
72
+ detail: t('{pending} exchanges nobody has distilled yet, of {entries}', { pending, entries }),
73
+ next: t('The archivist catches up on its own. A backlog that never clears means it cannot run: check who is allowed to distil.'),
74
+ },
75
+ ];
76
+
77
+ const score = round(signals.reduce((sum, signal) => sum + signal.value * (WEIGHTS[signal.id] ?? 0), 0));
78
+ return { score, stage: stageOf(score), signals, weakest: [...signals].sort((a, b) => a.value - b.value)[0]?.id ?? null };
79
+ }
80
+
81
+ // What to call it. A name is not a measurement, but a number alone tells nobody whether to act.
82
+ export const STAGES = [
83
+ { at: 0.85, id: 'mature', label: 'MATURE', says: 'Worth training on. Export and run the recipe.' },
84
+ { at: 0.6, id: 'working', label: 'WORKING', says: 'Usable, and it will be better for waiting.' },
85
+ { at: 0.35, id: 'forming', label: 'FORMING', says: 'It has a shape. Too thin to train on.' },
86
+ { at: 0.12, id: 'sparse', label: 'SPARSE', says: 'A few things remembered, little connecting them.' },
87
+ { at: 0, id: 'empty', label: 'EMPTY', says: 'Nothing has been distilled yet.' },
88
+ ];
89
+ // The table holds the English, and the stage is said in the room's language when it is asked
90
+ // for: this list is built when the file is imported, and the room learns its language after.
91
+ export function stageOf(score) {
92
+ const stage = STAGES.find((one) => score >= one.at) ?? STAGES.at(-1);
93
+ return { ...stage, label: t(stage.label), says: t(stage.says) };
94
+ }
@@ -17,6 +17,7 @@ import { execFile } from 'node:child_process';
17
17
  import { mkdir, writeFile, realpath } from 'node:fs/promises';
18
18
  import { basename, extname, join, resolve, sep } from 'node:path';
19
19
  import { promisify } from 'node:util';
20
+ import { OutboundLog } from '../outbound.mjs';
20
21
 
21
22
  const execFileAsync = promisify(execFile);
22
23
  const SERVER_NAME = 'pulse-image';
@@ -62,6 +63,16 @@ export function explainGoogleError(status, body) {
62
63
  return { code: 'ERROR', message: message || `Google returned HTTP ${status}.` };
63
64
  }
64
65
 
66
+ // The image studio runs in a process of its own, so it writes its own line into the room's
67
+ // outbound log. Without this, the one request MADRE makes that carries somebody's words to
68
+ // Google would be the one request the log could not see.
69
+ let outboundLog = null;
70
+ function outboundFetch(fetchImpl, file) {
71
+ if (!file) return fetchImpl;
72
+ outboundLog ??= new OutboundLog({ file });
73
+ return outboundLog.watch(fetchImpl);
74
+ }
75
+
65
76
  export async function generateImage({ prompt, fileName, outDir, model = process.env.PULSE_IMAGE_MODEL || DEFAULT_MODEL, env = process.env, fetchImpl = fetch }) {
66
77
  if (!prompt || typeof prompt !== 'string') throw Object.assign(new Error('A text prompt is required.'), { code: 'INVALID' });
67
78
  const root = await realpath(outDir).catch(() => null);
@@ -76,7 +87,7 @@ export async function generateImage({ prompt, fileName, outDir, model = process.
76
87
  }
77
88
  const key = await resolveGeminiKey(env);
78
89
  if (!key) throw Object.assign(new Error('No Gemini API key: set GEMINI_API_KEY or sign in with the Gemini CLI (/auth → API key).'), { code: 'NO_KEY' });
79
- const response = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`, {
90
+ const response = await outboundFetch(fetchImpl, env.PULSE_OUTBOUND_LOG)(`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`, {
80
91
  method: 'POST',
81
92
  headers: { 'content-type': 'application/json', 'x-goog-api-key': key },
82
93
  body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { responseModalities: ['IMAGE'] } }),
@@ -57,7 +57,7 @@ export const TOOLS = [
57
57
  name: 'memory_note',
58
58
  description: 'Save one durable memory of this room, ONLY when the human explicitly asks you to remember, note or save something (memories are otherwise distilled automatically; never save on your own initiative). One self-contained sentence, at most 240 characters, in the language the room uses, with the ledger sequences it comes from when you know them. Refused in a GHOST turn.',
59
59
  inputSchema: { type: 'object', properties: {
60
- kind: { type: 'string', enum: MEMORY_KINDS, description: 'decision, fact, preference or question.' },
60
+ kind: { type: 'string', enum: MEMORY_KINDS, description: 'decision, fact, preference, question, or aberration for a claim this room established is false.' },
61
61
  text: { type: 'string', description: 'The memory, one sentence, names and numbers exact.' },
62
62
  sources: { type: 'array', items: { type: 'integer' }, description: 'Ledger sequences it comes from, if known.' },
63
63
  }, required: ['kind', 'text'] },