@jossuealcala/madre 0.7.2 → 1.0.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 +773 -1
- package/README.md +92 -28
- package/bin/madre.mjs +30 -10
- package/docs/REFERENCE.md +157 -81
- package/docs/SDK.md +23 -19
- package/package.json +1 -1
- package/public/app.js +11207 -4196
- package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
- package/public/assets/brand/Geist-latin.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
- package/public/assets/brand/OFL-Geist.txt +93 -0
- package/public/assets/brand/OFL-GeistMono.txt +93 -0
- package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
- package/public/assets/brand/OFL-Sora.txt +93 -0
- package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
- package/public/assets/brand/Sora-latin.woff2 +0 -0
- package/public/assets/brand/madre-app-180.png +0 -0
- package/public/assets/brand/madre-app-192.png +0 -0
- package/public/assets/brand/madre-app-512.png +0 -0
- package/public/assets/brand/madre-app.svg +1 -0
- package/public/assets/brand/madre-favicon.svg +1 -0
- package/public/assets/news/1.0/alerts.webp +0 -0
- package/public/assets/news/1.0/bar.webp +0 -0
- package/public/assets/news/1.0/finder.webp +0 -0
- package/public/assets/news/1.0/github.webp +0 -0
- package/public/assets/news/1.0/intents.webp +0 -0
- package/public/assets/news/1.0/looks.webp +0 -0
- package/public/assets/news/1.0/modules.webp +0 -0
- package/public/assets/news/1.0/mother.webp +0 -0
- package/public/assets/news/1.0/nostromo.webp +0 -0
- package/public/assets/news/1.0/permissions.webp +0 -0
- package/public/assets/news/1.0/room.webp +0 -0
- package/public/assets/news/1.0/settings.webp +0 -0
- package/public/assets/wallpapers/biznaga.webp +0 -0
- package/public/assets/wallpapers/canela.webp +0 -0
- package/public/assets/wallpapers/jarrito.webp +0 -0
- package/public/assets/wallpapers/magna.webp +0 -0
- package/public/assets/wallpapers/oceano.webp +0 -0
- package/public/assets/wallpapers/paraiso.webp +0 -0
- package/public/assets/wallpapers/quila.webp +0 -0
- package/public/assets/wallpapers/tradicion.webp +0 -0
- package/public/assets/wallpapers/tropical.webp +0 -0
- package/public/assets/wallpapers/urbania.webp +0 -0
- package/public/assets/wallpapers/ventura.webp +0 -0
- package/public/assets/wallpapers/xico.webp +0 -0
- package/public/brands.js +28 -2
- package/public/editor.js +744 -0
- package/public/es.js +2784 -1026
- package/public/i18n.js +1 -1
- package/public/index.html +201 -175
- package/public/inquiry.js +11 -11
- package/public/loader.js +79 -0
- package/public/macroverso.js +1497 -0
- package/public/manifest.webmanifest +14 -0
- package/public/nostromo-engine.js +2222 -0
- package/public/resay.js +24 -0
- package/public/review-ask.js +85 -0
- package/public/salas-mac.css +787 -0
- package/public/salas-mac.html +154 -0
- package/public/salas-mac.js +3264 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6207 -425
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +342 -0
- package/public/tour.js +510 -0
- package/public/troubleshooting.js +159 -140
- package/src/adapters/codex.mjs +46 -6
- package/src/adapters/gemini.mjs +30 -10
- package/src/adapters/madre.mjs +110 -18
- package/src/adapters/process.mjs +42 -2
- package/src/auth-probe.mjs +2 -2
- package/src/bridge/devices.mjs +202 -0
- package/src/bridge/handoff.mjs +45 -0
- package/src/bridge/host.mjs +82 -0
- package/src/bridge/network.mjs +133 -0
- package/src/bridge/policy.mjs +89 -0
- package/src/bridge/qr.mjs +287 -0
- package/src/bridge/server.mjs +269 -0
- package/src/capabilities.mjs +5 -9
- package/src/chats.mjs +59 -22
- package/src/checkpoint.mjs +137 -35
- package/src/choices.mjs +18 -3
- package/src/cli-args.mjs +12 -1
- package/src/commands.mjs +15 -15
- package/src/config.mjs +73 -9
- package/src/conversation-context.mjs +5 -2
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +0 -5
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +0 -2
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/looks.mjs +55 -0
- package/src/maturity.mjs +3 -3
- package/src/mcp/github-server.mjs +164 -0
- package/src/mcp/image-server.mjs +1 -1
- package/src/mcp/memory-server.mjs +11 -3
- package/src/memory.mjs +38 -12
- package/src/models.mjs +89 -5
- package/src/modules/ahp.mjs +4 -4
- package/src/modules/ash.mjs +1 -1
- package/src/modules/bishop-plan.mjs +207 -0
- package/src/modules/bishop.mjs +645 -0
- package/src/modules/choices.mjs +2 -2
- package/src/modules/correo.mjs +1 -1
- package/src/modules/enlaces.mjs +255 -0
- package/src/modules/git-pulse.mjs +2 -2
- package/src/modules/github.mjs +322 -0
- package/src/modules/image-studio.mjs +3 -3
- package/src/modules/index.mjs +18 -4
- package/src/modules/intents-calibration.mjs +338 -0
- package/src/modules/intents-desk.mjs +481 -0
- package/src/modules/intents-observe.mjs +266 -0
- package/src/modules/intents-policy.mjs +414 -0
- package/src/modules/intents-try.mjs +91 -0
- package/src/modules/intents.mjs +145 -0
- package/src/modules/ollama.mjs +4 -4
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +2 -2
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +6 -1
- package/src/outbound.mjs +19 -17
- package/src/override.mjs +100 -0
- package/src/ports.mjs +23 -1
- package/src/prices.mjs +72 -0
- package/src/privacy.mjs +30 -11
- package/src/quota-sources.mjs +0 -8
- package/src/request-guard.mjs +1 -1
- package/src/review-ask.mjs +111 -0
- package/src/room/archivist.mjs +55 -37
- package/src/room/budget.mjs +17 -5
- package/src/room/control.mjs +70 -7
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +2 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +22 -15
- package/src/room/runs.mjs +84 -21
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +351 -65
- package/src/router.mjs +3 -1
- package/src/runs.mjs +23 -5
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1526 -163
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +159 -0
- package/src/updates.mjs +48 -8
- package/src/usage-sentinel.mjs +1 -1
- package/src/verdict.mjs +7 -7
- package/src/whats-new.mjs +160 -0
package/src/modules/index.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// (defineModule is applied here), a module already built with defineModule, or a function
|
|
5
5
|
// receiving { defineModule } and returning either. Agents cannot write those folders: .madre/
|
|
6
6
|
// is a forbidden zone, and ~/.pulse lives outside every project.
|
|
7
|
-
import { readdir, readFile, writeFile, mkdir, unlink, mkdtemp, rm
|
|
7
|
+
import { readdir, readFile, writeFile, mkdir, unlink, mkdtemp, rm } from 'node:fs/promises';
|
|
8
8
|
import { translate } from '../i18n.mjs';
|
|
9
9
|
import { join, basename, resolve, sep } from 'node:path';
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
@@ -14,12 +14,17 @@ import ahp from './ahp.mjs';
|
|
|
14
14
|
import imageStudio from './image-studio.mjs';
|
|
15
15
|
import gitPulse from './git-pulse.mjs';
|
|
16
16
|
import ash from './ash.mjs';
|
|
17
|
+
import intents from './intents.mjs';
|
|
17
18
|
import choices from './choices.mjs';
|
|
18
19
|
import runs from './runs.mjs';
|
|
19
20
|
import ripley from './ripley.mjs';
|
|
20
21
|
import ollama from './ollama.mjs';
|
|
21
22
|
import playwright from './playwright.mjs';
|
|
22
23
|
import correo from './correo.mjs';
|
|
24
|
+
import github from './github.mjs';
|
|
25
|
+
import enlaces from './enlaces.mjs';
|
|
26
|
+
import bishop from './bishop.mjs';
|
|
27
|
+
import { isSalasRoom } from '../salas.mjs';
|
|
23
28
|
import { defineModule, matchRoute } from './sdk.mjs';
|
|
24
29
|
|
|
25
30
|
// The lab: modules that are built and tested but not offered yet, because they are not finished
|
|
@@ -28,7 +33,7 @@ import { defineModule, matchRoute } from './sdk.mjs';
|
|
|
28
33
|
// so a module of somebody's own cannot take one and collide with it the day it comes out.
|
|
29
34
|
export const LAB_MODULES = [correo];
|
|
30
35
|
const labsAsked = new Set(String(process.env.PULSE_LABS ?? '').split(',').map((id) => id.trim()).filter(Boolean));
|
|
31
|
-
export const MODULES = [ahp, imageStudio, gitPulse, ash, choices, runs, ripley, ollama, playwright, ...LAB_MODULES.filter((module) => labsAsked.has(module.id))];
|
|
36
|
+
export const MODULES = [ahp, imageStudio, gitPulse, ash, intents, choices, runs, ripley, ollama, playwright, github, enlaces, bishop, ...LAB_MODULES.filter((module) => labsAsked.has(module.id))];
|
|
32
37
|
export const BUILTIN_IDS = new Set([...MODULES, ...LAB_MODULES].map((module) => module.id));
|
|
33
38
|
export const loadFailures = []; // { file, error } for MODULES to show
|
|
34
39
|
export const moduleFolders = ({ stateRoot, projectRoot }) => ({ user: join(stateRoot, 'modules'), project: join(projectRoot, '.madre', 'modules') });
|
|
@@ -172,7 +177,8 @@ export const moduleById = (id) => MODULES.find((module) => module.id === id) ??
|
|
|
172
177
|
// What MODULES shows, with each card's update state attached from the cache when the room offers
|
|
173
178
|
// the service. Never a network read: a screen that waits on a registry is a screen that hangs.
|
|
174
179
|
export async function describeModules(ctx) {
|
|
175
|
-
const
|
|
180
|
+
const visible = MODULES.filter((module) => module.id !== 'bishop' || (ctx.projectRoot && ctx.stateRoot && isSalasRoom(ctx.projectRoot, ctx.stateRoot)));
|
|
181
|
+
const items = await Promise.all(visible.map((module) => module.describe(ctx)));
|
|
176
182
|
const look = ctx.services?.moduleUpdate;
|
|
177
183
|
// One place, at the boundary: what a module says about itself is written in English inside the
|
|
178
184
|
// module, and it is the card that speaks the room's language. Nothing in the module files
|
|
@@ -206,7 +212,15 @@ export function moduleCommands() {
|
|
|
206
212
|
// that forgets to declare, or declares wrongly, cannot widen its own permission by doing so: a
|
|
207
213
|
// server whose every tool sends is dropped whole.
|
|
208
214
|
export async function toolsForTurn(ctx, turn) {
|
|
209
|
-
|
|
215
|
+
// Each module on its own (LOG-17 of the 1.0 review): one that threw took every module's tools
|
|
216
|
+
// out of the turn with it, Playwright and GitHub and the links included.
|
|
217
|
+
const offering = MODULES.filter((module) => module.toolsForTurn);
|
|
218
|
+
const settled = await Promise.allSettled(offering.map(async (module) => module.toolsForTurn(ctx, turn)));
|
|
219
|
+
const lists = settled.map((outcome, index) => {
|
|
220
|
+
if (outcome.status === 'fulfilled') return outcome.value ?? [];
|
|
221
|
+
console.error(`MADRE module ${offering[index].id ?? '?'} handed no tools: ${outcome.reason?.message ?? outcome.reason}`);
|
|
222
|
+
return [];
|
|
223
|
+
});
|
|
210
224
|
const airlock = Number(turn?.mode) === 4;
|
|
211
225
|
const servers = [];
|
|
212
226
|
for (const server of lists.flat()) {
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// The two tests behind Intenciones: is a cheaper rung enough for this project?
|
|
2
|
+
//
|
|
3
|
+
// A suggestion is only worth what it was measured against, and the room's ordinary use can never
|
|
4
|
+
// measure it: every turn runs at one effort, so nobody ever sees what the other rung would have
|
|
5
|
+
// said. These tests ask on purpose. Real questions from this project's own ledger go to the same
|
|
6
|
+
// agent twice, at its usual rung and one cheaper, and a judge who cannot tell which is which says
|
|
7
|
+
// whether the cheaper answer is missing anything that matters.
|
|
8
|
+
//
|
|
9
|
+
// QUICK — the human judges. Six questions, one rung down, twelve turns. Enough to say "you did
|
|
10
|
+
// not notice a difference", never enough to say there is none.
|
|
11
|
+
// FULL — an impartial agent judges, chosen the way EYECAT chooses: never the agent being
|
|
12
|
+
// measured. It walks down the ladder one rung at a time, per kind of question, and
|
|
13
|
+
// stops at the first rung that is not enough. It runs only when the human started it
|
|
14
|
+
// or when the agent's window is about to reset with quota that would be lost anyway.
|
|
15
|
+
//
|
|
16
|
+
// Everything that talks to a model is handed in (`ask`, `judge`), as matchExam's `ask` is. Nothing
|
|
17
|
+
// here spends a turn by itself, and every turn it does spend is counted against `maxTurns`.
|
|
18
|
+
//
|
|
19
|
+
// Read-only questions only. A request to change the project, asked again with no lease, measures
|
|
20
|
+
// how well an agent explains that it cannot write; that needs a worktree and the project's own
|
|
21
|
+
// tests as the judge, and it is not this file.
|
|
22
|
+
|
|
23
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
24
|
+
import { exchanges } from '../exam.mjs';
|
|
25
|
+
import { judgeFor } from '../eyecat.mjs';
|
|
26
|
+
import { budgetTokens } from '../room/budget.mjs';
|
|
27
|
+
import { classifyUsagePercent } from '../usage-sentinel.mjs';
|
|
28
|
+
import { classifyIntent, effortLadder, evidenceKey, foldEvidence, lowestSufficient, nextRung, wilsonLower, wilsonUpper, MIN_COMPARISONS, MAX_COMPARISONS } from './intents-policy.mjs';
|
|
29
|
+
|
|
30
|
+
export const READ_ONLY_INTENTS = ['lookup', 'explain', 'review', 'diagnose', 'design'];
|
|
31
|
+
export const CASE_MIN_CHARS = 20;
|
|
32
|
+
export const QUICK_CASES = 6;
|
|
33
|
+
export const FULL_MAX_TURNS = 120;
|
|
34
|
+
// A guard against a loop, not a budget: every round consumes questions, so the pool runs out first.
|
|
35
|
+
const MAX_ROUNDS = 40;
|
|
36
|
+
|
|
37
|
+
// The questions this project has actually asked, each with the kind of question it is. A question
|
|
38
|
+
// the local model answered is left out with the archive questions: both are @madre's.
|
|
39
|
+
export function calibrationCases(events = []) {
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const cases = [];
|
|
42
|
+
// Lookups are short by nature; the archive's forty-character floor would leave them all out.
|
|
43
|
+
for (const one of exchanges(events, { minAsk: CASE_MIN_CHARS })) {
|
|
44
|
+
if (one.local) continue;
|
|
45
|
+
const intent = classifyIntent(one.asked)?.intent;
|
|
46
|
+
if (!READ_ONLY_INTENTS.includes(intent)) continue;
|
|
47
|
+
const key = one.asked.toLowerCase().replace(/\s+/g, ' ');
|
|
48
|
+
if (seen.has(key)) continue;
|
|
49
|
+
seen.add(key);
|
|
50
|
+
cases.push({ caseId: `q${one.sequence}`, question: one.asked, intent });
|
|
51
|
+
}
|
|
52
|
+
return cases;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The rung the human usually asks this agent for, which is what a cheaper rung has to match.
|
|
56
|
+
export function usualEffort(events = [], agent) {
|
|
57
|
+
const ladder = effortLadder(agent);
|
|
58
|
+
if (!ladder) return null;
|
|
59
|
+
const counts = new Map();
|
|
60
|
+
for (const event of events) {
|
|
61
|
+
const p = event?.payload ?? {};
|
|
62
|
+
if (event?.type !== 'message.created' || p.role !== 'user' || p.target !== agent || !ladder.includes(p.effort)) continue;
|
|
63
|
+
counts.set(p.effort, (counts.get(p.effort) ?? 0) + 1);
|
|
64
|
+
}
|
|
65
|
+
let best = null;
|
|
66
|
+
for (const [effort, count] of counts) if (!best || count > best.count || (count === best.count && ladder.indexOf(effort) > ladder.indexOf(best.effort))) best = { effort, count };
|
|
67
|
+
return best?.effort ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Interleaved by kind, so six questions are six different kinds of work where the ledger has them
|
|
71
|
+
// rather than six of whatever the room asks most.
|
|
72
|
+
function interleave(cases) {
|
|
73
|
+
const byIntent = new Map();
|
|
74
|
+
for (const one of cases) (byIntent.get(one.intent) ?? byIntent.set(one.intent, []).get(one.intent)).push(one);
|
|
75
|
+
const queues = [...byIntent.values()];
|
|
76
|
+
const out = [];
|
|
77
|
+
while (queues.some((queue) => queue.length)) for (const queue of queues) if (queue.length) out.push(queue.shift());
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// One round: which question goes to which rung. Records are everything already kept, from this run
|
|
82
|
+
// and earlier ones; a question is never asked twice at the same rung of the same version.
|
|
83
|
+
//
|
|
84
|
+
// The quick test may be aimed: `effort` is the rung to try (below the reference; one rung down when
|
|
85
|
+
// not said) and `intent` the kind of question to ask first. Six questions of one kind are enough to
|
|
86
|
+
// settle that kind; when the ledger has fewer, the rest are of other kinds and nothing is settled.
|
|
87
|
+
export function planRound({ kind = 'quick', cases = [], agent, model = null, reference, version = null, records = [], effort = null, intent = null } = {}) {
|
|
88
|
+
const ladder = effortLadder(agent);
|
|
89
|
+
if (!ladder || !ladder.includes(reference)) return [];
|
|
90
|
+
const mine = comparable(records, { agent, model, reference, version });
|
|
91
|
+
// What an untrusted judge saw may be asked again: nothing was learned from it.
|
|
92
|
+
const asked = new Set(mine.filter((record) => !record.untrusted).map((record) => `${record.caseId}|${record.effort}`));
|
|
93
|
+
const fresh = (one, effort) => !asked.has(`${one.caseId}|${effort}`);
|
|
94
|
+
|
|
95
|
+
if (kind === 'quick') {
|
|
96
|
+
const top = ladder.indexOf(reference);
|
|
97
|
+
const aimed = ladder.indexOf(effort);
|
|
98
|
+
const below = aimed >= 0 && aimed < top ? effort : ladder[top - 1];
|
|
99
|
+
if (!below) return [];
|
|
100
|
+
const pool = interleave(cases).filter((one) => fresh(one, below));
|
|
101
|
+
const first = intent ? pool.filter((one) => one.intent === intent) : [];
|
|
102
|
+
return [...first, ...pool.filter((one) => !first.includes(one))].slice(0, QUICK_CASES).map((one) => ({ ...one, effort: below, reference }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const evidence = foldEvidence(mine, { versions: version ? { [agent]: version } : {} });
|
|
106
|
+
const plan = [];
|
|
107
|
+
for (const intent of READ_ONLY_INTENTS) {
|
|
108
|
+
const cell = evidence.get(evidenceKey(agent, model, intent));
|
|
109
|
+
const effort = nextRung(cell, ladder, reference);
|
|
110
|
+
if (!effort) continue;
|
|
111
|
+
// Six more at a time, and never past the cap: the twentieth comparison is where an open rung
|
|
112
|
+
// is called not enough, so a twenty-fourth would be spent on a question already answered.
|
|
113
|
+
const room = Math.min(MIN_COMPARISONS, MAX_COMPARISONS - (cell?.rungs?.[effort]?.n ?? 0));
|
|
114
|
+
const pool = cases.filter((one) => one.intent === intent && fresh(one, effort));
|
|
115
|
+
// Questions already answered at the reference come first: their reference turn is paid.
|
|
116
|
+
const answered = new Set(mine.map((record) => record.caseId));
|
|
117
|
+
pool.sort((a, b) => Number(answered.has(b.caseId)) - Number(answered.has(a.caseId)));
|
|
118
|
+
plan.push(...pool.slice(0, room).map((one) => ({ ...one, effort, reference })));
|
|
119
|
+
}
|
|
120
|
+
return plan;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Records of one agent, model and reference that still describe the CLI installed now.
|
|
124
|
+
const comparable = (records, { agent, model, reference, version }) => records.filter((record) => record.agent === agent
|
|
125
|
+
&& (record.model ?? null) === (model ?? null)
|
|
126
|
+
&& record.reference === reference
|
|
127
|
+
&& !(version && record.version && record.version !== version));
|
|
128
|
+
|
|
129
|
+
// Where a calibration stands, per kind of question: the rung that was measured to be enough, or
|
|
130
|
+
// the rung it would test next. The totals are the human-sized reading of a quick test — "you did
|
|
131
|
+
// not notice a difference in six of six" — which never settles a kind of question by itself.
|
|
132
|
+
export function calibrationSummary(records = [], { agent, model = null, reference, version = null } = {}) {
|
|
133
|
+
const ladder = effortLadder(agent);
|
|
134
|
+
const mine = comparable(records, { agent, model, reference, version });
|
|
135
|
+
const evidence = foldEvidence(mine, { versions: version ? { [agent]: version } : {} });
|
|
136
|
+
const totals = { compared: 0, worse: 0, same: 0, better: 0, skipped: 0 };
|
|
137
|
+
for (const record of mine) {
|
|
138
|
+
if (record.control) continue;
|
|
139
|
+
if (record.skipped) totals.skipped += 1;
|
|
140
|
+
else if (totals[record.verdict] !== undefined) { totals.compared += 1; totals[record.verdict] += 1; }
|
|
141
|
+
}
|
|
142
|
+
const intents = READ_ONLY_INTENTS.map((intent) => {
|
|
143
|
+
const cell = evidence.get(evidenceKey(agent, model, intent));
|
|
144
|
+
const settled = lowestSufficient(cell, ladder);
|
|
145
|
+
const n = Object.values(cell?.rungs ?? {}).reduce((sum, rung) => sum + rung.n, 0);
|
|
146
|
+
return { intent, n, effort: settled?.effort ?? null, status: settled ? 'measured' : 'open', next: ladder ? nextRung(cell, ladder, reference) : null };
|
|
147
|
+
});
|
|
148
|
+
return { agent, model, reference, totals, intents, judge: judgeHealth(mine) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// What a plan will cost before it runs, in turns of the agent being measured: one per question and
|
|
152
|
+
// rung, plus one reference per question not yet answered in this run.
|
|
153
|
+
export function estimateTurns(plan = [], { answered = new Set() } = {}) {
|
|
154
|
+
const references = new Set(plan.map((one) => one.caseId).filter((id) => !answered.has(id)));
|
|
155
|
+
return new Set(plan.map((one) => `${one.caseId}|${one.effort}`)).size + references.size;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ---------- when ---------- */
|
|
159
|
+
|
|
160
|
+
// Never above the warning line: a test that leaves the agent unable to answer the human has cost
|
|
161
|
+
// more than it could ever save. The full test, unattended, waits for quota that would expire
|
|
162
|
+
// unused — a window close to its reset with plenty left. Unknown quota is never spent unseen.
|
|
163
|
+
export const EXPIRING_MS = 2 * 60 * 60 * 1000;
|
|
164
|
+
export const SPARE_PERCENT = 60;
|
|
165
|
+
|
|
166
|
+
export function mayCalibrate({ kind = 'quick', usage = null, byHuman = false, now = Date.now() } = {}) {
|
|
167
|
+
const used = Number(usage?.usedPercent);
|
|
168
|
+
const known = usage && Number.isFinite(used);
|
|
169
|
+
if (known && classifyUsagePercent(used) !== 'normal') return { ok: false, reason: 'quota' };
|
|
170
|
+
if (kind === 'quick' || byHuman) return { ok: true, reason: byHuman ? 'asked' : 'quick' };
|
|
171
|
+
if (!known) return { ok: false, reason: 'unknown-quota' };
|
|
172
|
+
const reset = Date.parse(usage.resetAt ?? '');
|
|
173
|
+
if (!Number.isFinite(reset) || reset - now > EXPIRING_MS || reset <= now) return { ok: false, reason: 'not-expiring' };
|
|
174
|
+
if (used > SPARE_PERCENT) return { ok: false, reason: 'no-spare' };
|
|
175
|
+
return { ok: true, reason: 'expiring' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ---------- blind ---------- */
|
|
179
|
+
|
|
180
|
+
// Which answer is shown first, decided by the case and not by the order the room asked them in:
|
|
181
|
+
// a judge that learns "the first one is the expensive one" is not judging anymore.
|
|
182
|
+
export function blindPair(candidate, reference, seed = '') {
|
|
183
|
+
const candidateFirst = createHash('sha256').update(String(seed)).digest()[0] % 2 === 0;
|
|
184
|
+
return candidateFirst
|
|
185
|
+
? { first: candidate, second: reference, candidateFirst }
|
|
186
|
+
: { first: reference, second: candidate, candidateFirst };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The judge's word, turned back into what it says about the cheaper rung.
|
|
190
|
+
export function unblind(choice, candidateFirst) {
|
|
191
|
+
if (choice === 'same') return 'same';
|
|
192
|
+
if (choice === 'first') return candidateFirst ? 'better' : 'worse';
|
|
193
|
+
if (choice === 'second') return candidateFirst ? 'worse' : 'better';
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The question to an impartial judge: no names, no rungs, no order of authority, and a bar that is
|
|
198
|
+
// about the person asking rather than about which answer is longer.
|
|
199
|
+
export function judgePrompt({ question, first, second }) {
|
|
200
|
+
return [
|
|
201
|
+
'You are a reviewer. You are given a question someone asked about their software project, and two answers to it. Nothing else is known about either answer, and neither is more likely to be right because of where it appears.',
|
|
202
|
+
'Decide whether one of them leaves out, or gets wrong, something that matters to the person asking.',
|
|
203
|
+
'Length, tone and formatting do not count. Two answers that would serve the person equally well are the SAME, however differently they are written.',
|
|
204
|
+
'Reply with exactly one word and nothing else: FIRST if the first answer serves them better, SECOND if the second does, SAME if neither does.',
|
|
205
|
+
'<question>', String(question ?? ''), '</question>',
|
|
206
|
+
'<first>', String(first ?? ''), '</first>',
|
|
207
|
+
'<second>', String(second ?? ''), '</second>',
|
|
208
|
+
].join('\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Only one clear word survives. A judge that names both, or neither, has not judged.
|
|
212
|
+
export function parseJudgement(text) {
|
|
213
|
+
const words = (line) => [...new Set((String(line).toUpperCase().match(/\b(FIRST|SECOND|SAME)\b/g) ?? []))];
|
|
214
|
+
const all = words(text);
|
|
215
|
+
if (all.length === 1) return all[0].toLowerCase();
|
|
216
|
+
const last = String(text ?? '').trim().split('\n').filter((line) => line.trim()).at(-1) ?? '';
|
|
217
|
+
const final = words(last);
|
|
218
|
+
return final.length === 1 ? final[0].toLowerCase() : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Who may judge an agent's answers: anyone but that agent, in EYECAT's order (the local model
|
|
222
|
+
// first, because it is free and owes nothing to any account in the room).
|
|
223
|
+
export const judgeForAgent = (agent, options = {}) => judgeFor({ claim: { agent }, against: { agent } }, options);
|
|
224
|
+
|
|
225
|
+
/* ---------- the judge's own test ---------- */
|
|
226
|
+
|
|
227
|
+
// A judge that cannot see a difference is the one failure here that costs quality instead of
|
|
228
|
+
// money: it calls every cheaper rung enough, and the room starts suggesting turns that answer
|
|
229
|
+
// worse. A judge that prefers longer answers only errs the other way — it finds fewer savings —
|
|
230
|
+
// so it is tolerated. A blind one is not, and blindness is tested the way exam.mjs tests recall:
|
|
231
|
+
// against a control whose right answer is known.
|
|
232
|
+
//
|
|
233
|
+
// The control costs no turn of the agent being measured. It is the reference answer the room
|
|
234
|
+
// already paid for, next to a copy cut to its first third. A judge that calls those two the same
|
|
235
|
+
// cannot be trusted with the subtle cases, and nothing it judged in that run is kept as evidence.
|
|
236
|
+
export const CONTROL_MIN = 3; // checked first, before the judge's word is worth anything
|
|
237
|
+
export const CONTROL_EVERY = 3; // then one comparison in three is followed by a control
|
|
238
|
+
export const CONTROL_BAR = 0.5; // it must catch the cut more often than not, with confidence
|
|
239
|
+
|
|
240
|
+
export function degrade(text) {
|
|
241
|
+
const whole = String(text ?? '').trim();
|
|
242
|
+
const cut = Math.max(1, Math.floor(whole.length / 3));
|
|
243
|
+
const head = whole.slice(0, cut);
|
|
244
|
+
const space = head.lastIndexOf(' ');
|
|
245
|
+
return (space > cut / 2 ? head.slice(0, space) : head).trim();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// trusted — it caught the cut often enough to say so; blind — it clearly does not see it, and the
|
|
249
|
+
// run stops there; unknown — not enough controls yet to say either.
|
|
250
|
+
export function judgeHealth(records = [], { run = null } = {}) {
|
|
251
|
+
const controls = records.filter((record) => record.control && record.verdict && (!run || record.run === run));
|
|
252
|
+
const caught = controls.filter((record) => record.verdict === 'worse').length;
|
|
253
|
+
const n = controls.length;
|
|
254
|
+
const status = n >= CONTROL_MIN && wilsonLower(caught, n) >= CONTROL_BAR ? 'trusted'
|
|
255
|
+
: n >= CONTROL_MIN && wilsonUpper(caught, n) < CONTROL_BAR ? 'blind'
|
|
256
|
+
: 'unknown';
|
|
257
|
+
return { controls: n, caught, status };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ---------- run ---------- */
|
|
261
|
+
|
|
262
|
+
// `ask({ question, agent, model, effort })` → the adapter's result ({ text, usage, error }).
|
|
263
|
+
// `judge` → { id, decide({ question, first, second, caseId, intent }) } returning 'first',
|
|
264
|
+
// 'second', 'same' or null. For the quick test the human is the judge and `decide` waits for them;
|
|
265
|
+
// a person is not put through controls. An agent judging is, unless `controls` says otherwise.
|
|
266
|
+
//
|
|
267
|
+
// What comes back is every record the run kept: comparisons, skips and controls. A turn that
|
|
268
|
+
// failed is a skip, never a loss: a rate limit says nothing about how well a rung thinks. If the
|
|
269
|
+
// judge was not shown to see, its comparisons come back marked `untrusted` and teach nothing.
|
|
270
|
+
// `turns` counts the agent being measured, against `maxTurns`; `judged` counts the judge.
|
|
271
|
+
export async function runCalibration({ kind = 'quick', cases = [], agent, model = null, reference, version = null, records = [], effort = null, intent = null, ask, judge, controls = judge?.id !== 'you', maxTurns = kind === 'quick' ? QUICK_CASES * 2 : FULL_MAX_TURNS, stop = () => false, onProgress = () => {}, now = () => new Date().toISOString(), run = randomUUID() } = {}) {
|
|
272
|
+
let kept = [];
|
|
273
|
+
const answers = new Map();
|
|
274
|
+
let turns = 0;
|
|
275
|
+
let judged = 0;
|
|
276
|
+
let compared = 0;
|
|
277
|
+
|
|
278
|
+
const answer = async (one, effort) => {
|
|
279
|
+
const key = `${one.caseId}|${effort}`;
|
|
280
|
+
if (answers.has(key)) return answers.get(key);
|
|
281
|
+
turns += 1;
|
|
282
|
+
let result = null;
|
|
283
|
+
try { result = await ask({ question: one.question, agent, model, effort }); } catch (error) { result = { error: error.message }; }
|
|
284
|
+
const text = String(result?.text ?? '').trim();
|
|
285
|
+
// Why a turn failed is kept, in one short line: a measurement that fails in silence is tokens
|
|
286
|
+
// spent with nothing to show and nothing to fix.
|
|
287
|
+
const why = result?.error ? String(result.error).split('\n')[0].trim().slice(0, 160) : !text ? 'the agent answered nothing' : null;
|
|
288
|
+
const value = { text, tokens: result?.usage ? budgetTokens(result.usage) : null, failed: Boolean(why), why };
|
|
289
|
+
answers.set(key, value);
|
|
290
|
+
return value;
|
|
291
|
+
};
|
|
292
|
+
const cost = (one) => Number(!answers.has(`${one.caseId}|${one.effort}`)) + Number(!answers.has(`${one.caseId}|${reference}`));
|
|
293
|
+
// The judge's word on one blinded pair, turned back into what it says about `candidate`.
|
|
294
|
+
const decide = async (one, candidate, usual, seed) => {
|
|
295
|
+
const pair = blindPair(candidate, usual, seed);
|
|
296
|
+
let choice = null;
|
|
297
|
+
judged += 1;
|
|
298
|
+
try { choice = await judge.decide({ question: one.question, first: pair.first, second: pair.second, caseId: one.caseId, intent: one.intent }); } catch { choice = null; }
|
|
299
|
+
return unblind(choice, pair.candidateFirst);
|
|
300
|
+
};
|
|
301
|
+
const finish = (ended) => {
|
|
302
|
+
const health = controls ? judgeHealth(kept, { run }) : null;
|
|
303
|
+
if (health && health.status !== 'trusted') kept = kept.map((record) => (record.control || record.skipped ? record : { ...record, untrusted: true }));
|
|
304
|
+
onProgress({ done: true, turns });
|
|
305
|
+
return { run, records: kept, turns, judged, judge: health, ended };
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
for (let round = 0; round < MAX_ROUNDS; round += 1) {
|
|
309
|
+
if (kind === 'quick' && round > 0) break;
|
|
310
|
+
const plan = planRound({ kind, cases, agent, model, reference, version, records: [...records, ...kept], effort, intent });
|
|
311
|
+
if (!plan.length) break;
|
|
312
|
+
for (const [index, one] of plan.entries()) {
|
|
313
|
+
if (stop()) return finish('stopped');
|
|
314
|
+
if (turns + cost(one) > maxTurns) return finish('budget');
|
|
315
|
+
onProgress({ round, done: index, total: plan.length, turns, intent: one.intent });
|
|
316
|
+
const base = { at: now(), run, kind, agent, model, version, intent: one.intent, caseId: one.caseId, effort: one.effort, reference };
|
|
317
|
+
const usual = await answer(one, reference);
|
|
318
|
+
const cheaper = await answer(one, one.effort);
|
|
319
|
+
if (usual.failed || cheaper.failed) {
|
|
320
|
+
const at = usual.failed ? reference : one.effort;
|
|
321
|
+
kept.push({ ...base, skipped: 'failed', failedAt: at, why: (usual.failed ? usual.why : cheaper.why) ?? null, tokens: { effort: cheaper.tokens, reference: usual.tokens } });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const verdict = await decide(one, cheaper.text, usual.text, `${run}|${one.caseId}|${one.effort}`);
|
|
325
|
+
if (!verdict) { kept.push({ ...base, skipped: 'unjudged' }); continue; }
|
|
326
|
+
kept.push({ ...base, verdict, judge: judge.id, tokens: { effort: cheaper.tokens, reference: usual.tokens } });
|
|
327
|
+
compared += 1;
|
|
328
|
+
|
|
329
|
+
if (!controls) continue;
|
|
330
|
+
const checked = kept.filter((record) => record.control).length;
|
|
331
|
+
if (checked >= CONTROL_MIN && compared % CONTROL_EVERY !== 0) continue;
|
|
332
|
+
const caught = await decide(one, degrade(usual.text), usual.text, `${run}|${one.caseId}|control`);
|
|
333
|
+
kept.push({ ...base, effort: null, control: true, judge: judge.id, ...(caught ? { verdict: caught } : { skipped: 'unjudged' }) });
|
|
334
|
+
if (judgeHealth(kept, { run }).status === 'blind') return finish('judge-blind');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return finish('done');
|
|
338
|
+
}
|