@jossuealcala/madre 0.3.3 → 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.
- package/CHANGELOG.md +412 -3
- package/CONTRIBUTING.md +3 -1
- package/README.md +67 -185
- package/SECURITY.md +2 -1
- package/bin/madre.mjs +56 -13
- package/docs/INTERNALS.md +16 -0
- package/docs/REFERENCE.md +249 -0
- package/docs/SDK.md +121 -0
- package/docs/room.png +0 -0
- package/docs/sdk/hello-module.mjs +51 -0
- package/package.json +9 -1
- package/public/app.js +3825 -851
- package/public/es.js +2050 -0
- package/public/i18n.js +66 -0
- package/public/index.html +94 -13
- package/public/inquiry.js +220 -0
- package/public/resay.js +77 -0
- package/public/styles.css +602 -62
- package/public/troubleshooting.js +168 -46
- package/src/adapters/claude.mjs +2 -1
- package/src/adapters/codex.mjs +2 -1
- package/src/adapters/gemini.mjs +6 -5
- package/src/adapters/opencode.mjs +2 -1
- package/src/adapters/process.mjs +17 -5
- package/src/asking.mjs +128 -0
- package/src/auth-probe.mjs +58 -1
- package/src/chats.mjs +193 -0
- package/src/checkpoint.mjs +1 -1
- package/src/cold.mjs +56 -0
- package/src/commands.mjs +6 -0
- package/src/conversation-context.mjs +35 -3
- package/src/credentials.mjs +145 -0
- package/src/dataset.mjs +56 -4
- package/src/distiller.mjs +12 -5
- package/src/event-store.mjs +14 -8
- package/src/exam.mjs +240 -0
- package/src/extensions.mjs +3 -2
- package/src/eyecat-watch.mjs +100 -0
- package/src/eyecat.mjs +169 -0
- package/src/i18n.mjs +47 -0
- package/src/image-studio.mjs +2 -0
- package/src/launch.mjs +61 -0
- package/src/maturity.mjs +94 -0
- package/src/mcp/image-server.mjs +12 -1
- package/src/mcp/memory-server.mjs +1 -1
- package/src/memory.mjs +325 -17
- package/src/modules/ahp.mjs +9 -7
- package/src/modules/ash.mjs +36 -0
- package/src/modules/git-pulse.mjs +5 -3
- package/src/modules/helpers.mjs +31 -0
- package/src/modules/image-studio.mjs +9 -4
- package/src/modules/index.mjs +141 -9
- package/src/modules/ollama.mjs +66 -10
- package/src/modules/playwright.mjs +36 -19
- package/src/modules/ripley.mjs +5 -3
- package/src/modules/sdk.mjs +93 -2
- package/src/modules/updates.mjs +81 -0
- package/src/ollama.mjs +5 -2
- package/src/outbound.mjs +292 -0
- package/src/privacy.mjs +54 -7
- package/src/room/context.mjs +4 -4
- package/src/room/economy.mjs +161 -0
- package/src/room/prompt.mjs +118 -46
- package/src/room.mjs +443 -44
- package/src/runtime-detection.mjs +27 -8
- package/src/server.mjs +699 -69
- package/src/setup.mjs +1 -1
- package/src/updates.mjs +4 -2
- package/src/usage-sentinel.mjs +13 -8
- package/src/verdict.mjs +74 -0
- package/src/ashcode.mjs +0 -64
- package/src/modules/ashcode.mjs +0 -28
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// EYECAT, as the room runs it. The judgement lives in eyecat.mjs; this schedules it, picks who
|
|
2
|
+
// may answer, and puts what comes back in front of the human.
|
|
3
|
+
//
|
|
4
|
+
// It is not a module and not part of the room. It watches from outside, the way the error
|
|
5
|
+
// sentinel does: it subscribes to the ledger, it never takes a turn, it writes no file and it
|
|
6
|
+
// holds no permission. Nothing an agent says can reach it, and nothing it decides is final.
|
|
7
|
+
// What it produces is a question for a person, never an entry in the archive.
|
|
8
|
+
|
|
9
|
+
import { suspectPairs, unsupportedNotes, judgeFor, verdictPrompt, parseVerdict } from './eyecat.mjs';
|
|
10
|
+
|
|
11
|
+
export const EYECAT_MAX_PER_SWEEP = 3; // a ceiling, so a large archive cannot run up a bill
|
|
12
|
+
export const EYECAT_FLOOR = 0.72; // how close two notes must be to count as the same subject
|
|
13
|
+
export const EYECAT_MIN_CONFIDENCE = 0.5; // below this a verdict is not worth a person's attention
|
|
14
|
+
|
|
15
|
+
export class Eyecat {
|
|
16
|
+
#deps;
|
|
17
|
+
#settled = new Set();
|
|
18
|
+
#open = new Map();
|
|
19
|
+
#running = false;
|
|
20
|
+
|
|
21
|
+
constructor(deps) { this.#deps = deps; }
|
|
22
|
+
|
|
23
|
+
settings() { return { enabled: this.#deps.enabled?.() !== false, floor: EYECAT_FLOOR, maxPerSweep: EYECAT_MAX_PER_SWEEP }; }
|
|
24
|
+
findings() { return [...this.#open.values()].sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0)); }
|
|
25
|
+
|
|
26
|
+
// What a person already decided, replayed from the ledger so a restart does not ask twice.
|
|
27
|
+
seed(events = []) {
|
|
28
|
+
for (const event of events) {
|
|
29
|
+
if (event?.type === 'eyecat.flagged' && event.payload?.key) this.#open.set(event.payload.key, event.payload);
|
|
30
|
+
if (event?.type === 'eyecat.settled' && event.payload?.key) { this.#settled.add(event.payload.key); this.#open.delete(event.payload.key); }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The room only ever distils between turns, so this is already out of band: no agent is
|
|
35
|
+
// waiting on it and none of them will see the answer.
|
|
36
|
+
async observe(event) {
|
|
37
|
+
if (event?.type !== 'memory.distilled') return null;
|
|
38
|
+
return this.sweep({ reason: 'distilled' }).catch(() => null);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
settle(key, { verdict = 'dismissed' } = {}) {
|
|
42
|
+
if (!this.#open.has(key) && this.#settled.has(key)) return null;
|
|
43
|
+
const finding = this.#open.get(key) ?? { key };
|
|
44
|
+
this.#settled.add(key);
|
|
45
|
+
this.#open.delete(key);
|
|
46
|
+
return { ...finding, settledAs: verdict };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async sweep({ reason = 'asked' } = {}) {
|
|
50
|
+
if (this.#running || this.#deps.enabled?.() === false) return null;
|
|
51
|
+
const research = this.#deps.research?.();
|
|
52
|
+
if (!research?.memories?.length) return null;
|
|
53
|
+
this.#running = true;
|
|
54
|
+
try {
|
|
55
|
+
const skip = new Set([...this.#settled, ...this.#open.keys()]);
|
|
56
|
+
const candidates = [
|
|
57
|
+
...suspectPairs(research.links ?? [], research.memories, { floor: EYECAT_FLOOR, settled: skip }),
|
|
58
|
+
...unsupportedNotes(research.memories, this.#deps.entriesFor?.(research.memories) ?? [], { settled: skip }),
|
|
59
|
+
].slice(0, EYECAT_MAX_PER_SWEEP);
|
|
60
|
+
const raised = [];
|
|
61
|
+
for (const candidate of candidates) {
|
|
62
|
+
const finding = await this.#judge(candidate, reason).catch(() => null);
|
|
63
|
+
if (finding) raised.push(finding);
|
|
64
|
+
}
|
|
65
|
+
return raised.length ? raised : null;
|
|
66
|
+
} finally {
|
|
67
|
+
this.#running = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async #judge(candidate, reason) {
|
|
72
|
+
const judge = judgeFor(candidate, this.#deps.bench?.() ?? {});
|
|
73
|
+
// With nobody impartial free, the candidate is left alone rather than handed to an agent
|
|
74
|
+
// with a stake in the answer. It will come round again on the next sweep.
|
|
75
|
+
if (!judge) return null;
|
|
76
|
+
const invoke = this.#deps.bench?.().invokers?.[judge.adapter];
|
|
77
|
+
if (!invoke) return null;
|
|
78
|
+
const cited = candidate.kind === 'unsupported' ? (this.#deps.entriesFor?.([candidate.claim]) ?? []).filter((entry) => candidate.cites.includes(entry.sequence)).map((entry) => entry.text) : [];
|
|
79
|
+
const answer = await invoke({ prompt: verdictPrompt({ ...candidate, citedText: cited }, { json: judge.local === true }), json: judge.local === true });
|
|
80
|
+
const verdict = parseVerdict(String(answer ?? ''), candidate);
|
|
81
|
+
if (!verdict || verdict.verdict !== 'contradiction') return null;
|
|
82
|
+
if (verdict.confidence !== null && verdict.confidence < EYECAT_MIN_CONFIDENCE) return null;
|
|
83
|
+
const accused = candidate.kind === 'unsupported' ? candidate.claim : (verdict.accused === candidate.against?.id ? candidate.against : candidate.claim);
|
|
84
|
+
const finding = {
|
|
85
|
+
key: candidate.key,
|
|
86
|
+
kind: candidate.kind,
|
|
87
|
+
reason,
|
|
88
|
+
judge: judge.id,
|
|
89
|
+
signals: candidate.signals ?? [],
|
|
90
|
+
confidence: verdict.confidence,
|
|
91
|
+
correction: verdict.correction || null,
|
|
92
|
+
claim: { id: accused.id, text: accused.text, kind: accused.kind, agent: accused.agent },
|
|
93
|
+
against: candidate.against && candidate.against.id !== accused.id ? { id: candidate.against.id, text: candidate.against.text } : null,
|
|
94
|
+
at: new Date().toISOString(),
|
|
95
|
+
};
|
|
96
|
+
this.#open.set(finding.key, finding);
|
|
97
|
+
await this.#deps.emit('eyecat.flagged', finding);
|
|
98
|
+
return finding;
|
|
99
|
+
}
|
|
100
|
+
}
|
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
|
+
}
|
package/src/image-studio.mjs
CHANGED
|
@@ -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/maturity.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/mcp/image-server.mjs
CHANGED
|
@@ -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
|
|
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'] },
|