@jossuealcala/madre 0.7.1 → 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 +833 -1
- package/README.md +92 -28
- package/SECURITY.md +1 -0
- package/bin/madre.mjs +30 -10
- package/docs/REFERENCE.md +158 -79
- package/docs/SDK.md +23 -19
- package/package.json +1 -1
- package/public/app.js +11599 -4186
- 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 +2863 -1008
- 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 +6303 -414
- 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/claude.mjs +4 -3
- package/src/adapters/codex.mjs +49 -8
- 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 +45 -6
- 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 +42 -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 +109 -1
- 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 +19 -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 +9 -5
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +34 -0
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +25 -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 +88 -8
- 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 +32 -13
- package/src/room/runs.mjs +151 -0
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +402 -54
- package/src/router.mjs +3 -1
- package/src/runs.mjs +137 -0
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1578 -167
- 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
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// Intenciones: how hard a turn needs to think, said before it is paid for.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions. The room hands in the words, the thread they belong to and whatever the
|
|
4
|
+
// calibration has measured; nothing here calls a model, reads a file or touches the ledger.
|
|
5
|
+
//
|
|
6
|
+
// Only agents whose CLI takes an effort per run are in scope (EFFORTS in models.mjs). A
|
|
7
|
+
// suggestion for a CLI that has no such knob would be a guess about something MADRE cannot set.
|
|
8
|
+
//
|
|
9
|
+
// What this file does NOT do: decide. It says which effort the turn looks like it needs and why,
|
|
10
|
+
// and the human keeps or changes it. Like the @madre offer in routing.js, a caller that cannot say
|
|
11
|
+
// WHY it is suggesting something does not suggest it.
|
|
12
|
+
//
|
|
13
|
+
// Two kinds of why, and they are never confused:
|
|
14
|
+
// rule — the shape of the request (a lookup, a stack trace, "think it through"). Cheap,
|
|
15
|
+
// immediate, and unmeasured: it is what the request looks like, not what it needed.
|
|
16
|
+
// measured — the calibration put real questions from this project to the same agent at two
|
|
17
|
+
// efforts and a blind judge could not tell them apart. Measured beats rule.
|
|
18
|
+
// observed — the room's own turns, read after the fact (intents-observe.mjs): at this rung, for
|
|
19
|
+
// this kind of request, the human moved on instead of asking again. It costs no
|
|
20
|
+
// token, and it is weaker than a measurement: nobody saw the other rung's answer.
|
|
21
|
+
// Measured beats observed, and observed beats rule.
|
|
22
|
+
|
|
23
|
+
import { EFFORTS } from '../models.mjs';
|
|
24
|
+
import { archiveQuestion } from '../../public/routing.js';
|
|
25
|
+
|
|
26
|
+
// The three tiers a rule may speak in, lightest first (light, standard, deep). They name rungs both
|
|
27
|
+
// ladders share, so a rule never proposes an extreme: `minimal`, `xhigh` and `max` are reached only
|
|
28
|
+
// by measurement or by the human choosing them.
|
|
29
|
+
export const TIER_EFFORT = { light: 'low', standard: 'medium', deep: 'high' };
|
|
30
|
+
|
|
31
|
+
// What each kind of request asks of a model before anything else is known about it. `archive` is
|
|
32
|
+
// absent on purpose: a question about what the room already said is routing.js's to answer, by
|
|
33
|
+
// offering @madre, and two offers on one message would compete.
|
|
34
|
+
export const INTENT_TIER = {
|
|
35
|
+
chat: 'light',
|
|
36
|
+
lookup: 'light',
|
|
37
|
+
explain: 'standard',
|
|
38
|
+
review: 'standard',
|
|
39
|
+
implement: 'standard',
|
|
40
|
+
diagnose: 'deep',
|
|
41
|
+
design: 'deep',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const effortLadder = (agentId) => EFFORTS[agentId] ?? null;
|
|
45
|
+
|
|
46
|
+
const fold = (text) => String(text ?? '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
|
47
|
+
|
|
48
|
+
// The verb that says what is being asked. In Spanish it can sit anywhere: "quiero que trabajes
|
|
49
|
+
// en…" carries its verb in the middle, and so does "perfecto, comencemos: cablea…". The earliest
|
|
50
|
+
// one wins, and the imperative, infinitive and subjunctive forms all count because all three are
|
|
51
|
+
// how people write to an agent. English only counts at the head of the request, because half its
|
|
52
|
+
// verbs are also nouns — "the build", "the update", "the fix" — and a noun is not an order.
|
|
53
|
+
const VERBS = [
|
|
54
|
+
['diagnose', /\b(arregl(a|ar|es|emos)|corrig(e|as|amos)|correg(ir|imos)|repar(a|ar|es)|depur(a|ar|es)|investig(a|ar|ues)|diagnostic(a|ar|ues))\b/, 'fix|debug|troubleshoot|investigate|diagnose'],
|
|
55
|
+
['implement', /\b(implement(a|ar|es|emos)?|agreg(a|ar|ues|uemos)|anad(e|ir|as|amos)|cre(a|ar|emos)|escrib(e|ir|as|amos)|haz|hacer|hagas|hagamos|cambi(a|ar|es|emos)|refactoriz(a|ar|es)|migr(a|ar|es|emos)|renombr(a|ar|es)|muev(e|as)|mover|elimin(a|ar|es)|borr(a|ar|es)|actualiz(a|ar|es)|constru(ye|ir|yas|yamos)|trabaj(a|ar|es|emos)|cabl(ea|ear|ees|eemos)|conect(a|ar|es))\b/, 'implement|add|create|write|build|make|change|refactor|rename|move|remove|delete|update|wire|ship'],
|
|
56
|
+
['review', /\b(revis(a|ar|es|emos)|audit(a|ar|es)|evalu(a|ar|es)|critic(a|ar|ques)|analiz(a|ar|es|amos)|verific(a|ar|ques))\b/, 'review|audit|evaluate|assess|critique|analy[sz]e|check'],
|
|
57
|
+
['design', /\b(disen(a|ar|es|emos)|plane(a|ar|es)|planific(a|ar|ques)|propon(er|gas|gamos)?|compar(a|ar|es|emos))\b/, 'design|plan|propose|compare|architect'],
|
|
58
|
+
['explain', /\b(explic(a|ar|ques)|describ(e|ir|as)|resum(e|ir|as)|cuentame|ensename)\b/, 'explain|describe|summari[sz]e|walk me through'],
|
|
59
|
+
].map(([intent, spanish, english]) => [intent, spanish, new RegExp(`^\\W*(?:(?:please|pls|can you|could you|would you|will you|i want you to|i need you to|let'?s|now|then|also|and|ok|okay)[\\s,]+)*(?:${english})\\b`)]);
|
|
60
|
+
|
|
61
|
+
// What refines a verb. "Explica por qué falla" is a diagnosis however politely it is asked, and
|
|
62
|
+
// "analiza esta idea" is a design question rather than a code review.
|
|
63
|
+
const FAILURE = /\b(errore?s?|fallan?|fallando|fallo|crash(ea)?|truena|tronando|se cae|se rompe|rot[oa]|rompio|no funciona|sin funcionar|sigue fallando|no sirve|no jala|no arranca|no compila|no carga|bugs?|traceback|stack ?trace|exception|excepcion|regresion|regression|broken|broke|fails?|failing|failed|doesn'?t work|not working|throws?|panic)\b/;
|
|
64
|
+
const STACK = /^\s+at \S.*:\d+(:\d+)?\)?\s*$|^Traceback \(most recent call last\)|^\s*File "[^"]+", line \d+|^\w*(Error|Exception): \S/m;
|
|
65
|
+
const DESIGN = /\b(idea|estrategia|propuesta|arquitectura|trade-?offs?|monetiz\w*|negocio|enfoque|alternativas?|opciones|conviene|deberiamos|vale la pena|pros y contras|roadmap|should we|which approach|architecture|strategy|proposal|approach|worth it)\b/;
|
|
66
|
+
const EXPLAIN = /\b(como funciona|por que|que hace|para que sirve|how does|how do|why|what does)\b/;
|
|
67
|
+
const LOOKUP = /^\W*(donde|cual(es)?|cuant[oa]s?|cuando|quien|que (version|puerto|comando|archivo|rama|modelo|variable)|hay (un|una|algun)|where|which|when|who|how many|is there|what('s| is) the)\b/;
|
|
68
|
+
const THANKS = /^\W*(gracias|thanks|thank you|ok(ay)?|vale|perfecto|genial|excelente|listo|entendido|got it|great|nice|cool)\b/;
|
|
69
|
+
|
|
70
|
+
// "Sí, hazlo" means whatever was being discussed, done. The message carries no intent of its own.
|
|
71
|
+
// Only the whole message counts: "si cambias X, ¿qué pasa?" starts like a yes and is a question.
|
|
72
|
+
const GO_AHEAD = /^\W*((ok(ay)?|perfecto|va|bien|genial|si)[\s,.!]+)*(si|dale|va|adelante|hazlo|procede|comencemos|empecemos|continua|sigue|go ahead|do it|proceed|yes|yep|sure|ship it|let'?s go|let'?s do it)([\s,.!]+(por favor|please|hazlo|adelante|dale|con eso|asi))*\W*$/;
|
|
73
|
+
const GO_AHEAD_MAX = 60;
|
|
74
|
+
// A go-ahead after a proposal, a review or a diagnosis is the order to carry it out; after an
|
|
75
|
+
// explanation it is "keep going" and stays what it was.
|
|
76
|
+
const CARRIED_OUT = new Set(['design', 'review', 'diagnose', 'implement']);
|
|
77
|
+
|
|
78
|
+
export function classifyIntent(text = '', { previous = null } = {}) {
|
|
79
|
+
const raw = String(text ?? '').trim();
|
|
80
|
+
if (!raw) return null;
|
|
81
|
+
if (archiveQuestion(raw)) return { intent: 'archive', cues: ['archive'] };
|
|
82
|
+
const said = fold(raw);
|
|
83
|
+
|
|
84
|
+
if (raw.length <= GO_AHEAD_MAX && GO_AHEAD.test(said) && previous?.intent && previous.intent !== 'archive') {
|
|
85
|
+
return { intent: CARRIED_OUT.has(previous.intent) ? 'implement' : previous.intent, cues: ['go-ahead'] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let lead = null;
|
|
89
|
+
let at = Infinity;
|
|
90
|
+
for (const [intent, spanish, english] of VERBS) {
|
|
91
|
+
const match = spanish.exec(said) ?? english.exec(said);
|
|
92
|
+
if (match && match.index < at) { lead = intent; at = match.index; }
|
|
93
|
+
}
|
|
94
|
+
const failing = FAILURE.test(said) || STACK.test(raw);
|
|
95
|
+
const cues = lead ? [`verb:${lead}`] : [];
|
|
96
|
+
if (failing) cues.push('failure');
|
|
97
|
+
|
|
98
|
+
// A fix with nothing failing in it is an edit: "arregla el typo del README" is not a hunt.
|
|
99
|
+
if (lead === 'diagnose' && !failing) return { intent: 'implement', cues };
|
|
100
|
+
if (lead === 'diagnose' || lead === 'implement') return { intent: lead, cues };
|
|
101
|
+
// A request to read or explain something that is failing is a diagnosis; a review of an idea or
|
|
102
|
+
// of a choice between approaches is design. The verb alone undersells both. Designing the error
|
|
103
|
+
// handling is still design, and explaining the architecture is still an explanation.
|
|
104
|
+
if (failing && lead !== 'design') return { intent: 'diagnose', cues };
|
|
105
|
+
if ((lead === null || lead === 'review') && DESIGN.test(said)) return { intent: 'design', cues: [...cues, 'design'] };
|
|
106
|
+
if (lead) return { intent: lead, cues };
|
|
107
|
+
if (EXPLAIN.test(said)) return { intent: 'explain', cues: ['explain'] };
|
|
108
|
+
if (LOOKUP.test(said) && raw.length <= 160) return { intent: 'lookup', cues: ['lookup'] };
|
|
109
|
+
if (THANKS.test(said) && raw.length <= 40 && !raw.includes('?')) return { intent: 'chat', cues: ['thanks'] };
|
|
110
|
+
// Something was asked and nothing above says what kind of thing: the middle of the ladder is
|
|
111
|
+
// the honest default, and it is the one most CLIs start at anyway.
|
|
112
|
+
return { intent: 'explain', cues: ['unclassified'] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// What the human said about effort in so many words. Both at once cancel out.
|
|
116
|
+
const QUICK = /\b(rapido|rapidito|breve|brevemente|corto|en una linea|en pocas palabras|solo dime|nada mas dime|sin rodeos|quick(ly)?|brief(ly)?|short answer|in one line|tl;?dr|just tell me)\b/;
|
|
117
|
+
const DEEP = /(\bpiensa (bien|a fondo|con calma|detenidamente)\b|\ba fondo\b|\bcon cuidado\b|\bdetenidamente\b|\bprofund\w*|\bexhaustiv\w*|\bminucios\w*|\briguros\w*|\bthink (hard|harder|carefully|deeply)\b|\bcarefully\b|\bin depth\b|\bthorough(ly)?\b|\bultrathink\b)/;
|
|
118
|
+
// The same request again, because the last answer did not do it.
|
|
119
|
+
const RETRY = /\b(sigue sin|sigue fallando|todavia no (funciona|sirve|jala)|aun no (funciona|sirve|jala)|no funciono|no sirvio|still (fails|failing|broken|not working)|didn'?t work|doesn'?t work yet)\b/;
|
|
120
|
+
// Long enough that reading it is work: a pasted log, a spec, several files at once.
|
|
121
|
+
export const HEAVY_CHARS = 1500;
|
|
122
|
+
export const HEAVY_FILES = 3;
|
|
123
|
+
|
|
124
|
+
// Every way the turn's own context moves the effort, one rung each, with its name.
|
|
125
|
+
export function contextSignals(text = '', { previous = null, references = 0, attachments = 0, quota = null } = {}) {
|
|
126
|
+
const raw = String(text ?? '');
|
|
127
|
+
const said = fold(raw);
|
|
128
|
+
const signals = [];
|
|
129
|
+
const quick = QUICK.test(said);
|
|
130
|
+
const deep = DEEP.test(said);
|
|
131
|
+
if (quick && !deep) signals.push({ id: 'asked-quick', shift: -1 });
|
|
132
|
+
if (deep && !quick) signals.push({ id: 'asked-deep', shift: 1 });
|
|
133
|
+
if (raw.length > HEAVY_CHARS || STACK.test(raw) || Number(references) + Number(attachments) >= HEAVY_FILES) signals.push({ id: 'heavy', shift: 1 });
|
|
134
|
+
if (['failed', 'bad'].includes(previous?.outcome) || RETRY.test(said)) signals.push({ id: 'retry', shift: 1 });
|
|
135
|
+
// An agent near the end of its window is not the place for the deepest rung: the next turn may
|
|
136
|
+
// be the one that does not run at all. A cap, not a push down: light work stays light.
|
|
137
|
+
if (['critical', 'exhausted'].includes(quota?.level)) signals.push({ id: 'quota', cap: 'standard' });
|
|
138
|
+
return signals;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// What the last exchange in this conversation asked and how it went, read from the ledger. The
|
|
142
|
+
// outcome is the human's word on the reply if there is one, or the fact that it failed.
|
|
143
|
+
export function threadContext(events = [], { agent = null } = {}) {
|
|
144
|
+
let last = null;
|
|
145
|
+
const replies = new Map();
|
|
146
|
+
const failed = new Set();
|
|
147
|
+
const ratings = new Map();
|
|
148
|
+
for (const event of events) {
|
|
149
|
+
if (!event || event.ghost) continue;
|
|
150
|
+
const p = event.payload ?? {};
|
|
151
|
+
if (event.type === 'message.created' && p.role === 'user' && p.sender === 'you' && (!agent || p.target === agent)) last = p;
|
|
152
|
+
if (event.type === 'message.created' && p.role === 'assistant' && p.parentMessageId) replies.set(p.parentMessageId, p.messageId);
|
|
153
|
+
if (event.type === 'message.failed' && p.messageId) failed.add(p.messageId);
|
|
154
|
+
if (event.type === 'message.rated' && p.messageId) ratings.set(p.messageId, p.rating);
|
|
155
|
+
}
|
|
156
|
+
if (!last) return null;
|
|
157
|
+
const reply = replies.get(last.messageId);
|
|
158
|
+
const rating = reply ? ratings.get(reply) : null;
|
|
159
|
+
const outcome = failed.has(last.messageId) ? 'failed'
|
|
160
|
+
: ['bad', 'never'].includes(rating) ? 'bad'
|
|
161
|
+
: ['good', 'preference'].includes(rating) ? 'good'
|
|
162
|
+
: null;
|
|
163
|
+
return { intent: classifyIntent(last.text)?.intent ?? null, effort: last.effort ?? null, outcome };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* ---------- measured ---------- */
|
|
167
|
+
|
|
168
|
+
// A cheaper rung is enough when, against the agent's usual rung, a blind judge rarely prefers the
|
|
169
|
+
// usual one. "Rarely" with a number on it: at 80% confidence the cheaper one loses at most one
|
|
170
|
+
// comparison in four. With six cases that means none lost; with twelve, one.
|
|
171
|
+
export const MIN_COMPARISONS = 6;
|
|
172
|
+
export const MAX_COMPARISONS = 20;
|
|
173
|
+
export const LOSS_BAR = 0.25;
|
|
174
|
+
export const CONFIDENCE_Z = 1.28;
|
|
175
|
+
export const COMPARISON_VERDICTS = ['worse', 'same', 'better'];
|
|
176
|
+
|
|
177
|
+
function wilson(hits, n, z, sign) {
|
|
178
|
+
if (!n) return sign > 0 ? 1 : 0;
|
|
179
|
+
const p = hits / n;
|
|
180
|
+
const z2 = z * z;
|
|
181
|
+
const centre = p + z2 / (2 * n);
|
|
182
|
+
const spread = z * Math.sqrt((p * (1 - p)) / n + z2 / (4 * n * n));
|
|
183
|
+
return Math.min(1, Math.max(0, (centre + sign * spread) / (1 + z2 / n)));
|
|
184
|
+
}
|
|
185
|
+
export const wilsonUpper = (hits, n, z = CONFIDENCE_Z) => wilson(hits, n, z, 1);
|
|
186
|
+
export const wilsonLower = (hits, n, z = CONFIDENCE_Z) => wilson(hits, n, z, -1);
|
|
187
|
+
|
|
188
|
+
// One rung against the reference: enough, not enough, or not known yet. Twenty comparisons that
|
|
189
|
+
// still cannot show it is enough are an answer too: not cheaply enough to be worth suggesting.
|
|
190
|
+
export function rungStatus(stats) {
|
|
191
|
+
const n = Number(stats?.n ?? 0);
|
|
192
|
+
const worse = Number(stats?.worse ?? 0);
|
|
193
|
+
if (n < MIN_COMPARISONS) return 'open';
|
|
194
|
+
if (wilsonUpper(worse, n) <= LOSS_BAR) return 'sufficient';
|
|
195
|
+
if (n >= MAX_COMPARISONS || wilsonLower(worse, n) > LOSS_BAR) return 'insufficient';
|
|
196
|
+
return 'open';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const evidenceKey = (agent, model, intent) => `${agent}|${model ?? 'default'}|${intent}`;
|
|
200
|
+
|
|
201
|
+
// Every comparison the calibration kept, folded per agent, model and intent. A comparison made
|
|
202
|
+
// under another version of the CLI measured another program and is left out; one made against a
|
|
203
|
+
// different reference rung answers a different question, so only the latest reference counts.
|
|
204
|
+
export function foldEvidence(records = [], { versions = {} } = {}) {
|
|
205
|
+
const usable = records.filter((record) => record
|
|
206
|
+
&& !record.skipped
|
|
207
|
+
// A control measures the judge, not a rung; an untrusted comparison was made by a judge that
|
|
208
|
+
// failed its controls, so its word is not evidence of anything.
|
|
209
|
+
&& !record.control
|
|
210
|
+
&& !record.untrusted
|
|
211
|
+
&& COMPARISON_VERDICTS.includes(record.verdict)
|
|
212
|
+
&& effortLadder(record.agent)
|
|
213
|
+
&& !(versions[record.agent] && record.version && record.version !== versions[record.agent]));
|
|
214
|
+
const reference = new Map();
|
|
215
|
+
for (const record of usable) reference.set(evidenceKey(record.agent, record.model, record.intent), record.reference);
|
|
216
|
+
const cells = new Map();
|
|
217
|
+
for (const record of usable) {
|
|
218
|
+
const key = evidenceKey(record.agent, record.model, record.intent);
|
|
219
|
+
if (record.reference !== reference.get(key)) continue;
|
|
220
|
+
const cell = cells.get(key) ?? { agent: record.agent, model: record.model ?? null, intent: record.intent, reference: record.reference, rungs: {}, tokens: {} };
|
|
221
|
+
const rung = cell.rungs[record.effort] ?? { n: 0, worse: 0, same: 0, better: 0 };
|
|
222
|
+
rung.n += 1;
|
|
223
|
+
rung[record.verdict] += 1;
|
|
224
|
+
cell.rungs[record.effort] = rung;
|
|
225
|
+
for (const [effort, spent] of [[record.effort, record.tokens?.effort], [record.reference, record.tokens?.reference]]) {
|
|
226
|
+
if (!Number.isFinite(spent)) continue;
|
|
227
|
+
(cell.tokens[effort] ??= []).push(spent);
|
|
228
|
+
}
|
|
229
|
+
cells.set(key, cell);
|
|
230
|
+
}
|
|
231
|
+
return cells;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The cheapest rung that is enough, walking down from the reference. "Low was as good as high" is a
|
|
235
|
+
// measurement of low, whatever nobody asked of medium, so a rung nobody measured is stepped over.
|
|
236
|
+
// A rung that was not enough is where the walk stops: below it, a lucky run is not believed. When
|
|
237
|
+
// the first rung measured on the way down was not enough, the answer is the reference itself.
|
|
238
|
+
export function lowestSufficient(cell, ladder) {
|
|
239
|
+
if (!cell || !ladder) return null;
|
|
240
|
+
const top = ladder.indexOf(cell.reference);
|
|
241
|
+
if (top < 0) return null;
|
|
242
|
+
let best = null;
|
|
243
|
+
for (let i = top - 1; i >= 0; i -= 1) {
|
|
244
|
+
const status = rungStatus(cell.rungs[ladder[i]]);
|
|
245
|
+
if (status === 'sufficient') { best = ladder[i]; continue; }
|
|
246
|
+
if (status === 'insufficient') {
|
|
247
|
+
if (best === null) return { effort: cell.reference, n: cell.rungs[ladder[i]]?.n ?? 0, needsReference: true };
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return best ? { effort: best, n: cell.rungs[best].n, needsReference: false } : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// The rung the calibration should test next for one cell: the first one down that is still open.
|
|
255
|
+
// Null when the cell is settled — a rung was found not to be enough, or the ladder ran out.
|
|
256
|
+
export function nextRung(cell, ladder, reference) {
|
|
257
|
+
const top = ladder?.indexOf(reference) ?? -1;
|
|
258
|
+
if (top < 0) return null;
|
|
259
|
+
for (let i = top - 1; i >= 0; i -= 1) {
|
|
260
|
+
const status = rungStatus(cell?.rungs?.[ladder[i]]);
|
|
261
|
+
if (status === 'open') return ladder[i];
|
|
262
|
+
if (status === 'insufficient') return null;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* ---------- observed ---------- */
|
|
268
|
+
|
|
269
|
+
// A rung is enough in real use when, out of at least eight turns of one kind at it, the human moved
|
|
270
|
+
// on often enough that even the pessimistic end of the interval is three in four. And it is not
|
|
271
|
+
// enough, whatever its own rate, when the rung above it was clearly better in the same use: a rung
|
|
272
|
+
// that holds 80% next to one that holds 99% is costing the human the other 19%.
|
|
273
|
+
export const OBSERVED_MIN_TURNS = 8;
|
|
274
|
+
export const OBSERVED_HELD_BAR = 0.75;
|
|
275
|
+
|
|
276
|
+
// The lowest rung of the ladder that real use showed to be enough, or null. `cell` is one entry of
|
|
277
|
+
// foldObserved(): { rungs: { [effort]: { n, held, short, tokens } } }. A rung outside the ladder
|
|
278
|
+
// (`default`, a turn sent with no effort to a CLI that never says its own) is never an answer.
|
|
279
|
+
export function lowestObservedSufficient(cell, ladder) {
|
|
280
|
+
if (!cell?.rungs || !ladder) return null;
|
|
281
|
+
for (let i = 0; i < ladder.length; i += 1) {
|
|
282
|
+
const rung = cell.rungs[ladder[i]];
|
|
283
|
+
if (!rung || rung.n < OBSERVED_MIN_TURNS || wilsonLower(rung.held, rung.n) < OBSERVED_HELD_BAR) continue;
|
|
284
|
+
const above = ladder.slice(i + 1).map((effort) => cell.rungs[effort]).find((one) => one?.n > 0);
|
|
285
|
+
if (above && above.n >= OBSERVED_MIN_TURNS && wilsonLower(above.held, above.n) > wilsonUpper(rung.held, rung.n)) continue;
|
|
286
|
+
return { effort: ladder[i], n: rung.n, held: rung.held, heldRate: rung.held / rung.n };
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Whether a rung real use has seen enough of was not enough: the walk above passed over it with
|
|
292
|
+
// eight turns or more in hand. A raise from it is something the room saw, not something it guessed.
|
|
293
|
+
const observedShort = (cell, ladder, effort) => {
|
|
294
|
+
const rung = cell?.rungs?.[effort];
|
|
295
|
+
if (!rung || rung.n < OBSERVED_MIN_TURNS) return false;
|
|
296
|
+
const lowest = lowestObservedSufficient(cell, ladder);
|
|
297
|
+
return !lowest || ladder.indexOf(lowest.effort) > ladder.indexOf(effort);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const median = (list = []) => {
|
|
301
|
+
if (!list.length) return null;
|
|
302
|
+
const sorted = [...list].sort((a, b) => a - b);
|
|
303
|
+
const mid = Math.floor(sorted.length / 2);
|
|
304
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/* ---------- the suggestion ---------- */
|
|
308
|
+
|
|
309
|
+
// Which effort this turn looks like it needs, and why. Null when there is nothing honest to say:
|
|
310
|
+
// an agent without a ladder, a question for the archive, or a suggestion equal to what the turn
|
|
311
|
+
// would already run at.
|
|
312
|
+
//
|
|
313
|
+
// `effort` is what the composer has chosen (null = the CLI's own default); `defaultEffort` is that
|
|
314
|
+
// default when MADRE knows it (Codex's config.toml). `evidence` is foldEvidence()'s result and
|
|
315
|
+
// `observed` is foldObserved()'s (intents-observe.mjs): what the room's own turns showed.
|
|
316
|
+
export function suggestEffort({ text = '', agent, model = null, effort = null, defaultEffort = null, previous = null, references = 0, attachments = 0, quota = null, evidence = null, observed = null } = {}) {
|
|
317
|
+
const ladder = effortLadder(agent);
|
|
318
|
+
if (!ladder) return null;
|
|
319
|
+
const read = classifyIntent(text, { previous });
|
|
320
|
+
if (!read || read.intent === 'archive') return null;
|
|
321
|
+
const from = effort ?? defaultEffort ?? null;
|
|
322
|
+
|
|
323
|
+
const cell = evidence?.get?.(evidenceKey(agent, model, read.intent)) ?? null;
|
|
324
|
+
const measured = lowestSufficient(cell, ladder);
|
|
325
|
+
// Real use speaks only where the calibration has not, and only about what it saw: a cheaper rung
|
|
326
|
+
// that held, or a raise from a rung it watched fall short. A rung nobody used is not a rung that
|
|
327
|
+
// failed, so the lowest rung with enough turns says nothing about the ones below it.
|
|
328
|
+
const seenCell = measured ? null : observed?.get?.(evidenceKey(agent, model, read.intent)) ?? null;
|
|
329
|
+
let seen = lowestObservedSufficient(seenCell, ladder);
|
|
330
|
+
if (seen && from !== null && ladder.includes(from) && ladder.indexOf(seen.effort) > ladder.indexOf(from) && !observedShort(seenCell, ladder, from)) seen = null;
|
|
331
|
+
// With no rung known to start from, turns sent with no effort are the CLI's default at work. If
|
|
332
|
+
// they held at least as well, and cost no more, there is nothing to say about a named rung.
|
|
333
|
+
if (seen && from === null) {
|
|
334
|
+
const usual = seenCell.rungs.default;
|
|
335
|
+
if (usual?.n >= OBSERVED_MIN_TURNS && wilsonLower(usual.held, usual.n) >= OBSERVED_HELD_BAR
|
|
336
|
+
&& !(Number.isFinite(usual.tokens) && Number.isFinite(seenCell.rungs[seen.effort].tokens) && seenCell.rungs[seen.effort].tokens < usual.tokens)) seen = null;
|
|
337
|
+
}
|
|
338
|
+
const ruled = TIER_EFFORT[INTENT_TIER[read.intent]];
|
|
339
|
+
const anchor = measured?.effort ?? seen?.effort ?? null;
|
|
340
|
+
let index = ladder.indexOf(anchor ?? ruled);
|
|
341
|
+
// A rule stays inside low…high. A measurement or an observation may go further in either
|
|
342
|
+
// direction, because it was seen to be enough (or needed) rather than assumed.
|
|
343
|
+
const floor = Math.min(ladder.indexOf(TIER_EFFORT.light), anchor ? ladder.indexOf(anchor) : Infinity);
|
|
344
|
+
const ceiling = Math.max(ladder.indexOf(TIER_EFFORT.deep), cell ? ladder.indexOf(cell.reference) : -1, seen ? ladder.indexOf(seen.effort) : -1);
|
|
345
|
+
|
|
346
|
+
const signals = contextSignals(text, { previous, references, attachments, quota });
|
|
347
|
+
const moved = [];
|
|
348
|
+
for (const signal of signals) {
|
|
349
|
+
if (!signal.shift) continue;
|
|
350
|
+
const next = Math.min(ceiling, Math.max(floor, index + signal.shift));
|
|
351
|
+
if (next !== index) moved.push(signal.id);
|
|
352
|
+
index = next;
|
|
353
|
+
}
|
|
354
|
+
// The cap gives way to a measurement: a rung the calibration, or real use, saw fall short is not
|
|
355
|
+
// offered to save quota.
|
|
356
|
+
const cap = signals.find((signal) => signal.cap);
|
|
357
|
+
const capped = cap ? ladder.indexOf(TIER_EFFORT[cap.cap]) : -1;
|
|
358
|
+
if (cap && index > capped && rungStatus(cell?.rungs?.[ladder[capped]]) !== 'insufficient' && !observedShort(seenCell, ladder, ladder[capped])) { index = capped; moved.push(cap.id); }
|
|
359
|
+
|
|
360
|
+
const suggested = ladder[index];
|
|
361
|
+
if (suggested === from) return null;
|
|
362
|
+
const direction = from && ladder.includes(from) ? (ladder.indexOf(suggested) < ladder.indexOf(from) ? 'lower' : 'raise') : null;
|
|
363
|
+
// An observation vouches for the rung it saw and nothing else: once the turn's own context has
|
|
364
|
+
// moved the suggestion off it, what is left is a rule's word, and it is said as one.
|
|
365
|
+
const basis = measured ? 'measured' : seen && suggested === seen.effort ? 'observed' : 'rule';
|
|
366
|
+
|
|
367
|
+
// What the cheaper rung actually cost against the one it replaces, when both were measured — or,
|
|
368
|
+
// from real use, the middle turn at each rung. With no rung to start from, the turns sent with no
|
|
369
|
+
// effort are what the human pays today.
|
|
370
|
+
let saving = null;
|
|
371
|
+
if (basis === 'observed') {
|
|
372
|
+
const here = seenCell.rungs[suggested]?.tokens;
|
|
373
|
+
const there = seenCell.rungs[from ?? 'default']?.tokens;
|
|
374
|
+
if (direction !== 'raise' && Number.isFinite(here) && Number.isFinite(there) && there > 0) saving = Number((1 - here / there).toFixed(2));
|
|
375
|
+
} else {
|
|
376
|
+
const spentHere = median(cell?.tokens?.[suggested]);
|
|
377
|
+
const spentThere = median(cell?.tokens?.[from]);
|
|
378
|
+
saving = direction === 'lower' && spentHere !== null && spentThere ? Number((1 - spentHere / spentThere).toFixed(2)) : null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
agent,
|
|
383
|
+
model,
|
|
384
|
+
intent: read.intent,
|
|
385
|
+
cues: read.cues,
|
|
386
|
+
effort: suggested,
|
|
387
|
+
from,
|
|
388
|
+
direction,
|
|
389
|
+
basis,
|
|
390
|
+
signals: moved,
|
|
391
|
+
measured: measured ? { n: measured.n, reference: cell.reference, needsReference: measured.needsReference } : null,
|
|
392
|
+
observed: basis === 'observed' ? { n: seen.n, held: seen.held, heldRate: Number(seen.heldRate.toFixed(2)), rung: seen.effort } : null,
|
|
393
|
+
saving,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Whether a suggestion is worth putting in front of the human. A measured one always is: the room
|
|
398
|
+
// saw it. A rule only speaks up to raise the effort, because a request that looks like a hunt run
|
|
399
|
+
// cheaply wastes the whole turn, while a rule that says "this looks cheap" has measured nothing and
|
|
400
|
+
// would be asking the human to trust a guess with their answer.
|
|
401
|
+
//
|
|
402
|
+
// When the CLI never says what effort it runs at by default (Claude does not), there is no rung to
|
|
403
|
+
// raise from. A rule then still speaks for the deep kinds — «this one wants HIGH» names a level,
|
|
404
|
+
// not a comparison — and the light ones become an invitation to measure, below.
|
|
405
|
+
const deepEnough = (suggestion) => (effortLadder(suggestion.agent) ?? []).indexOf(suggestion.effort) >= (effortLadder(suggestion.agent) ?? []).indexOf(TIER_EFFORT.deep);
|
|
406
|
+
// An observed one is offered like a measured one: the room saw it in this project's own turns.
|
|
407
|
+
export const offerable = (suggestion) => Boolean(suggestion) && (['measured', 'observed'].includes(suggestion.basis) || suggestion.direction === 'raise' || (suggestion.direction === null && suggestion.from === null && deepEnough(suggestion)));
|
|
408
|
+
|
|
409
|
+
// The other half: a rule that says a cheaper rung would do. It is never offered as advice, because
|
|
410
|
+
// nothing backs it; it is offered as a question worth measuring — and only for the kinds of request
|
|
411
|
+
// a read-only test can measure.
|
|
412
|
+
export const MEASURABLE = ['lookup', 'explain', 'review', 'diagnose', 'design'];
|
|
413
|
+
export const invitable = (suggestion) => Boolean(suggestion) && suggestion.basis === 'rule' && MEASURABLE.includes(suggestion.intent)
|
|
414
|
+
&& (suggestion.direction === 'lower' || (suggestion.direction === null && suggestion.from === null && !deepEnough(suggestion)));
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Intenciones, trying a cheaper rung on one message.
|
|
2
|
+
//
|
|
3
|
+
// Real use (intents-observe.mjs) can only vouch for the rungs the human actually uses. Somebody who
|
|
4
|
+
// sends everything at the CLI's default never runs a cheaper rung, so real use never sees one hold
|
|
5
|
+
// and Intenciones never has a reason to suggest saving. A try closes that gap: when the rule thinks
|
|
6
|
+
// one rung lower is enough for a read-only kind of request, it proposes running THIS message at it,
|
|
7
|
+
// and the same observation rules that read every other turn say whether it held.
|
|
8
|
+
//
|
|
9
|
+
// A try is never the default and never every message. It is offered only where a wrong answer
|
|
10
|
+
// costs little (a lookup, an explanation, a review, in a mode that writes nothing), only while real
|
|
11
|
+
// use has not yet seen enough turns at the cheaper rung to say, at most once every three messages
|
|
12
|
+
// that qualify, and it stops for a week where the cheaper rung fell short twice in its last three.
|
|
13
|
+
//
|
|
14
|
+
// Pure functions. The desk keeps the counts and the tries in memory and hands everything in.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
effortLadder, OBSERVED_HELD_BAR, OBSERVED_MIN_TURNS, TIER_EFFORT, wilsonLower,
|
|
18
|
+
} from './intents-policy.mjs';
|
|
19
|
+
import { counted } from './intents-observe.mjs';
|
|
20
|
+
|
|
21
|
+
// The kinds of request a try may touch: the ones that only read. A change, a hunt or a design call
|
|
22
|
+
// run cheaply can waste the whole turn, and a short reply or a question for the archive has no rung
|
|
23
|
+
// worth learning.
|
|
24
|
+
export const TRY_INTENTS = ['lookup', 'explain', 'review'];
|
|
25
|
+
// With no rung known to start from and nothing seen to hold there, only the lightest kinds.
|
|
26
|
+
export const TRY_BLIND_INTENTS = ['lookup', 'explain'];
|
|
27
|
+
// The modes a try may run in: EXCHANGE and CREATE. CONTROL and AIRLOCK act on the project, and a
|
|
28
|
+
// GHOST turn leaves nothing in the ledger for the observation to read.
|
|
29
|
+
export const TRY_MODES = [1, 2];
|
|
30
|
+
// At most one try out of every this many messages that qualify, per agent, model and kind.
|
|
31
|
+
export const TRY_EVERY = 3;
|
|
32
|
+
// Fell short this many times in the last so many decided turns at the cheaper rung: a pause.
|
|
33
|
+
export const TRY_SHORT = 2;
|
|
34
|
+
export const TRY_LAST = 3;
|
|
35
|
+
export const TRY_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
|
|
36
|
+
// A try is reported when the message leaves; the ledger stamps the message a moment later or, on
|
|
37
|
+
// a slow clock, a moment earlier. The observation of the try is the first one after this much slack.
|
|
38
|
+
export const TRY_SLACK_MS = 60 * 1000;
|
|
39
|
+
|
|
40
|
+
// Whether turns sent with no effort — the CLI's own default at work — held in real use: enough of
|
|
41
|
+
// them, and the pessimistic end of the interval at three in four.
|
|
42
|
+
export function defaultHeld(cell) {
|
|
43
|
+
const usual = cell?.rungs?.default;
|
|
44
|
+
return Boolean(usual) && usual.n >= OBSERVED_MIN_TURNS && wilsonLower(usual.held, usual.n) >= OBSERVED_HELD_BAR;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The effort a CLI runs at when told none, where MADRE knows it. `defaults` is { agent: effort } or
|
|
48
|
+
// { agent: { effort, byModel: { model: effort } } }, as the desk receives it.
|
|
49
|
+
export function knownDefault(defaults = {}, agent, model = null) {
|
|
50
|
+
const ladder = effortLadder(agent) ?? [];
|
|
51
|
+
const known = defaults?.[agent];
|
|
52
|
+
const said = typeof known === 'string' ? known : (model && known?.byModel?.[model]) || known?.effort || null;
|
|
53
|
+
return ladder.includes(said) ? said : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The rung a try would run at, or null. `rule` is the rung the rule names for this message (with
|
|
57
|
+
// its own context taken into account); `from` the rung the message would run at, or null when the
|
|
58
|
+
// CLI never says its default. With a rung to start from, a try is exactly one rung below it and only
|
|
59
|
+
// where the rule points there. Without one, the rule's rung when real use shows the default held,
|
|
60
|
+
// and otherwise the lightest rung, for lookups and explanations only. Never the deep tier.
|
|
61
|
+
export function tryRung({ agent, intent, from = null, rule = null, defaultHeld: held = false } = {}) {
|
|
62
|
+
const ladder = effortLadder(agent);
|
|
63
|
+
if (!ladder || !TRY_INTENTS.includes(intent) || !ladder.includes(rule)) return null;
|
|
64
|
+
if (from !== null && from !== undefined) {
|
|
65
|
+
const at = ladder.indexOf(from);
|
|
66
|
+
return at > 0 && ladder.indexOf(rule) === at - 1 ? rule : null;
|
|
67
|
+
}
|
|
68
|
+
if (ladder.indexOf(rule) >= ladder.indexOf(TIER_EFFORT.deep)) return null;
|
|
69
|
+
if (held) return rule;
|
|
70
|
+
return TRY_BLIND_INTENTS.includes(intent) ? TIER_EFFORT.light : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sameCell = (one, { agent, model, intent }) => one.agent === agent && (one.model ?? null) === (model ?? null) && one.intent === intent;
|
|
74
|
+
const stamp = (one) => { const at = Date.parse(one?.at ?? ''); return Number.isFinite(at) ? at : null; };
|
|
75
|
+
|
|
76
|
+
// When the pause on a rung ends, in ms, or null when there is none: the cheaper rung fell short at
|
|
77
|
+
// least twice in its last three decided turns for this agent, model and kind. It runs a week from
|
|
78
|
+
// the latest of those shortfalls, so a turn that held after it does not stretch it.
|
|
79
|
+
export function tryCooldown(observations = [], { agent, model = null, intent, effort } = {}) {
|
|
80
|
+
const last = observations.filter((one) => counted(one) && sameCell(one, { agent, model, intent }) && one.effort === effort).slice(-TRY_LAST);
|
|
81
|
+
const short = last.filter((one) => one.outcome === 'fell-short');
|
|
82
|
+
if (short.length < TRY_SHORT) return null;
|
|
83
|
+
const at = stamp(short.at(-1));
|
|
84
|
+
return at === null ? null : at + TRY_COOLDOWN_MS;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// How a try went, read like every other turn: the first observation of that agent, model and kind
|
|
88
|
+
// at the rung tried, from the moment the try left. Null while nothing in the ledger says yet.
|
|
89
|
+
export function triedOutcome(observations = [], { agent, model = null, intent, effort, at } = {}) {
|
|
90
|
+
return observations.find((one) => sameCell(one, { agent, model, intent }) && one.effort === effort && (stamp(one) ?? -Infinity) >= at - TRY_SLACK_MS) ?? null;
|
|
91
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Intenciones: how hard each agent should think for the message being written.
|
|
2
|
+
//
|
|
3
|
+
// It reads the request, the conversation it belongs to and what this project has measured, and
|
|
4
|
+
// when there is something worth saying it says it under the composer: this looks like a lookup and
|
|
5
|
+
// a lower rung was enough here, or this looks like a hunt and the rung chosen is too light. The
|
|
6
|
+
// human presses the button or does not. Nothing changes on its own.
|
|
7
|
+
//
|
|
8
|
+
// What it knows, it learns first from real use, for no tokens (intents-observe): every turn the
|
|
9
|
+
// human already sent, and whether the rung it ran at was enough — the human moved on, or asked
|
|
10
|
+
// again. Faster and stronger, for a price, are the two tests the human starts from this card
|
|
11
|
+
// (intents-calibration): the quick one, where the human compares two answers without knowing which
|
|
12
|
+
// is which, and the full one, where an impartial agent does, after proving it can see a difference.
|
|
13
|
+
//
|
|
14
|
+
// Only Claude Code and Codex take an effort per run, so only they are in it.
|
|
15
|
+
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { defineModule } from './sdk.mjs';
|
|
18
|
+
import { t } from '../i18n.mjs';
|
|
19
|
+
import { IntentsDesk } from './intents-desk.mjs';
|
|
20
|
+
|
|
21
|
+
const desks = new Map();
|
|
22
|
+
// One desk per project room, made the first time anything asks for it.
|
|
23
|
+
export function deskFor(ctx) {
|
|
24
|
+
const where = ctx.services?.intents;
|
|
25
|
+
if (!where?.roomDir) return null;
|
|
26
|
+
if (!desks.has(where.roomDir)) {
|
|
27
|
+
desks.set(where.roomDir, new IntentsDesk({
|
|
28
|
+
file: join(where.roomDir, 'intents.jsonl'),
|
|
29
|
+
offersFile: join(where.roomDir, 'intents-offers.json'),
|
|
30
|
+
events: where.events,
|
|
31
|
+
projectEvents: where.projectEvents,
|
|
32
|
+
ledgers: where.ledgers,
|
|
33
|
+
defaults: where.defaults,
|
|
34
|
+
bench: where.bench,
|
|
35
|
+
projectRoot: ctx.projectRoot,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
return desks.get(where.roomDir);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const enabled = (ctx) => ctx.config?.modules?.intents?.enabled !== false;
|
|
42
|
+
const count = (value) => Math.max(0, Math.min(50, Number.parseInt(value, 10) || 0));
|
|
43
|
+
const modeOf = (value) => (Number.isInteger(value) && value >= 0 && value <= 4 ? value : null);
|
|
44
|
+
const nameOrNull = (value) => (typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,120}$/.test(value) ? value : null);
|
|
45
|
+
|
|
46
|
+
export default defineModule({
|
|
47
|
+
id: 'intents',
|
|
48
|
+
configKey: 'intents',
|
|
49
|
+
name: 'Intenciones',
|
|
50
|
+
vendor: 'MADRE',
|
|
51
|
+
version: '1.0.0',
|
|
52
|
+
summary: 'Suggests how hard Claude Code and Codex should think for the message you are writing, from what was measured on this project. It suggests; you choose.',
|
|
53
|
+
creates: ['nothing in the project', 'its measurements next to the project, in intents.jsonl'],
|
|
54
|
+
settings: { enabled: true },
|
|
55
|
+
card: 'intents',
|
|
56
|
+
async status(ctx) {
|
|
57
|
+
return {
|
|
58
|
+
status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? t('on · suggests an effort when there is a reason to') : t('off · no suggestions under the Write box') },
|
|
59
|
+
install: { display: ctx.settings.enabled ? 'stop suggesting an effort' : 'suggest an effort when there is a reason to', platforms: [] },
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
routes: [
|
|
63
|
+
{
|
|
64
|
+
method: 'GET', path: '/api/intents',
|
|
65
|
+
handler: async (ctx, { url }) => {
|
|
66
|
+
const desk = deskFor(ctx);
|
|
67
|
+
if (!desk) return { status: 503, body: { error: t('Intenciones is not available in this project.') } };
|
|
68
|
+
let models = {};
|
|
69
|
+
try { models = JSON.parse(url.searchParams.get('models') ?? '{}') ?? {}; } catch { models = {}; }
|
|
70
|
+
const chosen = Object.fromEntries(Object.entries(models).map(([agent, model]) => [agent, nameOrNull(model)]));
|
|
71
|
+
let efforts = {};
|
|
72
|
+
try { efforts = JSON.parse(url.searchParams.get('efforts') ?? '{}') ?? {}; } catch { efforts = {}; }
|
|
73
|
+
const rungs = Object.fromEntries(Object.entries(efforts).map(([agent, effort]) => [agent, nameOrNull(effort)]));
|
|
74
|
+
return { status: 200, body: { enabled: enabled(ctx), ...(await desk.view({ models: chosen, efforts: rungs })) } };
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
method: 'POST', path: '/api/intents/suggest',
|
|
79
|
+
handler: async (ctx, { payload }) => {
|
|
80
|
+
const desk = deskFor(ctx);
|
|
81
|
+
if (!desk || !enabled(ctx)) return { status: 200, body: { suggestion: null, explore: null } };
|
|
82
|
+
const text = String(payload?.text ?? '').slice(0, 20000);
|
|
83
|
+
const { suggestion, explore } = await desk.consider({
|
|
84
|
+
text,
|
|
85
|
+
agent: String(payload?.agent ?? ''),
|
|
86
|
+
model: nameOrNull(payload?.model),
|
|
87
|
+
effort: nameOrNull(payload?.effort),
|
|
88
|
+
defaultEffort: nameOrNull(payload?.defaultEffort),
|
|
89
|
+
references: count(payload?.references),
|
|
90
|
+
attachments: count(payload?.attachments),
|
|
91
|
+
mode: modeOf(payload?.mode),
|
|
92
|
+
});
|
|
93
|
+
return { status: 200, body: { suggestion, explore } };
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
method: 'POST', path: '/api/intents/calibrate',
|
|
98
|
+
handler: async (ctx, { payload }) => {
|
|
99
|
+
const desk = deskFor(ctx);
|
|
100
|
+
if (!desk) return { status: 503, body: { error: t('Intenciones is not available in this project.') } };
|
|
101
|
+
if (!enabled(ctx)) return { status: 409, body: { error: t('Turn Intenciones on first.') } };
|
|
102
|
+
const result = await desk.start({
|
|
103
|
+
kind: payload?.kind === 'full' ? 'full' : 'quick',
|
|
104
|
+
agent: String(payload?.agent ?? ''),
|
|
105
|
+
model: nameOrNull(payload?.model),
|
|
106
|
+
reference: nameOrNull(payload?.reference),
|
|
107
|
+
effort: nameOrNull(payload?.effort),
|
|
108
|
+
intent: nameOrNull(payload?.intent),
|
|
109
|
+
});
|
|
110
|
+
return { status: result.error ? 409 : 202, body: result };
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
method: 'POST', path: '/api/intents/answer',
|
|
115
|
+
handler: async (ctx, { payload }) => {
|
|
116
|
+
const result = deskFor(ctx)?.answer(String(payload?.choice ?? '')) ?? { error: t('Intenciones is not available in this project.') };
|
|
117
|
+
return { status: result.error ? 409 : 200, body: result };
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{ method: 'POST', path: '/api/intents/stop', handler: async (ctx) => ({ status: 200, body: (await deskFor(ctx)?.stop()) ?? { stopping: false } }) },
|
|
121
|
+
{
|
|
122
|
+
method: 'POST', path: '/api/intents/offer',
|
|
123
|
+
handler: async (ctx, { payload }) => {
|
|
124
|
+
const desk = deskFor(ctx);
|
|
125
|
+
if (!desk || !enabled(ctx)) return { status: 200, body: {} };
|
|
126
|
+
const explore = payload?.explore && typeof payload.explore === 'object'
|
|
127
|
+
? { agent: nameOrNull(payload.explore.agent), model: nameOrNull(payload.explore.model), intent: nameOrNull(payload.explore.intent) }
|
|
128
|
+
: null;
|
|
129
|
+
return { status: 200, body: { offers: await desk.offered({
|
|
130
|
+
accepted: payload?.accepted === true,
|
|
131
|
+
// A message that only qualified for a try, with nothing shown, paces the tries and counts no offer.
|
|
132
|
+
shown: payload?.shown !== false,
|
|
133
|
+
kind: nameOrNull(payload?.kind),
|
|
134
|
+
basis: nameOrNull(payload?.basis),
|
|
135
|
+
agent: nameOrNull(payload?.agent),
|
|
136
|
+
model: nameOrNull(payload?.model),
|
|
137
|
+
intent: nameOrNull(payload?.intent),
|
|
138
|
+
effort: nameOrNull(payload?.effort),
|
|
139
|
+
from: nameOrNull(payload?.from),
|
|
140
|
+
explore: explore?.agent && explore.intent ? explore : null,
|
|
141
|
+
}) } };
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
});
|