@jossuealcala/madre 0.7.2 → 1.0.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 +817 -1
- package/CONTRIBUTING.md +5 -1
- package/NOTICE +20 -1
- package/README.md +99 -34
- package/TRADEMARKS.md +65 -0
- package/bin/madre.mjs +30 -10
- package/docs/INTERNALS.md +2 -2
- package/docs/REFERENCE.md +159 -82
- package/docs/SDK.md +23 -19
- package/docs/madre-banner.png +0 -0
- package/package.json +4 -3
- package/public/app.js +11668 -4219
- 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 +3443 -1425
- 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 +1517 -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 +1281 -0
- package/public/salas-mac.html +158 -0
- package/public/salas-mac.js +4593 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6372 -426
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +452 -0
- package/public/tour.js +546 -0
- package/public/troubleshooting.js +160 -141
- 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 +31 -8
- 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/credentials.mjs +80 -0
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +6 -11
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +31 -12
- package/src/file-ops.mjs +23 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/heartbeat.mjs +55 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/local-ai.mjs +48 -0
- package/src/looks.mjs +88 -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 +127 -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 +18 -6
- 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 +36 -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 +3 -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 +411 -84
- 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 +1864 -178
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +174 -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/docs/madre-banner.svg +0 -25
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
// The desk behind Intenciones: what this project has measured, what is being measured now, and
|
|
2
|
+
// what that says about the message being written.
|
|
3
|
+
//
|
|
4
|
+
// One desk per project, because what an agent needs is a fact about this project's work and not
|
|
5
|
+
// about one conversation. Its measurements live next to the project's room, in intents.jsonl, and
|
|
6
|
+
// never in a conversation's ledger: a calibration is not something anybody said.
|
|
7
|
+
//
|
|
8
|
+
// It spends turns only when the human presses a button, and only through the same door the
|
|
9
|
+
// archivist uses: the agent's own CLI, read-only, with no lease and no tools beyond its own. The
|
|
10
|
+
// quick test waits for the human's word on each pair; the full one asks an impartial agent and
|
|
11
|
+
// tests that agent before believing it (intents-calibration.mjs).
|
|
12
|
+
//
|
|
13
|
+
// Most of what it knows it learns for free: every turn the human already sent, read after the fact
|
|
14
|
+
// (intents-observe.mjs). That reading lives in memory only; it is the ledger, looked at again.
|
|
15
|
+
|
|
16
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
import { t } from '../i18n.mjs';
|
|
19
|
+
import { classifyUsagePercent } from '../usage-sentinel.mjs';
|
|
20
|
+
import {
|
|
21
|
+
classifyIntent, effortLadder, evidenceKey, foldEvidence, INTENT_TIER, invitable, lowestSufficient, offerable,
|
|
22
|
+
rungStatus, suggestEffort, threadContext, OBSERVED_MIN_TURNS, TIER_EFFORT,
|
|
23
|
+
} from './intents-policy.mjs';
|
|
24
|
+
import { foldObserved, observable, observedSummary, observeTurns } from './intents-observe.mjs';
|
|
25
|
+
import { defaultHeld, knownDefault, triedOutcome, tryCooldown, tryRung, TRY_EVERY, TRY_INTENTS, TRY_MODES } from './intents-try.mjs';
|
|
26
|
+
import {
|
|
27
|
+
calibrationCases, calibrationSummary, estimateTurns, judgeForAgent, judgePrompt, mayCalibrate,
|
|
28
|
+
parseJudgement, planRound, runCalibration, usualEffort, QUICK_CASES, READ_ONLY_INTENTS,
|
|
29
|
+
} from './intents-calibration.mjs';
|
|
30
|
+
|
|
31
|
+
// A calibration turn is a real turn of the CLI, reading the project as it would for the human.
|
|
32
|
+
export const ASK_TIMEOUT_MS = 300000;
|
|
33
|
+
const CHOICES = new Set(['first', 'second', 'same']);
|
|
34
|
+
|
|
35
|
+
// What the provider last said about this agent's window, as the room heard it.
|
|
36
|
+
export function usageOf(events = [], agent) {
|
|
37
|
+
let last = null;
|
|
38
|
+
for (const event of events) if (event?.type === 'quota.updated' && event.payload?.agent === agent) last = event.payload;
|
|
39
|
+
return last ? { usedPercent: last.usedPercent, resetAt: last.resetAt ?? null } : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const versionsOf = (agents = []) => Object.fromEntries(agents.filter((agent) => agent.version).map((agent) => [agent.id, agent.version]));
|
|
43
|
+
|
|
44
|
+
export class IntentsDesk {
|
|
45
|
+
#file;
|
|
46
|
+
#offersFile;
|
|
47
|
+
#events;
|
|
48
|
+
#projectEvents;
|
|
49
|
+
#project = null;
|
|
50
|
+
#ledgers;
|
|
51
|
+
#defaults;
|
|
52
|
+
#observed = null;
|
|
53
|
+
#bench;
|
|
54
|
+
#projectRoot;
|
|
55
|
+
#records = null;
|
|
56
|
+
#offers = null;
|
|
57
|
+
#run = null;
|
|
58
|
+
#last = null;
|
|
59
|
+
#pending = null;
|
|
60
|
+
#controller = null;
|
|
61
|
+
#stopping = false;
|
|
62
|
+
#running = null;
|
|
63
|
+
// Where the run stands, for the pair the human is shown: which question of how many, and its kind.
|
|
64
|
+
#pendingAt = { step: 0, total: 0, intent: null };
|
|
65
|
+
// Trying a cheaper rung on one message (intents-try.mjs), kept in memory per agent, model and kind:
|
|
66
|
+
// how many messages that qualified for a try have gone out, and the last try the human took.
|
|
67
|
+
#tryPace = new Map();
|
|
68
|
+
#tried = new Map();
|
|
69
|
+
#clock;
|
|
70
|
+
|
|
71
|
+
// `events()` is the open conversation's ledger: the thread a suggestion reads and the quota the
|
|
72
|
+
// provider reported. `projectEvents()` is what was said in every conversation of the project,
|
|
73
|
+
// where the questions to calibrate with come from. `bench()` is room.bench(): the agents, their
|
|
74
|
+
// invokers and who is busy. Everything else is files under the project's room.
|
|
75
|
+
// `ledgers()` is every conversation's turns, cut down by observable(), for learning from real use;
|
|
76
|
+
// without it the open conversation is all there is. `defaults()` is the effort each CLI runs at
|
|
77
|
+
// when it is told none, where MADRE knows it ({ codex: 'medium' }).
|
|
78
|
+
// `clock()` is now, in ms; a test hands in its own.
|
|
79
|
+
constructor({ file, offersFile, events, projectEvents = null, ledgers = null, defaults = null, bench, projectRoot, clock = () => Date.now() }) {
|
|
80
|
+
this.#clock = clock;
|
|
81
|
+
this.#file = file;
|
|
82
|
+
this.#offersFile = offersFile;
|
|
83
|
+
this.#events = events;
|
|
84
|
+
this.#projectEvents = projectEvents ?? events;
|
|
85
|
+
this.#ledgers = ledgers ?? (async () => (await events()).map((event) => observable(event)).filter(Boolean));
|
|
86
|
+
this.#defaults = defaults ?? (async () => ({}));
|
|
87
|
+
this.#bench = bench;
|
|
88
|
+
this.#projectRoot = projectRoot;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Every conversation of the project, read at most every few seconds: the composer asks while the
|
|
92
|
+
// human types, and the questions in a project do not change between two keystrokes.
|
|
93
|
+
static PROJECT_TTL_MS = 20000;
|
|
94
|
+
async #said() {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
if (this.#project && now - this.#project.at < IntentsDesk.PROJECT_TTL_MS) return this.#project.events;
|
|
97
|
+
const events = await this.#projectEvents();
|
|
98
|
+
this.#project = { at: now, events };
|
|
99
|
+
return events;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Every turn of the project, as real use judged it. Read at most every few seconds, like the
|
|
103
|
+
// questions, and worked out again only when the ledger grew or a turn that was too recent to say
|
|
104
|
+
// has become old enough to.
|
|
105
|
+
async observations() {
|
|
106
|
+
const now = this.#clock();
|
|
107
|
+
const kept = this.#observed;
|
|
108
|
+
if (kept && now - kept.at < IntentsDesk.PROJECT_TTL_MS && now < kept.until) return kept.list;
|
|
109
|
+
const [events, defaults] = await Promise.all([this.#ledgers(), this.#defaults().catch(() => ({}))]);
|
|
110
|
+
const signature = `${events.length}|${events.at(-1)?.chat ?? ''}|${events.at(-1)?.sequence ?? ''}|${JSON.stringify(defaults ?? {})}`;
|
|
111
|
+
if (kept && kept.signature === signature && now < kept.until) {
|
|
112
|
+
kept.at = now;
|
|
113
|
+
return kept.list;
|
|
114
|
+
}
|
|
115
|
+
const list = observeTurns(events, { defaults: defaults ?? {}, now });
|
|
116
|
+
const until = list.reduce((soonest, one) => (one.settles ? Math.min(soonest, one.settles) : soonest), Infinity);
|
|
117
|
+
this.#observed = { at: now, signature, until, list };
|
|
118
|
+
return list;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async records() {
|
|
122
|
+
if (this.#records) return this.#records;
|
|
123
|
+
const text = await readFile(this.#file, 'utf8').catch(() => '');
|
|
124
|
+
this.#records = text.split('\n').filter(Boolean).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
|
|
125
|
+
return this.#records;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async #keep(records) {
|
|
129
|
+
if (!records.length) return;
|
|
130
|
+
await this.records();
|
|
131
|
+
await mkdir(dirname(this.#file), { recursive: true });
|
|
132
|
+
await appendFile(this.#file, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`);
|
|
133
|
+
this.#records.push(...records);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async offers() {
|
|
137
|
+
if (this.#offers) return this.#offers;
|
|
138
|
+
let kept = {};
|
|
139
|
+
try { kept = JSON.parse(await readFile(this.#offersFile, 'utf8')) ?? {}; } catch { kept = {}; }
|
|
140
|
+
this.#offers = { shown: 0, accepted: 0, ...kept, tries: { shown: 0, accepted: 0, ...(kept.tries ?? {}) } };
|
|
141
|
+
return this.#offers;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A message went out. Counted once per message, never per keystroke: an offer shown with it, and
|
|
145
|
+
// whether the human took it — how often they do is the only number that says whether this is
|
|
146
|
+
// worth anything. A try is an offer too, and is also counted on its own; taken, it is the try the
|
|
147
|
+
// next messages of its kind are read against. `explore` names the kind when the message qualified
|
|
148
|
+
// for a try, shown or not: that is what paces them. `basis: 'tried'` is the word that a try fell
|
|
149
|
+
// short, said once.
|
|
150
|
+
async offered({ accepted = false, shown = true, kind = null, basis = null, agent = null, model = null, intent = null, effort = null, from = null, explore = null } = {}) {
|
|
151
|
+
const offers = await this.offers();
|
|
152
|
+
const ladder = effortLadder(agent);
|
|
153
|
+
if (ladder && intent) {
|
|
154
|
+
const key = evidenceKey(agent, model, intent);
|
|
155
|
+
if (kind === 'try' && accepted && ladder.includes(effort)) this.#tried.set(key, { agent, model, intent, effort, from: ladder.includes(from) ? from : null, at: this.#clock() });
|
|
156
|
+
else if (basis === 'tried') this.#tried.delete(key);
|
|
157
|
+
}
|
|
158
|
+
if (explore && effortLadder(explore.agent) && TRY_INTENTS.includes(explore.intent)) {
|
|
159
|
+
const key = evidenceKey(explore.agent, explore.model ?? null, explore.intent);
|
|
160
|
+
this.#tryPace.set(key, (this.#tryPace.get(key) ?? 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
if (!shown) return offers;
|
|
163
|
+
offers.shown += 1;
|
|
164
|
+
if (accepted) offers.accepted += 1;
|
|
165
|
+
if (kind === 'try') {
|
|
166
|
+
offers.tries.shown += 1;
|
|
167
|
+
if (accepted) offers.tries.accepted += 1;
|
|
168
|
+
}
|
|
169
|
+
await mkdir(dirname(this.#offersFile), { recursive: true });
|
|
170
|
+
await writeFile(this.#offersFile, JSON.stringify(offers));
|
|
171
|
+
return offers;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// What to say about the message being written, or null.
|
|
175
|
+
async suggest(asked = {}) {
|
|
176
|
+
return (await this.consider(asked)).suggestion;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// What to say about the message being written, and whether it qualifies for a try. In order:
|
|
180
|
+
// a measured offer — the strongest word there is;
|
|
181
|
+
// going back up, once, after a try fell short;
|
|
182
|
+
// any other offer offerable() lets through — something real use saw, or a rule that asks for more;
|
|
183
|
+
// a try — a rule that says one rung less would do, run on this one message so real use can see;
|
|
184
|
+
// an invitation — a rule that says less would do, on an agent nobody has measured here yet: not
|
|
185
|
+
// advice, a question worth six answers.
|
|
186
|
+
// `mode` is the permission mode the message would go out in. `explore` is set whenever the message
|
|
187
|
+
// qualifies for a try, offered or held back by the pace, so the message that leaves can be counted.
|
|
188
|
+
async consider({ text = '', agent, model = null, effort = null, defaultEffort = null, references = 0, attachments = 0, mode = null } = {}) {
|
|
189
|
+
const none = { suggestion: null, explore: null };
|
|
190
|
+
if (!effortLadder(agent)) return none;
|
|
191
|
+
const events = await this.#events();
|
|
192
|
+
const usage = usageOf(events, agent);
|
|
193
|
+
const { agents = [] } = this.#bench() ?? {};
|
|
194
|
+
const records = await this.records();
|
|
195
|
+
const observations = await this.observations();
|
|
196
|
+
const evidence = foldEvidence(records, { versions: versionsOf(agents) });
|
|
197
|
+
const observed = foldObserved(observations);
|
|
198
|
+
const quota = usage ? { level: classifyUsagePercent(usage.usedPercent) } : null;
|
|
199
|
+
const previous = threadContext(events, { agent });
|
|
200
|
+
const asked = { text, agent, model, effort, defaultEffort, references, attachments, previous, quota };
|
|
201
|
+
const suggestion = suggestEffort({ ...asked, evidence, observed });
|
|
202
|
+
if (offerable(suggestion) && suggestion.basis === 'measured') return { suggestion: { ...suggestion, kind: 'offer' }, explore: null };
|
|
203
|
+
const read = classifyIntent(text, { previous });
|
|
204
|
+
const back = this.#goBack({ read, agent, model, from: effort ?? defaultEffort ?? null, observations });
|
|
205
|
+
if (back) return { suggestion: back, explore: null };
|
|
206
|
+
if (offerable(suggestion)) return { suggestion: { ...suggestion, kind: 'offer' }, explore: null };
|
|
207
|
+
const trial = this.#trial({ read, asked, mode, evidence, observed, observations });
|
|
208
|
+
if (trial.suggestion) return trial;
|
|
209
|
+
const invite = await this.#invite(suggestion, { agent, model, agents, records });
|
|
210
|
+
return { suggestion: invite, explore: trial.explore };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// After a try the human took fell short — asked again, a thumbs-down, a failure, sent again
|
|
214
|
+
// higher — the next message of that kind to that agent offers to go back to where it was. Once:
|
|
215
|
+
// it is put away when that message leaves, and a try that held is simply forgotten.
|
|
216
|
+
#goBack({ read, agent, model, from, observations }) {
|
|
217
|
+
const intent = read?.intent;
|
|
218
|
+
if (!TRY_INTENTS.includes(intent)) return null;
|
|
219
|
+
const key = evidenceKey(agent, model, intent);
|
|
220
|
+
const tried = this.#tried.get(key);
|
|
221
|
+
if (!tried) return null;
|
|
222
|
+
const one = triedOutcome(observations, tried);
|
|
223
|
+
if (!one || one.outcome === 'unknown') return null;
|
|
224
|
+
const ladder = effortLadder(agent);
|
|
225
|
+
const to = tried.from ?? from;
|
|
226
|
+
if (one.outcome === 'held' || (from !== null && to !== null && ladder.indexOf(from) > ladder.indexOf(to))) {
|
|
227
|
+
this.#tried.delete(key);
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
kind: 'offer', agent, model, intent, cues: read.cues, effort: to, from,
|
|
232
|
+
direction: 'raise', basis: 'tried', signals: [], measured: null, observed: null, saving: null,
|
|
233
|
+
tried: { effort: tried.effort, why: one.why },
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Whether this message qualifies for a try, and the try when the pace lets one through. Every rule
|
|
238
|
+
// is in intents-try.mjs; what is checked here is the order they are read in.
|
|
239
|
+
#trial({ read, asked, mode, evidence, observed, observations }) {
|
|
240
|
+
const none = { suggestion: null, explore: null };
|
|
241
|
+
const { agent, model, effort, defaultEffort, quota } = asked;
|
|
242
|
+
const intent = read?.intent;
|
|
243
|
+
if (!TRY_INTENTS.includes(intent) || !TRY_MODES.includes(mode)) return none;
|
|
244
|
+
if (['critical', 'exhausted'].includes(quota?.level)) return none;
|
|
245
|
+
const ladder = effortLadder(agent);
|
|
246
|
+
const key = evidenceKey(agent, model, intent);
|
|
247
|
+
// A measurement for this kind wins over a try, as it does over everything else.
|
|
248
|
+
const cell = evidence.get(key) ?? null;
|
|
249
|
+
if (lowestSufficient(cell, ladder)) return none;
|
|
250
|
+
// What the rule alone says, with nothing measured or seen: a try is the rule's word, put to work.
|
|
251
|
+
const rule = suggestEffort({ ...asked, evidence: null, observed: null });
|
|
252
|
+
if (!rule || rule.basis !== 'rule') return none;
|
|
253
|
+
const from = effort ?? defaultEffort ?? null;
|
|
254
|
+
const seen = observed.get(key) ?? null;
|
|
255
|
+
const lower = tryRung({ agent, intent, from, rule: rule.effort, defaultHeld: from === null && defaultHeld(seen) });
|
|
256
|
+
if (!lower || rungStatus(cell?.rungs?.[lower]) === 'insufficient') return none;
|
|
257
|
+
// Once real use has seen enough turns at the cheaper rung, it speaks for itself.
|
|
258
|
+
const n = seen?.rungs?.[lower]?.n ?? 0;
|
|
259
|
+
if (n >= OBSERVED_MIN_TURNS) return none;
|
|
260
|
+
const until = tryCooldown(observations, { agent, model, intent, effort: lower });
|
|
261
|
+
if (until !== null && this.#clock() < until) return none;
|
|
262
|
+
const explore = { agent, model, intent, effort: lower };
|
|
263
|
+
if ((this.#tryPace.get(key) ?? 0) % TRY_EVERY !== 0) return { suggestion: null, explore };
|
|
264
|
+
return {
|
|
265
|
+
suggestion: {
|
|
266
|
+
kind: 'try', agent, model, intent, cues: rule.cues, effort: lower, from,
|
|
267
|
+
direction: from === null ? null : 'lower', basis: 'try', signals: rule.signals,
|
|
268
|
+
observedAtLower: n, needs: OBSERVED_MIN_TURNS,
|
|
269
|
+
},
|
|
270
|
+
explore,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async #invite(suggestion, { agent, model, agents, records }) {
|
|
275
|
+
if (!invitable(suggestion) || this.#run) return null;
|
|
276
|
+
const subject = agents.find((one) => one.id === agent);
|
|
277
|
+
if (!subject?.ready) return null;
|
|
278
|
+
// Once this agent and model have been measured under the CLI installed now, the card is where
|
|
279
|
+
// to measure more. A new version of the CLI is a new program, and the invitation comes back.
|
|
280
|
+
const version = subject.version ?? null;
|
|
281
|
+
if (records.some((record) => record.agent === agent && (record.model ?? null) === (model ?? null) && !(version && record.version && record.version !== version))) return null;
|
|
282
|
+
const plan = planRound({ kind: 'quick', cases: calibrationCases(await this.#said()), agent, model, reference: suggestion.from, effort: suggestion.effort, intent: suggestion.intent, version, records });
|
|
283
|
+
if (!plan.length) return null;
|
|
284
|
+
return { ...suggestion, kind: 'invite', questions: plan.length, ofKind: plan.filter((one) => one.intent === suggestion.intent).length };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The rung a cheaper one has to match: what the human usually asks this agent for, or the deep
|
|
288
|
+
// tier when the ledger has never said.
|
|
289
|
+
#reference(events, agent) {
|
|
290
|
+
return usualEffort(events, agent) ?? TIER_EFFORT.deep;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Where trying a cheaper rung stands for one agent and model, per kind a try may touch: the rung
|
|
294
|
+
// a try would run at for a message of that kind with nothing else in it, how many decided turns
|
|
295
|
+
// real use has at it, and whether tries are paused there. `from` is the rung messages go out at.
|
|
296
|
+
#triesFor({ agent, model, from, evidence, observed, observations }) {
|
|
297
|
+
const ladder = effortLadder(agent);
|
|
298
|
+
const now = this.#clock();
|
|
299
|
+
const out = [];
|
|
300
|
+
for (const intent of TRY_INTENTS) {
|
|
301
|
+
const key = evidenceKey(agent, model, intent);
|
|
302
|
+
if (lowestSufficient(evidence.get(key), ladder)) continue;
|
|
303
|
+
const seen = observed.get(key) ?? null;
|
|
304
|
+
const effort = tryRung({ agent, intent, from, rule: TIER_EFFORT[INTENT_TIER[intent]], defaultHeld: from === null && defaultHeld(seen) });
|
|
305
|
+
if (!effort) continue;
|
|
306
|
+
const n = seen?.rungs?.[effort]?.n ?? 0;
|
|
307
|
+
const until = tryCooldown(observations, { agent, model, intent, effort });
|
|
308
|
+
const paused = until !== null && now < until;
|
|
309
|
+
const status = paused ? 'paused' : n >= OBSERVED_MIN_TURNS ? 'settled' : n > 0 || this.#tried.has(key) ? 'trying' : 'ready';
|
|
310
|
+
out.push({ intent, effort, n, needs: OBSERVED_MIN_TURNS, status, cooldownUntil: paused ? new Date(until).toISOString() : null });
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Everything the card shows. `models` is what the composer has chosen per agent: evidence is
|
|
316
|
+
// per model, so the card reads the one the human would actually be sending to. `efforts` is the
|
|
317
|
+
// rung each agent's messages go out at, when the composer knows it.
|
|
318
|
+
async view({ models = {}, efforts = {} } = {}) {
|
|
319
|
+
const events = await this.#said();
|
|
320
|
+
const records = await this.records();
|
|
321
|
+
const { agents = [] } = this.#bench() ?? {};
|
|
322
|
+
const cases = calibrationCases(events);
|
|
323
|
+
const crew = agents.filter((agent) => agent.detected && effortLadder(agent.id));
|
|
324
|
+
const observations = await this.observations();
|
|
325
|
+
const evidence = foldEvidence(records, { versions: versionsOf(agents) });
|
|
326
|
+
const observed = foldObserved(observations);
|
|
327
|
+
const defaults = await this.#defaults().catch(() => ({}));
|
|
328
|
+
return {
|
|
329
|
+
agents: crew.map((agent) => {
|
|
330
|
+
const model = models[agent.id] ?? null;
|
|
331
|
+
const reference = this.#reference(events, agent.id);
|
|
332
|
+
const spec = { agent: agent.id, model, reference, version: agent.version ?? null };
|
|
333
|
+
const full = planRound({ kind: 'full', cases, records, ...spec });
|
|
334
|
+
const quick = planRound({ kind: 'quick', cases, records, ...spec });
|
|
335
|
+
const mine = records.filter((record) => record.agent === agent.id && (record.model ?? null) === model);
|
|
336
|
+
const spent = mine.reduce((sum, record) => sum + (record.tokens?.effort ?? 0) + (record.tokens?.reference ?? 0), 0);
|
|
337
|
+
// What one answer has cost here, so a test says its price before it runs; and the tests that
|
|
338
|
+
// failed, with the last reason, so a failure is something to fix, not a blank.
|
|
339
|
+
const perAnswer = mine.flatMap((record) => [record.tokens?.effort, record.tokens?.reference]).filter((n) => Number.isFinite(n) && n > 0).sort((a, b) => a - b);
|
|
340
|
+
const typical = perAnswer.length ? perAnswer[Math.floor(perAnswer.length / 2)] : null;
|
|
341
|
+
const failed = mine.filter((record) => record.skipped === 'failed');
|
|
342
|
+
const lastFailed = failed.at(-1) ?? null;
|
|
343
|
+
return {
|
|
344
|
+
id: agent.id,
|
|
345
|
+
label: agent.label ?? agent.id,
|
|
346
|
+
version: agent.version ?? null,
|
|
347
|
+
model,
|
|
348
|
+
reference,
|
|
349
|
+
ladder: effortLadder(agent.id),
|
|
350
|
+
ready: Boolean(agent.ready),
|
|
351
|
+
summary: calibrationSummary(records, spec),
|
|
352
|
+
cases: Object.fromEntries(READ_ONLY_INTENTS.map((intent) => [intent, cases.filter((one) => one.intent === intent).length])),
|
|
353
|
+
estimate: { quick: estimateTurns(quick), full: estimateTurns(full), perAnswer: typical },
|
|
354
|
+
spent,
|
|
355
|
+
failures: failed.length ? { n: failed.length, of: mine.length, why: lastFailed?.why ?? null, at: lastFailed?.at ?? null } : null,
|
|
356
|
+
// What the turns the human already sent taught, at no cost: the lead of the card.
|
|
357
|
+
observed: observedSummary(observations, { agent: agent.id, model }),
|
|
358
|
+
// Trying a cheaper rung on one message, per kind: how far real use got at it, or the pause.
|
|
359
|
+
tries: this.#triesFor({
|
|
360
|
+
agent: agent.id, model, evidence, observed, observations,
|
|
361
|
+
from: effortLadder(agent.id).includes(efforts[agent.id]) ? efforts[agent.id] : knownDefault(defaults ?? {}, agent.id, model),
|
|
362
|
+
}),
|
|
363
|
+
};
|
|
364
|
+
}),
|
|
365
|
+
running: this.#run ? { ...this.#run } : null,
|
|
366
|
+
// The pair on the table, with nothing in it that says which answer came from which rung.
|
|
367
|
+
pending: this.#pending ? { step: this.#pending.step, total: this.#pending.total, intent: this.#pending.intent, question: this.#pending.question, first: this.#pending.first, second: this.#pending.second } : null,
|
|
368
|
+
last: this.#last,
|
|
369
|
+
offers: await this.offers(),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Starts a test in the background and answers at once. Refused, with the reason, when another
|
|
374
|
+
// is running, when the agent cannot be asked, when its window is too full, or when there is
|
|
375
|
+
// nothing in the ledger to ask it.
|
|
376
|
+
// The quick test may be aimed (`reference`, `effort`, `intent`): the invitation under the composer
|
|
377
|
+
// starts it from the rung the human is on, toward the one the rule names, on the kind of request
|
|
378
|
+
// they were writing.
|
|
379
|
+
async start({ kind = 'quick', agent, model = null, reference: from = null, effort = null, intent = null } = {}) {
|
|
380
|
+
if (this.#run) return { error: t('A calibration is already running.') };
|
|
381
|
+
if (!['quick', 'full'].includes(kind)) return { error: t('No such test "{kind}".', { kind }) };
|
|
382
|
+
if (!effortLadder(agent)) return { error: t('@{agent} takes no effort per turn, so there is nothing to calibrate.', { agent }) };
|
|
383
|
+
const bench = this.#bench() ?? {};
|
|
384
|
+
const subject = (bench.agents ?? []).find((one) => one.id === agent);
|
|
385
|
+
const invoke = subject ? bench.invokers?.[subject.adapter] : null;
|
|
386
|
+
if (!subject?.ready || !invoke) return { error: t('@{agent} is not ready to answer.', { agent }) };
|
|
387
|
+
|
|
388
|
+
const gate = mayCalibrate({ kind, usage: usageOf(await this.#events(), agent), byHuman: true });
|
|
389
|
+
if (!gate.ok) return { error: t('@{agent} is too close to the end of its window to spend turns on a test.', { agent }) };
|
|
390
|
+
|
|
391
|
+
let judge;
|
|
392
|
+
if (kind === 'quick') {
|
|
393
|
+
judge = { id: 'you', decide: (shown) => new Promise((resolve) => { this.#pending = { ...shown, ...this.#pendingAt, resolve }; }) };
|
|
394
|
+
} else {
|
|
395
|
+
const picked = judgeForAgent(agent, bench);
|
|
396
|
+
if (!picked) return { error: t('There is no impartial judge for @{agent}: another agent, or the local model, has to be ready.', { agent }) };
|
|
397
|
+
judge = {
|
|
398
|
+
id: picked.id,
|
|
399
|
+
decide: async ({ question, first, second }) => {
|
|
400
|
+
const result = await bench.invokers[picked.adapter]({ executable: picked.path, projectRoot: this.#projectRoot, prompt: judgePrompt({ question, first, second }), timeoutMs: ASK_TIMEOUT_MS, model: null, json: false, attachments: [], lease: null, scopes: { web: false, imageGen: false }, imageStudio: null, signal: this.#controller?.signal });
|
|
401
|
+
return parseJudgement(result?.text);
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const events = await this.#said();
|
|
407
|
+
const records = await this.records();
|
|
408
|
+
const cases = calibrationCases(events);
|
|
409
|
+
const ladder = effortLadder(agent);
|
|
410
|
+
const reference = ladder.includes(from) ? from : this.#reference(events, agent);
|
|
411
|
+
const aimed = kind === 'quick' && ladder.indexOf(effort) >= 0 && ladder.indexOf(effort) < ladder.indexOf(reference) ? effort : null;
|
|
412
|
+
const focus = kind === 'quick' && READ_ONLY_INTENTS.includes(intent) ? intent : null;
|
|
413
|
+
const version = subject.version ?? null;
|
|
414
|
+
const plan = planRound({ kind, cases, agent, model, reference, version, records, effort: aimed, intent: focus });
|
|
415
|
+
if (!plan.length) return { error: t('Nothing left to ask @{agent} at this rung: the ledger has no new questions of the kinds this test reads.', { agent }) };
|
|
416
|
+
|
|
417
|
+
this.#controller = new AbortController();
|
|
418
|
+
this.#stopping = false;
|
|
419
|
+
this.#last = null;
|
|
420
|
+
this.#pendingAt = { step: 0, total: kind === 'quick' ? Math.min(QUICK_CASES, plan.length) : plan.length, intent: null };
|
|
421
|
+
this.#run = { kind, agent, model, reference, effort: kind === 'quick' ? plan[0].effort : null, intent: focus, judge: judge.id, startedAt: new Date().toISOString(), turns: 0, estimate: estimateTurns(plan) };
|
|
422
|
+
const ask = ({ question, effort }) => invoke({ executable: subject.path, projectRoot: this.#projectRoot, prompt: question, timeoutMs: ASK_TIMEOUT_MS, model, effort, attachments: [], lease: null, scopes: { web: false, imageGen: false }, imageStudio: null, signal: this.#controller.signal });
|
|
423
|
+
|
|
424
|
+
this.#running = (async () => {
|
|
425
|
+
try {
|
|
426
|
+
const result = await runCalibration({
|
|
427
|
+
kind, cases, agent, model, reference, version, records, ask, judge, effort: aimed, intent: focus,
|
|
428
|
+
stop: () => this.#stopping,
|
|
429
|
+
onProgress: (at) => {
|
|
430
|
+
if (at.done === true) return;
|
|
431
|
+
this.#pendingAt = { step: at.done + 1, total: at.total, intent: at.intent ?? null };
|
|
432
|
+
if (this.#run) this.#run.turns = at.turns;
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
await this.#keep(result.records);
|
|
436
|
+
const compared = result.records.filter((record) => record.verdict && !record.control);
|
|
437
|
+
// Whether the kind the test was aimed at is now settled, and where: what the human is told
|
|
438
|
+
// when it ends, and what the composer will offer from now on.
|
|
439
|
+
const all = [...records, ...result.records];
|
|
440
|
+
const row = focus ? calibrationSummary(all, { agent, model, reference, version }).intents.find((one) => one.intent === focus) : null;
|
|
441
|
+
this.#last = {
|
|
442
|
+
kind, agent, model, reference, effort: aimed ?? plan[0].effort, intent: focus,
|
|
443
|
+
settled: row?.status === 'measured' ? row.effort : null,
|
|
444
|
+
ended: result.ended, turns: result.turns, judge: result.judge,
|
|
445
|
+
compared: compared.length,
|
|
446
|
+
same: compared.filter((record) => record.verdict === 'same').length,
|
|
447
|
+
worse: compared.filter((record) => record.verdict === 'worse').length,
|
|
448
|
+
};
|
|
449
|
+
} catch (error) {
|
|
450
|
+
this.#last = { kind, agent, ended: 'error', error: error.message };
|
|
451
|
+
} finally {
|
|
452
|
+
this.#run = null;
|
|
453
|
+
this.#pending = null;
|
|
454
|
+
this.#controller = null;
|
|
455
|
+
}
|
|
456
|
+
})();
|
|
457
|
+
return { started: kind, agent, reference, estimate: this.#run.estimate };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// The human's word on the pair on the table.
|
|
461
|
+
answer(choice) {
|
|
462
|
+
if (!this.#pending) return { error: t('There is no pair waiting for an answer.') };
|
|
463
|
+
const { resolve } = this.#pending;
|
|
464
|
+
this.#pending = null;
|
|
465
|
+
resolve(CHOICES.has(choice) ? choice : null);
|
|
466
|
+
return { ok: true };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Stops after the turn in flight; a pair waiting for the human is put away unjudged.
|
|
470
|
+
async stop() {
|
|
471
|
+
if (!this.#run) return { stopping: false };
|
|
472
|
+
this.#stopping = true;
|
|
473
|
+
this.#controller?.abort();
|
|
474
|
+
if (this.#pending) this.answer(null);
|
|
475
|
+
await this.#running?.catch(() => null);
|
|
476
|
+
return { stopping: true };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// For tests and for shutdown: the run in flight, settled.
|
|
480
|
+
async settled() { await this.#running?.catch(() => null); }
|
|
481
|
+
}
|