@jossuealcala/madre 0.3.3 → 0.4.1
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 +497 -3
- package/CONTRIBUTING.md +3 -1
- package/README.md +68 -186
- 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 +3979 -867
- package/public/es.js +2258 -0
- package/public/i18n.js +66 -0
- package/public/index.html +96 -15
- package/public/inquiry.js +220 -0
- package/public/resay.js +77 -0
- package/public/styles.css +622 -65
- package/public/troubleshooting.js +255 -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 +79 -20
- 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 +36 -3
- 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 +10 -4
- package/src/modules/index.mjs +141 -9
- package/src/modules/ollama.mjs +66 -10
- package/src/modules/playwright.mjs +44 -23
- 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 +297 -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/sentinel-errors.mjs +19 -1
- package/src/server.mjs +709 -71
- 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
package/src/exam.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// The three tests that answer "is this room ready to be developed with?"
|
|
2
|
+
//
|
|
3
|
+
// The maturity reading counts what the archive is made of. It cannot tell you whether the archive
|
|
4
|
+
// works, because nothing about a pile of notes says whether the right one comes back when it is
|
|
5
|
+
// needed. Only a test says that, and a test is only worth running if it can fail.
|
|
6
|
+
//
|
|
7
|
+
// 1 · COVERAGE — when this project asks its own questions, does the archive already hold the
|
|
8
|
+
// answer? Real human messages from the ledger, recall run at the point each
|
|
9
|
+
// one was asked, scored against the reply that was actually given.
|
|
10
|
+
// 2 · CONSISTENCY — does the archive contradict itself? Contradictions EYECAT is still holding,
|
|
11
|
+
// what has been taken out of circulation, and whether aberrations are being
|
|
12
|
+
// filed more often lately or less.
|
|
13
|
+
// 3 · MATCH — does the local model, with this archive behind it, land where the frontier
|
|
14
|
+
// CLI landed? Real questions from the ledger, asked again locally, compared
|
|
15
|
+
// against the answer that was given at the time.
|
|
16
|
+
//
|
|
17
|
+
// What every one of them refuses to do is grade itself generously. Each reports how it measured
|
|
18
|
+
// (meaning or words), how many cases it had, and the bar it used, because a number without those
|
|
19
|
+
// three is a decoration.
|
|
20
|
+
|
|
21
|
+
import { t } from './i18n.mjs';
|
|
22
|
+
|
|
23
|
+
export const COVERAGE_SAMPLE = 30;
|
|
24
|
+
export const COVERAGE_BAR = 0.55; // cosine between the reply and the closest thing recalled
|
|
25
|
+
export const COVERAGE_WORDS_BAR = 0.25;
|
|
26
|
+
export const MATCH_SAMPLE = 12; // a local model answering is slow; honesty about that beats a big number
|
|
27
|
+
export const MATCH_BAR = 0.6;
|
|
28
|
+
// Every text in one room is about the same handful of subjects, so any two pieces of it read as
|
|
29
|
+
// similar to an embedder. Measured against a bar alone, the first run of coverage scored thirty
|
|
30
|
+
// out of thirty — a test that cannot fail measures nothing. So each case is also measured against
|
|
31
|
+
// a control: the same question scored against another case's material. To count, what the archive
|
|
32
|
+
// actually handed over has to beat what it would have handed over for something else.
|
|
33
|
+
export const CONTROL_MARGIN = 0.05;
|
|
34
|
+
export const MIN_ASK = 40; // shorter than this is "ok", "sí", "dale": nothing to answer
|
|
35
|
+
export const PASS = { coverage: 0.7, match: 0.7 };
|
|
36
|
+
|
|
37
|
+
const rare = (text) => new Set(String(text ?? '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').match(/[a-z0-9][a-z0-9_/.-]{3,}/g) ?? []);
|
|
38
|
+
|
|
39
|
+
// How close two pieces of text are when there is nothing to embed them with. Not a semantic
|
|
40
|
+
// score and never reported as one: the share of the answer's own uncommon words that were
|
|
41
|
+
// already in front of the room.
|
|
42
|
+
export function wordScore(reference, candidate) {
|
|
43
|
+
const want = rare(reference);
|
|
44
|
+
const have = rare(candidate);
|
|
45
|
+
if (!want.size || !have.size) return 0;
|
|
46
|
+
let shared = 0;
|
|
47
|
+
for (const word of want) if (have.has(word)) shared += 1;
|
|
48
|
+
return shared / want.size;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function cosine(a, b) {
|
|
52
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
53
|
+
let dot = 0;
|
|
54
|
+
let left = 0;
|
|
55
|
+
let right = 0;
|
|
56
|
+
for (let i = 0; i < a.length; i += 1) { dot += a[i] * b[i]; left += a[i] * a[i]; right += b[i] * b[i]; }
|
|
57
|
+
return left && right ? dot / Math.sqrt(left * right) : 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Every human message in the ledger that actually got an answer, oldest first. Ghost turns never
|
|
61
|
+
// happened as far as the archive is concerned, and a one-word message has no answer to find.
|
|
62
|
+
export function exchanges(events = [], { minAsk = MIN_ASK, local = ['madre'] } = {}) {
|
|
63
|
+
const asked = new Map();
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const event of events) {
|
|
66
|
+
if (!event || event.ghost) continue;
|
|
67
|
+
const payload = event.payload ?? {};
|
|
68
|
+
if (event.type !== 'message.created') continue;
|
|
69
|
+
if (payload.role === 'user' && payload.sender === 'you') {
|
|
70
|
+
const text = String(payload.text ?? '').trim();
|
|
71
|
+
if (text.length >= minAsk && !text.startsWith('/')) asked.set(payload.messageId, { sequence: event.sequence, text, target: payload.target });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (payload.role !== 'assistant') continue;
|
|
75
|
+
const parent = payload.parentMessageId ?? payload.replyTo ?? payload.inReplyTo ?? null;
|
|
76
|
+
const question = parent ? asked.get(parent) : null;
|
|
77
|
+
if (!question) continue;
|
|
78
|
+
const answer = String(payload.text ?? '').trim();
|
|
79
|
+
if (answer.length < minAsk) continue;
|
|
80
|
+
asked.delete(parent);
|
|
81
|
+
out.push({ sequence: question.sequence, answerSequence: event.sequence, asked: question.text, answered: answer, agent: payload.sender, local: local.includes(payload.sender) });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The control partner for each case: far enough down the list that it is a different day and a
|
|
87
|
+
// different subject, and deterministic so a run can be repeated.
|
|
88
|
+
export const controlOf = (index, n) => (n > 1 ? (index + Math.floor(n / 2)) % n : 0);
|
|
89
|
+
|
|
90
|
+
// A sample spread across the whole ledger rather than taken off the end: fifty questions from one
|
|
91
|
+
// afternoon test one afternoon.
|
|
92
|
+
export function spread(items, size) {
|
|
93
|
+
if (items.length <= size) return [...items];
|
|
94
|
+
const step = items.length / size;
|
|
95
|
+
return Array.from({ length: size }, (_, i) => items[Math.floor(i * step)]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ---------- 1 · COVERAGE ---------- */
|
|
99
|
+
|
|
100
|
+
// `recall(text, beforeSequence)` hands back what the room would have been given at that moment.
|
|
101
|
+
// `embed(texts)` is optional; without it the scoring says so and uses words.
|
|
102
|
+
export async function coverageExam({ events = [], recall, embed = null, sample = COVERAGE_SAMPLE, bar = null } = {}) {
|
|
103
|
+
const all = exchanges(events);
|
|
104
|
+
const cases = spread(all, sample);
|
|
105
|
+
if (!cases.length) return { id: 'coverage', ran: false, says: t('No exchange in this room is long enough to test with yet.') };
|
|
106
|
+
const found = [];
|
|
107
|
+
for (const one of cases) {
|
|
108
|
+
const held = await recall(one.asked, one.sequence);
|
|
109
|
+
found.push(held.map((item) => String(item ?? '')).filter(Boolean));
|
|
110
|
+
}
|
|
111
|
+
const method = embed ? 'meaning' : 'words';
|
|
112
|
+
const used = bar ?? (embed ? COVERAGE_BAR : COVERAGE_WORDS_BAR);
|
|
113
|
+
const scores = [];
|
|
114
|
+
const controls = [];
|
|
115
|
+
if (embed) {
|
|
116
|
+
// One batch for the answers and one for everything that was recalled, so a remote embedder
|
|
117
|
+
// is asked twice and not sixty times.
|
|
118
|
+
const flat = found.flat();
|
|
119
|
+
const vectors = await embed([...cases.map((one) => one.answered), ...flat]);
|
|
120
|
+
const answers = vectors.slice(0, cases.length);
|
|
121
|
+
const rest = vectors.slice(cases.length);
|
|
122
|
+
const sets = [];
|
|
123
|
+
let at = 0;
|
|
124
|
+
for (const items of found) { sets.push(rest.slice(at, at + items.length)); at += items.length; }
|
|
125
|
+
for (let i = 0; i < cases.length; i += 1) {
|
|
126
|
+
const best = (set) => set.reduce((top, vector) => Math.max(top, cosine(answers[i], vector)), 0);
|
|
127
|
+
scores.push(best(sets[i]));
|
|
128
|
+
controls.push(best(sets[controlOf(i, cases.length)]));
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
for (let i = 0; i < cases.length; i += 1) {
|
|
132
|
+
const best = (set) => set.reduce((top, text) => Math.max(top, wordScore(cases[i].answered, text)), 0);
|
|
133
|
+
scores.push(best(found[i]));
|
|
134
|
+
controls.push(best(found[controlOf(i, cases.length)]));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// To count, the archive must have handed over this answer's material, and material it would
|
|
138
|
+
// not have handed over for a different question. With a single case there is nothing to
|
|
139
|
+
// compare against, and the result says so rather than pretending otherwise.
|
|
140
|
+
const controlled = cases.length > 1;
|
|
141
|
+
const hit = scores.map((score, i) => score >= used && (!controlled || score > controls[i] + CONTROL_MARGIN));
|
|
142
|
+
const hits = hit.filter(Boolean).length;
|
|
143
|
+
const rate = Number((hits / cases.length).toFixed(3));
|
|
144
|
+
const mean = (list) => Number((list.reduce((sum, value) => sum + value, 0) / list.length).toFixed(3));
|
|
145
|
+
return {
|
|
146
|
+
id: 'coverage', ran: true, at: new Date().toISOString(), n: cases.length, hits, rate, method, bar: used,
|
|
147
|
+
mean: mean(scores), control: controlled ? mean(controls) : null,
|
|
148
|
+
passed: rate >= PASS.coverage,
|
|
149
|
+
says: t('{hits} of {n} questions this room actually asked had their answer already in the archive, matched by {method}{control}.', { hits, n: cases.length, method: t(method), control: controlled ? t(' and against a control') : '' }),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ---------- 2 · CONSISTENCY ---------- */
|
|
154
|
+
|
|
155
|
+
// Pure. Contradictions still open, how much has been taken out of circulation, and whether the
|
|
156
|
+
// room is filing aberrations more often lately than it used to.
|
|
157
|
+
export function consistencyExam({ findings = [], notes = [], now = Date.now() } = {}) {
|
|
158
|
+
const standing = notes.filter((note) => note && note.kind !== 'aberration' && !note.refutedBy);
|
|
159
|
+
const refuted = notes.filter((note) => note && note.refutedBy).length;
|
|
160
|
+
const aberrations = notes.filter((note) => note && note.kind === 'aberration');
|
|
161
|
+
const open = findings.filter((finding) => !finding?.settled).length;
|
|
162
|
+
const times = aberrations.map((note) => Date.parse(note.created)).filter((at) => Number.isFinite(at)).sort((a, b) => a - b);
|
|
163
|
+
const written = notes.map((note) => Date.parse(note.created)).filter((at) => Number.isFinite(at)).sort((a, b) => a - b);
|
|
164
|
+
let trend = null;
|
|
165
|
+
if (written.length > 4 && times.length) {
|
|
166
|
+
const middle = written[Math.floor(written.length / 2)];
|
|
167
|
+
const before = times.filter((at) => at < middle).length;
|
|
168
|
+
const after = times.length - before;
|
|
169
|
+
trend = after === before ? 'level' : after < before ? 'falling' : 'rising';
|
|
170
|
+
}
|
|
171
|
+
const passed = open === 0 && trend !== 'rising';
|
|
172
|
+
return {
|
|
173
|
+
id: 'consistency', ran: true, at: new Date(now).toISOString(),
|
|
174
|
+
open, refuted, standing: standing.length, aberrations: aberrations.length, trend,
|
|
175
|
+
passed,
|
|
176
|
+
says: open
|
|
177
|
+
? t('EYECAT is holding {n} contradictions nobody has settled.', { n: open })
|
|
178
|
+
: trend === 'rising'
|
|
179
|
+
? t('Nothing is open, but aberrations are being filed more often lately than they used to be.')
|
|
180
|
+
: t('Nothing contradicts anything: {standing} notes stand, {refuted} were taken out of circulation.', { standing: standing.length, refuted }),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* ---------- 3 · MATCH ---------- */
|
|
185
|
+
|
|
186
|
+
// `ask(question)` is the local model with this archive behind it. `embed` is needed: comparing
|
|
187
|
+
// two answers by their words rewards copying the question back.
|
|
188
|
+
export async function matchExam({ events = [], ask, embed, sample = MATCH_SAMPLE, bar = MATCH_BAR, onProgress = () => {}, stop = () => false } = {}) {
|
|
189
|
+
if (!embed) return { id: 'match', ran: false, says: t('This test needs embeddings: two answers cannot be compared by their words alone.') };
|
|
190
|
+
const all = exchanges(events).filter((one) => !one.local); // a model is not tested against itself
|
|
191
|
+
const cases = spread(all, sample);
|
|
192
|
+
if (!cases.length) return { id: 'match', ran: false, says: t('No question in this room was answered by an agent other than the local one yet.') };
|
|
193
|
+
const mine = [];
|
|
194
|
+
for (const [index, one] of cases.entries()) {
|
|
195
|
+
if (stop()) return { id: 'match', ran: false, stopped: true, says: t('Stopped after {index} of {n}.', { index, n: cases.length }) };
|
|
196
|
+
onProgress({ done: index, total: cases.length });
|
|
197
|
+
try { mine.push(String((await ask(one.asked)) ?? '')); } catch { mine.push(''); }
|
|
198
|
+
}
|
|
199
|
+
onProgress({ done: cases.length, total: cases.length });
|
|
200
|
+
const vectors = await embed([...cases.map((one) => one.answered), ...mine]);
|
|
201
|
+
const theirs = vectors.slice(0, cases.length);
|
|
202
|
+
const ours = vectors.slice(cases.length);
|
|
203
|
+
const scores = cases.map((_, i) => (mine[i] ? cosine(theirs[i], ours[i]) : 0));
|
|
204
|
+
// Same control as coverage: an answer that is merely about the same project as every other
|
|
205
|
+
// answer in the room has not landed anywhere.
|
|
206
|
+
const controlled = cases.length > 1;
|
|
207
|
+
const controls = cases.map((_, i) => (mine[i] && controlled ? cosine(ours[i], theirs[controlOf(i, cases.length)]) : 0));
|
|
208
|
+
const matched = scores.filter((score, i) => score >= bar && (!controlled || score > controls[i] + CONTROL_MARGIN)).length;
|
|
209
|
+
const rate = Number((matched / cases.length).toFixed(3));
|
|
210
|
+
const mean = (list) => Number((list.reduce((sum, value) => sum + value, 0) / list.length).toFixed(3));
|
|
211
|
+
return {
|
|
212
|
+
id: 'match', ran: true, at: new Date().toISOString(), n: cases.length, matched, rate, mean: mean(scores), control: controlled ? mean(controls) : null, bar,
|
|
213
|
+
passed: rate >= PASS.match,
|
|
214
|
+
says: t('On {matched} of {n} real questions the local model landed where the agent of the day landed{control}.', { matched, n: cases.length, control: controlled ? t(', and not merely in the same project') : '' }),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A reading that was taken months ago is still read today, and the room may have changed its
|
|
219
|
+
// language since. The numbers are what was measured; the sentence is only how they are said, so
|
|
220
|
+
// it is said again, now, from the fields that were stored. A stored result that predates this
|
|
221
|
+
// keeps whatever sentence it was written with.
|
|
222
|
+
export function saysFor(result) {
|
|
223
|
+
if (!result?.ran) return result?.says ?? null;
|
|
224
|
+
if (result.id === 'coverage' && Number.isFinite(result.hits)) {
|
|
225
|
+
return t('{hits} of {n} questions this room actually asked had their answer already in the archive, matched by {method}{control}.',
|
|
226
|
+
{ hits: result.hits, n: result.n, method: t(result.method ?? 'words'), control: result.control === null || result.control === undefined ? '' : t(' and against a control') });
|
|
227
|
+
}
|
|
228
|
+
if (result.id === 'match' && Number.isFinite(result.matched)) {
|
|
229
|
+
return t('On {matched} of {n} real questions the local model landed where the agent of the day landed{control}.',
|
|
230
|
+
{ matched: result.matched, n: result.n, control: result.control === null || result.control === undefined ? '' : t(', and not merely in the same project') });
|
|
231
|
+
}
|
|
232
|
+
if (result.id === 'consistency' && Number.isFinite(result.open)) {
|
|
233
|
+
return result.open
|
|
234
|
+
? t('EYECAT is holding {n} contradictions nobody has settled.', { n: result.open })
|
|
235
|
+
: result.trend === 'rising'
|
|
236
|
+
? t('Nothing is open, but aberrations are being filed more often lately than they used to be.')
|
|
237
|
+
: t('Nothing contradicts anything: {standing} notes stand, {refuted} were taken out of circulation.', { standing: result.standing, refuted: result.refuted });
|
|
238
|
+
}
|
|
239
|
+
return result.says ?? null;
|
|
240
|
+
}
|
package/src/extensions.mjs
CHANGED
|
@@ -8,7 +8,7 @@ export const EXTENSIONS = MODULES;
|
|
|
8
8
|
export const AHP = moduleById('ahp');
|
|
9
9
|
export const IMAGE_STUDIO = moduleById('image-studio');
|
|
10
10
|
export const GIT_PULSE = moduleById('git-pulse');
|
|
11
|
-
export const
|
|
11
|
+
export const ASH = moduleById('ash');
|
|
12
12
|
export const RIPLEY = moduleById('ripley');
|
|
13
13
|
export const OLLAMA = moduleById('ollama');
|
|
14
14
|
export const extensionById = moduleById;
|
|
@@ -26,9 +26,10 @@ export async function listExtensions({ projectRoot, agents = [], config = {}, im
|
|
|
26
26
|
|
|
27
27
|
// Runs one installer inside the project, streaming output lines. `runner`
|
|
28
28
|
// lets tests substitute the real installer with a local script.
|
|
29
|
+
// `npm` and other Node CLIs are .cmd shims on Windows, which cannot be spawned directly.
|
|
29
30
|
export function runInstaller({ command, args, projectRoot, onLine, timeoutMs = 600000, heartbeatMs = 8000, env = process.env }) {
|
|
30
31
|
return new Promise((resolve) => {
|
|
31
|
-
const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', CI: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
32
|
+
const child = spawn(command, args, { cwd: projectRoot, env: { ...env, NO_COLOR: '1', CI: '1' }, stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32', windowsHide: true });
|
|
32
33
|
const started = Date.now();
|
|
33
34
|
let lastOutput = started;
|
|
34
35
|
// Installers go quiet for long stretches (npm install, git status); say so.
|
|
@@ -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
|
+
}
|