@chatpanel/events 0.85.0 → 0.88.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/agent.js +248 -0
- package/attribution.js +132 -0
- package/client-prefs.js +10 -1
- package/engine.js +131 -0
- package/gate.js +74 -0
- package/index.js +11 -2
- package/job.js +148 -0
- package/model-candidates.js +376 -0
- package/model-ledger.js +204 -0
- package/model-picker.js +3 -1
- package/package.json +19 -1
- package/project.js +170 -0
- package/route-strategies.js +232 -0
- package/scm-connection.js +180 -0
- package/scorecard.js +148 -4
- package/team-run.js +41 -6
- package/team-tool.js +16 -6
- package/team-trail.js +6 -0
- package/team.js +104 -9
- package/voice-speaker.js +98 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// HOW A MODEL IS CHOSEN — the strategies and the one step that is never optional.
|
|
2
|
+
//
|
|
3
|
+
// The routing CONTRACT (createModelRouter, signals, requirements, failover order) has lived
|
|
4
|
+
// in this package since it was written; the DECISIONS layered on it — escalate hard work,
|
|
5
|
+
// replace like with like, honour "use claude" — were typed in the extension, so the desktop
|
|
6
|
+
// was about to copy them. They are pure functions of the candidates and the need, so they
|
|
7
|
+
// belong here, and both clients build the same router from the same declarations.
|
|
8
|
+
//
|
|
9
|
+
// `needForTurn` builds the need from the turn's facts. The source guard (which page or
|
|
10
|
+
// record the turn is about, and what that caps reach at) is INJECTED as `guard`, because
|
|
11
|
+
// reading it means reading a client's settings; the ceiling itself is enforced here.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
defineMiddleware, defineRouteStrategy, signalsFrom, requirementsFor, preferenceFor,
|
|
15
|
+
failoverOrder, pinnedOrderOf,
|
|
16
|
+
} from './router.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Redaction is REQUIRED for anything that leaves the user's machine.
|
|
20
|
+
*
|
|
21
|
+
* Declared as middleware with `requiredFor` so the router refuses to route to a third party
|
|
22
|
+
* when it is not active. That is the difference between "we always redact" as a habit and as
|
|
23
|
+
* a property: a disabled plugin, a refactor or a new caller cannot quietly skip it.
|
|
24
|
+
*/
|
|
25
|
+
export const redactionStep = defineMiddleware({
|
|
26
|
+
id: 'redaction',
|
|
27
|
+
label: 'Redaction',
|
|
28
|
+
stage: 'request',
|
|
29
|
+
priority: 10, // before anything that reads the text
|
|
30
|
+
requiredFor: (model) => model.reach === 'any',
|
|
31
|
+
// The actual redaction still happens in streamChat's harness. This declares the
|
|
32
|
+
// REQUIREMENT; wiring the implementation through here is the next step, and doing both at
|
|
33
|
+
// once would mean changing what redaction does in the same commit that changes when it runs.
|
|
34
|
+
run: async (request) => request,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Escalate when the task is actually hard.
|
|
39
|
+
*
|
|
40
|
+
* The router was picking the cheapest eligible model for everything, which is right for
|
|
41
|
+
* "hello" and wrong for "draw a circle around Mickey" — a request needing spatial reasoning
|
|
42
|
+
* and a structured payload went to a 26B model because it was free. Cost is the correct
|
|
43
|
+
* tie-breaker among models that can all do the job; it is the wrong one when they cannot.
|
|
44
|
+
*
|
|
45
|
+
* Class R: length, code fences, image content and page tools are all readable for nothing.
|
|
46
|
+
* The escalation itself costs no model call — only the answer does, and that is the point.
|
|
47
|
+
*/
|
|
48
|
+
export const complexityStrategy = defineRouteStrategy({
|
|
49
|
+
id: 'escalate-on-complexity',
|
|
50
|
+
label: 'Escalate hard tasks',
|
|
51
|
+
classUsed: 'R',
|
|
52
|
+
decide: async (eligible, need) => {
|
|
53
|
+
const sig = need.signals;
|
|
54
|
+
// ASKED FOR NOTHING, ESCALATES TO NOTHING. This fired on 'hello' because the caller was
|
|
55
|
+
// passing `structured: structured || pageTools`, so every turn on a page with actions
|
|
56
|
+
// armed looked like exact structured work. Equipment is not demand — the same conflation
|
|
57
|
+
// that put a quality floor on a greeting, in a second place.
|
|
58
|
+
if (sig?.smalltalk) return null;
|
|
59
|
+
// NOR DOES BACKGROUND WORK ESCALATE. Dropping the quality floor for a topic pass and then
|
|
60
|
+
// letting escalation rank by quality anyway would move the same decision one step down
|
|
61
|
+
// and change nothing — the floor eliminated the local models, this would simply rank them
|
|
62
|
+
// last. Both read 'high' from the size of the material rather than the difficulty of the
|
|
63
|
+
// ask, so both have to abstain.
|
|
64
|
+
if (need.background) return null;
|
|
65
|
+
const hard = sig?.complexity === 'high' || sig?.modality === 'vision' || need.structured;
|
|
66
|
+
if (!hard) return null; // no opinion on easy work — let cost decide
|
|
67
|
+
// Prefer a model that claims what this task actually wants. Not a hard filter: declaring
|
|
68
|
+
// "reasoning" required would eliminate every model on a setup where nobody has ticked
|
|
69
|
+
// the box, and an empty candidate list is a worse answer than a merely adequate model.
|
|
70
|
+
const wants = new Set();
|
|
71
|
+
if (sig?.complexity === 'high') wants.add('reasoning');
|
|
72
|
+
if (need.structured) wants.add('tools');
|
|
73
|
+
if (sig?.modality === 'vision') wants.add('vision');
|
|
74
|
+
if (sig?.approxTokens > 20_000) wants.add('long-context');
|
|
75
|
+
const fit = (m) => [...wants].filter((c) => m.capabilities.includes(c)).length;
|
|
76
|
+
const best = Math.max(...eligible.map(fit));
|
|
77
|
+
let shortlist = best > 0 ? eligible.filter((m) => fit(m) === best) : eligible;
|
|
78
|
+
|
|
79
|
+
// STRUCTURED WORK WANTS A MODEL, NOT AN AGENT.
|
|
80
|
+
//
|
|
81
|
+
// A canvas or spreadsheet adapter is one call: hand it the data, it applies it, done. A
|
|
82
|
+
// CLI agent runs its OWN loop — it explores, reads files, decides what to do next — and
|
|
83
|
+
// having applied the shapes correctly it carries on, because finishing is not something
|
|
84
|
+
// its loop is told about. A user watched the circle appear and then waited until they
|
|
85
|
+
// killed the process.
|
|
86
|
+
//
|
|
87
|
+
// Not a hard filter: on a setup with only agents, an agent that overruns still beats no
|
|
88
|
+
// answer.
|
|
89
|
+
if (need.structured) {
|
|
90
|
+
const models = shortlist.filter((m) => m.classUsed !== 'A');
|
|
91
|
+
if (models.length) shortlist = models;
|
|
92
|
+
}
|
|
93
|
+
// Rank by declared quality — the axis this strategy exists to judge — then by the ORDER
|
|
94
|
+
// the user set, and only then by cost. A model with an unknown quality sits mid-table
|
|
95
|
+
// rather than last, so a newly added model is not permanently skipped.
|
|
96
|
+
//
|
|
97
|
+
// ORDER BEFORE COST, and this is the fix for a real complaint: three CLI agents of
|
|
98
|
+
// identical quality, one of them pinned to Order 1, and escalation picked a different one
|
|
99
|
+
// because it is cheaper per 1k. A hand-set order is a statement — they can see the prices
|
|
100
|
+
// and chose anyway — and it was being honoured in the score path and nowhere else, so
|
|
101
|
+
// the moment any strategy had an opinion the user's own preference stopped existing.
|
|
102
|
+
//
|
|
103
|
+
// Still only a TIE-BREAK: quality decides first, so a genuinely better model beats the
|
|
104
|
+
// pinned one, and an INFERRED order stays below cost where a guess belongs.
|
|
105
|
+
const q = (m) => (Number.isFinite(m.quality) ? m.quality : 0.5);
|
|
106
|
+
return [...shortlist].sort((a, b) => q(b) - q(a)
|
|
107
|
+
|| pinnedOrderOf(a) - pinnedOrderOf(b) || a.costPer1k - b.costPer1k);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* When a model declines, replace it with the closest thing available — not the cheapest.
|
|
113
|
+
*
|
|
114
|
+
* A frontier model that ran out of credits mid-task should be replaced by the same model at
|
|
115
|
+
* another provider, or by something comparably capable. Falling back to a small local model
|
|
116
|
+
* is how a drawing that was going well turns into a circle in the wrong place: the task did
|
|
117
|
+
* not get easier when the provider said no.
|
|
118
|
+
*
|
|
119
|
+
* Ranked by closeness to what failed, in the order that actually matters:
|
|
120
|
+
* 1. the SAME model somewhere else — identical capability, merely a different bill;
|
|
121
|
+
* 2. a model with every capability the failed one had, best quality first;
|
|
122
|
+
* 3. anything else, so the turn still completes rather than dying.
|
|
123
|
+
*/
|
|
124
|
+
export const failoverStrategy = defineRouteStrategy({
|
|
125
|
+
id: 'failover-to-similar',
|
|
126
|
+
label: 'Replace like with like',
|
|
127
|
+
classUsed: 'R',
|
|
128
|
+
decide: async (eligible, need) => {
|
|
129
|
+
const failed = need.like;
|
|
130
|
+
if (!failed) return null;
|
|
131
|
+
// THE ORDERING ITSELF LIVES IN @chatpanel/events, because two things need it: this
|
|
132
|
+
// strategy, and the projected chain the trace draws before any of it happens. A picture
|
|
133
|
+
// computed by a second implementation would eventually disagree with the real failover,
|
|
134
|
+
// and one that lies about what the router will do is worse than no picture. The strategy
|
|
135
|
+
// is the thin part — knowing there IS something to replace.
|
|
136
|
+
return failoverOrder(eligible, failed);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* "use claude" is an instruction, not a topic.
|
|
142
|
+
*
|
|
143
|
+
* A user naming a model in their message was being ignored entirely — the router read
|
|
144
|
+
* length, modality and tools, and not the one signal that is an explicit answer to the
|
|
145
|
+
* question it was asking. Asking for a specific model and being given another is the most
|
|
146
|
+
* annoying possible failure of a router, because it looks like the request was not read.
|
|
147
|
+
*
|
|
148
|
+
* DELIBERATELY CONSERVATIVE. Only imperative forms count — "use X", "with X", "ask X",
|
|
149
|
+
* "switch to X" — so "tell me about claude" stays a question about Claude rather than a
|
|
150
|
+
* routing instruction. A false positive here silently sends work to the wrong model, which
|
|
151
|
+
* is worse than missing an unusual phrasing.
|
|
152
|
+
*
|
|
153
|
+
* It still cannot widen reach: like every strategy it only ever chooses among candidates the
|
|
154
|
+
* hard constraints already allowed. A device-only request naming a cloud model still stays
|
|
155
|
+
* on-device.
|
|
156
|
+
*/
|
|
157
|
+
export const explicitModelStrategy = defineRouteStrategy({
|
|
158
|
+
id: 'named-by-user',
|
|
159
|
+
label: 'Use the model you asked for',
|
|
160
|
+
classUsed: 'R',
|
|
161
|
+
decide: async (eligible, need) => {
|
|
162
|
+
const text = String(need.requestText || '').toLowerCase();
|
|
163
|
+
if (!text) return null;
|
|
164
|
+
const directive = /\b(?:use|using|with|via|ask|switch to|route to|try)\s+([a-z0-9][a-z0-9.\- ]{1,28})/g;
|
|
165
|
+
const asked = [];
|
|
166
|
+
for (const m of text.matchAll(directive)) asked.push(m[1].trim());
|
|
167
|
+
if (!asked.length) return null;
|
|
168
|
+
|
|
169
|
+
const matches = eligible.filter((cand) => {
|
|
170
|
+
const names = [cand.label, cand.model, cand.id].filter(Boolean).map((x) => String(x).toLowerCase());
|
|
171
|
+
return asked.some((want) => names.some((n) => n.includes(want) || want.includes(n.split(' · ')[0])));
|
|
172
|
+
});
|
|
173
|
+
if (!matches.length) return null; // named something we do not have? say nothing and let the rest decide
|
|
174
|
+
// WHICH ONE, when the name matches several. This took the FIRST match in score order, so
|
|
175
|
+
// "use claude" on a setup with three Claude routes picked whichever happened to score
|
|
176
|
+
// best — a cost-and-latency guess deciding a question the user had already answered
|
|
177
|
+
// twice: once by naming the model, and once by ordering the routes to it.
|
|
178
|
+
//
|
|
179
|
+
// Returned as a LIST rather than a single model, so the ones that also matched become the
|
|
180
|
+
// runners-up: if the first declines, failover replaces it with another route to the model
|
|
181
|
+
// that was actually asked for.
|
|
182
|
+
return [...matches].sort((a, b) => pinnedOrderOf(a) - pinnedOrderOf(b));
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Ordered deliberately: an explicit request outranks every heuristic, because the user has
|
|
187
|
+
// answered the question the router was about to guess at. Failover next — a decline is newer
|
|
188
|
+
// information than the preference that made the original choice. Escalation is the general
|
|
189
|
+
// case.
|
|
190
|
+
export const ROUTE_STRATEGIES = [explicitModelStrategy, failoverStrategy, complexityStrategy];
|
|
191
|
+
export const ROUTE_MIDDLEWARE = [redactionStep];
|
|
192
|
+
|
|
193
|
+
export function needForTurn({ capabilities = [], request = null, structured = false, pageTools = false, force = false, background = false, guard = null } = {}) {
|
|
194
|
+
const signals = request ? signalsFrom(request) : {};
|
|
195
|
+
// REQUIREMENTS FIRST. What the work needs eliminates candidates; cost and speed only order
|
|
196
|
+
// what survives. A preference lets an unsuitable model win once the better ones decline,
|
|
197
|
+
// which is exactly how a chain of five ended on one that could not do the job.
|
|
198
|
+
const req = requirementsFor(signals, { structured, pageTools, hasTools: capabilities.includes('tools'), background });
|
|
199
|
+
// WHERE IT CAME FROM IS A CEILING, NOT A PREFERENCE. Routing asked what the work needed and
|
|
200
|
+
// never asked what it was about, so an internal page was summarised by a public inference
|
|
201
|
+
// host. This narrows reach and can only narrow it — reach is never relaxed (see the
|
|
202
|
+
// relaxation order in the router), so no later step can trade it away for capability.
|
|
203
|
+
// WHICH AXIS THIS REQUEST CARES ABOUT, read from the request rather than fixed at
|
|
204
|
+
// 'balanced' for everything. A greeting means fast — no answer to "hi" is improved by a
|
|
205
|
+
// frontier model thinking about it. A refactor means good — three seconds saved on an
|
|
206
|
+
// answer that has to be redone is not a saving. It only ever ORDERS what already
|
|
207
|
+
// qualifies; `req` above is what eliminates.
|
|
208
|
+
const pref = preferenceFor(signals, {
|
|
209
|
+
structured,
|
|
210
|
+
minQuality: req.minQuality,
|
|
211
|
+
// The same fact requirementsFor was given: a turn carrying tools is one that might use
|
|
212
|
+
// them, so it is never "answer fast at any quality".
|
|
213
|
+
hasTools: capabilities.includes('tools'),
|
|
214
|
+
background,
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
prefer: pref.prefer,
|
|
218
|
+
reach: guard ? guard.reach : 'any',
|
|
219
|
+
capabilities: [...new Set([...capabilities, ...req.required])],
|
|
220
|
+
minQuality: req.minQuality,
|
|
221
|
+
// Which requirements may be given up if nothing qualifies — never `tools`, and never
|
|
222
|
+
// reach. See requirementsFor.
|
|
223
|
+
negotiable: req.negotiable,
|
|
224
|
+
requirementReasons: [...(guard ? [...req.why, guard.why] : req.why), pref.why],
|
|
225
|
+
sourceGuard: guard,
|
|
226
|
+
signals,
|
|
227
|
+
requestText: request ? String(request.text || (request.messages || []).map((m) => m?.content || '').join('\n')).slice(-2000) : '',
|
|
228
|
+
structured,
|
|
229
|
+
force,
|
|
230
|
+
background,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// An SCM CONNECTION — how a harness engine reaches the organisation's source-control hub —
|
|
2
|
+
// and the two names a job gets in it: its branch and its worktree directory.
|
|
3
|
+
// (architecture-pillars.md §14: the repository is the source of truth.)
|
|
4
|
+
//
|
|
5
|
+
// ChatPanel is not a git host and not a git client. The harness runs `git`, `gh`, `glab`;
|
|
6
|
+
// ChatPanel brings the CREDENTIAL, the WORKTREE, the BRANCH NAME and the RECORD. This module
|
|
7
|
+
// is the pure part: what a connection record holds, how a remote URL maps to one, what a
|
|
8
|
+
// job's branch is called, and the environment that hands a token to git for ONE process
|
|
9
|
+
// without writing it anywhere.
|
|
10
|
+
//
|
|
11
|
+
// THE TOKEN IS NEVER IN THE RECORD. A connection stores a `secretRef` — the name under which
|
|
12
|
+
// the machine's keychain (or the bridge's secret store) holds the token. The record travels
|
|
13
|
+
// with the client-prefs document (`connections` section) so both clients show the same
|
|
14
|
+
// connections; the secret does not travel, and `normalizeConnection` REFUSES a record that
|
|
15
|
+
// carries one, so a settings export, a sync, or a prompt can never carry it either.
|
|
16
|
+
|
|
17
|
+
export const SCM_KINDS = Object.freeze(['github', 'github-enterprise', 'gitlab', 'bitbucket', 'gitea', 'azure-devops', 'git']);
|
|
18
|
+
export const CONNECTION_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
19
|
+
export const SECRET_REF_RE = /^[a-zA-Z0-9_.:@/-]{1,200}$/;
|
|
20
|
+
const SECRET_FIELDS = ['token', 'password', 'secret', 'pat', 'apiKey', 'key'];
|
|
21
|
+
const DEFAULT_HOST = { github: 'github.com', gitlab: 'gitlab.com', bitbucket: 'bitbucket.org', 'azure-devops': 'dev.azure.com' };
|
|
22
|
+
|
|
23
|
+
export class ScmError extends Error {
|
|
24
|
+
constructor(code, message) { super(message); this.name = 'ScmError'; this.code = code; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
28
|
+
|
|
29
|
+
/** The host a connection serves: its baseUrl's, or the kind's public host. */
|
|
30
|
+
export function hostOf(conn) {
|
|
31
|
+
const url = String(conn?.baseUrl || '').trim();
|
|
32
|
+
if (url) { try { return new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host.toLowerCase(); } catch { return url.toLowerCase().replace(/^https?:\/\//, '').split('/')[0]; } }
|
|
33
|
+
return DEFAULT_HOST[conn?.kind] || '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateConnection(conn) {
|
|
37
|
+
const errors = [];
|
|
38
|
+
if (!isRecord(conn)) return { ok: false, errors: ['connection must be an object'] };
|
|
39
|
+
if (!CONNECTION_ID_RE.test(String(conn.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
|
|
40
|
+
if (!SCM_KINDS.includes(conn.kind)) errors.push(`kind: one of ${SCM_KINDS.join(', ')}`);
|
|
41
|
+
const needsHost = ['github-enterprise', 'gitea', 'git'].includes(conn.kind);
|
|
42
|
+
if (needsHost && !String(conn.baseUrl || '').trim()) errors.push('baseUrl: the hub\'s URL');
|
|
43
|
+
if (conn.baseUrl && !/^(https?:\/\/)?[a-z0-9.-]+(:\d+)?(\/[^\s]*)?$/i.test(String(conn.baseUrl).trim())) errors.push('baseUrl: a host or URL');
|
|
44
|
+
const leaked = SECRET_FIELDS.filter((k) => conn[k] !== undefined && conn[k] !== null && conn[k] !== '');
|
|
45
|
+
if (leaked.length) errors.push(`${leaked.join(', ')}: a connection never stores a secret — store it in the keychain and reference it by secretRef`);
|
|
46
|
+
if (conn.secretRef !== undefined && conn.secretRef !== null && conn.secretRef !== '' && !SECRET_REF_RE.test(String(conn.secretRef))) errors.push('secretRef: a keychain / secret-store name');
|
|
47
|
+
if (conn.reach !== undefined && !Array.isArray(conn.reach)) errors.push('reach: a list of remotes or owners this connection may be used for');
|
|
48
|
+
return { ok: errors.length === 0, errors };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The stored form. Throws on a record that carries a secret. */
|
|
52
|
+
export function normalizeConnection(conn) {
|
|
53
|
+
const v = validateConnection(conn);
|
|
54
|
+
if (!v.ok) throw new ScmError('INVALID', v.errors.join('; '));
|
|
55
|
+
const id = String(conn.id);
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
kind: conn.kind,
|
|
59
|
+
label: String(conn.label || conn.name || '').trim().slice(0, 80) || `${conn.kind} · ${hostOf(conn) || id}`,
|
|
60
|
+
...(conn.baseUrl ? { baseUrl: String(conn.baseUrl).trim().replace(/\/+$/, '') } : {}),
|
|
61
|
+
// The secret's NAME — `chatpanel:scm:<id>` by default — under which the keychain holds it.
|
|
62
|
+
secretRef: String(conn.secretRef || '').trim() || `chatpanel:scm:${id}`,
|
|
63
|
+
...(conn.username ? { username: String(conn.username).trim().slice(0, 80) } : {}),
|
|
64
|
+
// Which remotes it may be used for: `owner/*`, `owner/name`, or a host. Empty = any on its host.
|
|
65
|
+
reach: (Array.isArray(conn.reach) ? conn.reach : []).map((r) => String(r).trim().toLowerCase()).filter(Boolean).slice(0, 64),
|
|
66
|
+
enabled: conn.enabled !== false,
|
|
67
|
+
...(conn.createdAt ? { createdAt: conn.createdAt } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A remote URL, read: `{ host, owner, name, protocol }` for https, ssh (`git@host:o/n.git`,
|
|
73
|
+
* `ssh://git@host/o/n`) and plain `host/o/n`. Credentials in the URL are dropped, never
|
|
74
|
+
* returned. Null for what cannot be read.
|
|
75
|
+
*/
|
|
76
|
+
export function parseRemote(url) {
|
|
77
|
+
const s = String(url || '').trim();
|
|
78
|
+
if (!s) return null;
|
|
79
|
+
let m = /^(?:ssh:\/\/)?(?:[\w.-]+@)?([a-z0-9.-]+)(?::\d+)?[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(s.replace(/^https?:\/\/(?:[^@/]+@)?/i, ''));
|
|
80
|
+
if (m && !/^https?:/i.test(s)) return { host: m[1].toLowerCase(), owner: m[2], name: m[3], protocol: /^ssh:|^[\w.-]+@/.test(s) ? 'ssh' : 'plain' };
|
|
81
|
+
try {
|
|
82
|
+
const u = new URL(s);
|
|
83
|
+
const parts = u.pathname.replace(/\.git$/, '').split('/').filter(Boolean);
|
|
84
|
+
if (parts.length < 2) return null;
|
|
85
|
+
// Azure DevOps: /org/project/_git/repo; GitLab subgroups: /a/b/c/repo — owner is all but the last.
|
|
86
|
+
const name = parts.at(-1);
|
|
87
|
+
const owner = parts.slice(0, -1).filter((p) => p !== '_git').join('/');
|
|
88
|
+
return { host: u.host.toLowerCase(), owner, name, protocol: u.protocol.replace(':', '') };
|
|
89
|
+
} catch { return null; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The connection to use for a remote: enabled, same host, and — when the connection lists
|
|
94
|
+
* a reach — the remote's `owner/name` or `owner/*` in it. The most specific reach wins.
|
|
95
|
+
*/
|
|
96
|
+
export function connectionFor(remote, connections = []) {
|
|
97
|
+
const r = typeof remote === 'string' ? parseRemote(remote) : remote;
|
|
98
|
+
if (!r) return null;
|
|
99
|
+
const slug = `${r.owner}/${r.name}`.toLowerCase();
|
|
100
|
+
let best = null; let bestScore = -1;
|
|
101
|
+
for (const c of connections || []) {
|
|
102
|
+
if (!c || c.enabled === false) continue;
|
|
103
|
+
if (hostOf(c) !== r.host) continue;
|
|
104
|
+
const reach = Array.isArray(c.reach) ? c.reach : [];
|
|
105
|
+
let score = reach.length ? -1 : 0;
|
|
106
|
+
for (const x of reach) {
|
|
107
|
+
if (x === slug) score = Math.max(score, 3);
|
|
108
|
+
else if (x.endsWith('/*') && slug.startsWith(x.slice(0, -1))) score = Math.max(score, 2);
|
|
109
|
+
else if (x === r.host) score = Math.max(score, 1);
|
|
110
|
+
}
|
|
111
|
+
if (score > bestScore) { best = c; bestScore = score; }
|
|
112
|
+
}
|
|
113
|
+
return best;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const slug = (s) => String(s || '').trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 48) || 'x';
|
|
117
|
+
|
|
118
|
+
/** A job's branch: `cp/<project>/<job>` — one branch per job, always under `cp/`, never a protected name. */
|
|
119
|
+
export function branchFor(projectId, jobId) { return `cp/${slug(projectId)}/${slug(jobId)}`; }
|
|
120
|
+
|
|
121
|
+
/** A job's worktree directory under the bridge's worktree root: `<project>/<job>`. */
|
|
122
|
+
export function worktreeDirFor(projectId, jobId) { return `${slug(projectId)}/${slug(jobId)}`; }
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The environment that hands ONE process the token, scoped to the connection's host:
|
|
126
|
+
* git's `credential.<url>.helper` set through `GIT_CONFIG_*` (never a file, never the
|
|
127
|
+
* command line), a helper that reads the token from the environment, terminal prompts
|
|
128
|
+
* off, and the hub CLIs' own variables (`GH_TOKEN` / `GH_HOST`, `GITLAB_TOKEN`) so `gh` and
|
|
129
|
+
* `glab` work in the same process. The caller spawns with `{ ...process.env, ...env }` and
|
|
130
|
+
* the token dies with the process.
|
|
131
|
+
*/
|
|
132
|
+
export function credentialEnv(conn, token, { username = null } = {}) {
|
|
133
|
+
const c = conn || {};
|
|
134
|
+
const host = hostOf(c);
|
|
135
|
+
if (!host || !token) return {};
|
|
136
|
+
const user = username || c.username || ({ github: 'x-access-token', 'github-enterprise': 'x-access-token', gitlab: 'oauth2', bitbucket: 'x-token-auth', gitea: 'oauth2', 'azure-devops': 'pat' }[c.kind] || 'token');
|
|
137
|
+
const env = {
|
|
138
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
139
|
+
GIT_CONFIG_COUNT: '2',
|
|
140
|
+
GIT_CONFIG_KEY_0: `credential.https://${host}.helper`,
|
|
141
|
+
// A shell helper: on `get`, answer from the environment. Nothing is written anywhere.
|
|
142
|
+
GIT_CONFIG_VALUE_0: '!f() { if [ "$1" = get ]; then printf "username=%s\\npassword=%s\\n" "$CHATPANEL_SCM_USER" "$CHATPANEL_SCM_TOKEN"; fi; }; f',
|
|
143
|
+
GIT_CONFIG_KEY_1: `credential.https://${host}.useHttpPath`,
|
|
144
|
+
GIT_CONFIG_VALUE_1: 'false',
|
|
145
|
+
CHATPANEL_SCM_USER: user,
|
|
146
|
+
CHATPANEL_SCM_TOKEN: String(token),
|
|
147
|
+
};
|
|
148
|
+
if (c.kind === 'github' || c.kind === 'github-enterprise') {
|
|
149
|
+
env.GH_TOKEN = String(token);
|
|
150
|
+
if (c.kind === 'github-enterprise') { env.GH_ENTERPRISE_TOKEN = String(token); env.GH_HOST = host; }
|
|
151
|
+
}
|
|
152
|
+
if (c.kind === 'gitlab') { env.GITLAB_TOKEN = String(token); env.GITLAB_HOST = host; }
|
|
153
|
+
return env;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One line a person reads: "GitHub · github.com · acme/*". */
|
|
157
|
+
export function describeConnection(conn) {
|
|
158
|
+
const c = conn || {};
|
|
159
|
+
const kindLabel = { github: 'GitHub', 'github-enterprise': 'GitHub Enterprise', gitlab: 'GitLab', bitbucket: 'Bitbucket', gitea: 'Gitea', 'azure-devops': 'Azure DevOps', git: 'Git' }[c.kind] || c.kind;
|
|
160
|
+
return `${kindLabel} · ${hostOf(c)}${c.reach?.length ? ` · ${c.reach.join(', ')}` : ''}${c.enabled === false ? ' (off)' : ''}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function blankConnection() { return { id: '', kind: 'github', label: '', baseUrl: '', reach: [], username: '' }; }
|
|
164
|
+
|
|
165
|
+
/** The editor's form → a connection, or the errors. The token is NOT part of the form's record: the host stores it separately. */
|
|
166
|
+
export function connectionFromForm(form) {
|
|
167
|
+
const f = form || {};
|
|
168
|
+
const id = String(f.id || '').trim() || slug(f.label || `${f.kind}-${hostOf(f)}`);
|
|
169
|
+
const conn = {
|
|
170
|
+
id, kind: f.kind || 'github', label: String(f.label || '').trim(),
|
|
171
|
+
...(f.baseUrl ? { baseUrl: String(f.baseUrl).trim() } : {}),
|
|
172
|
+
...(f.username ? { username: String(f.username).trim() } : {}),
|
|
173
|
+
...(f.secretRef ? { secretRef: String(f.secretRef).trim() } : {}),
|
|
174
|
+
reach: String(Array.isArray(f.reach) ? f.reach.join(',') : f.reach || '').split(/[,\s]+/).map((x) => x.trim()).filter(Boolean),
|
|
175
|
+
enabled: f.enabled !== false,
|
|
176
|
+
...(f.createdAt ? { createdAt: f.createdAt } : {}),
|
|
177
|
+
};
|
|
178
|
+
const v = validateConnection(conn);
|
|
179
|
+
return v.ok ? { ok: true, connection: normalizeConnection(conn) } : { ok: false, errors: v.errors };
|
|
180
|
+
}
|
package/scorecard.js
CHANGED
|
@@ -13,11 +13,59 @@
|
|
|
13
13
|
// against a job's needs with that record — the same function the evaluator starts from, so
|
|
14
14
|
// an application's fit has reasons a person can read and overrule.
|
|
15
15
|
//
|
|
16
|
+
// The model is a variable, not a constant (architecture-pillars.md §13): every task fact also
|
|
17
|
+
// says which ENGINE did it — a model endpoint or a harness (a CLI coding agent), and for a
|
|
18
|
+
// harness the model it was asked to run — so `summarize` can split the card by engine
|
|
19
|
+
// (`byEngine`) and a recruiter can tell an agent that did well on a small model from one
|
|
20
|
+
// that was carried by a large one. And where the task ran in a git checkout (§14), the fact
|
|
21
|
+
// carries `scm`: the branch and HEAD before and after, commits made, a PR when one was
|
|
22
|
+
// opened, and whether it merged — the one outcome that does not come from a judge.
|
|
23
|
+
//
|
|
16
24
|
// Dependency-free: hashing is `crypto.subtle` (browser, Node, a phone), injectable for tests.
|
|
17
25
|
|
|
18
26
|
export const SCORECARD_ENTRY_KINDS = Object.freeze(['task.done', 'task.failed', 'rating', 'created', 'interaction', 'role']);
|
|
19
27
|
export const ROLE_KINDS = Object.freeze(['ic', 'orchestrator', 'manager', 'manager-of-managers']);
|
|
20
28
|
export const SCORECARD_VERSION = 1;
|
|
29
|
+
export const ENGINE_KINDS = Object.freeze(['model', 'harness']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An engine as the record keeps it: `{ kind, id, model?, label? }`. `kind` is `model` (an
|
|
33
|
+
* endpoint the client calls) or `harness` (a CLI coding agent the bridge runs — `id` is the
|
|
34
|
+
* harness, `model` the model it was asked to run, when one was named). A host that does not
|
|
35
|
+
* say the kind gets `model`, which is the honest default for a bare model id. A string is
|
|
36
|
+
* an id.
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeEngine(e) {
|
|
39
|
+
if (!e) return null;
|
|
40
|
+
const src = typeof e === 'string' ? { id: e } : e;
|
|
41
|
+
const id = String(src.id || src.harnessId || src.model || '').trim();
|
|
42
|
+
if (!id) return null;
|
|
43
|
+
const kind = ENGINE_KINDS.includes(src.kind) ? src.kind : (src.harnessId ? 'harness' : 'model');
|
|
44
|
+
const model = src.model != null && String(src.model).trim() && String(src.model) !== id ? String(src.model).trim() : undefined;
|
|
45
|
+
return { kind, id, ...(model ? { model } : {}), ...(src.label && String(src.label) !== id ? { label: String(src.label).slice(0, 120) } : {}) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One key per engine — what `byEngine` groups on and what the model ledger will be keyed by. */
|
|
49
|
+
export function engineKey(e) {
|
|
50
|
+
const n = normalizeEngine(e);
|
|
51
|
+
return n ? `${n.kind}:${n.id}${n.model ? `/${n.model}` : ''}` : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What a task did in a checkout, as the record keeps it. Strings clipped, counts rounded. */
|
|
55
|
+
export function normalizeScm(s) {
|
|
56
|
+
if (!s || typeof s !== 'object') return null;
|
|
57
|
+
const str = (v, n = 200) => (v == null || v === '' ? undefined : String(v).slice(0, n));
|
|
58
|
+
const out = {
|
|
59
|
+
repo: str(s.repo, 300), remote: str(s.remote, 300), base: str(s.base, 120), branch: str(s.branch, 120),
|
|
60
|
+
head: str(s.head, 64), headAfter: str(s.headAfter, 64),
|
|
61
|
+
commits: s.commits != null ? Math.max(0, Math.round(Number(s.commits) || 0)) : undefined,
|
|
62
|
+
pr: str(s.pr, 300),
|
|
63
|
+
merged: s.merged === true ? true : s.merged === false ? false : undefined,
|
|
64
|
+
dirty: s.dirty === true ? true : s.dirty === false ? false : undefined,
|
|
65
|
+
};
|
|
66
|
+
for (const k of Object.keys(out)) if (out[k] === undefined) delete out[k];
|
|
67
|
+
return Object.keys(out).length ? out : null;
|
|
68
|
+
}
|
|
21
69
|
|
|
22
70
|
const enc = new TextEncoder();
|
|
23
71
|
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
@@ -60,6 +108,8 @@ export async function makeEntry(fact, prev, { now = () => Date.now(), subtle } =
|
|
|
60
108
|
...(fact.runId ? { runId: String(fact.runId) } : {}),
|
|
61
109
|
...(fact.taskId ? { taskId: String(fact.taskId) } : {}),
|
|
62
110
|
...(fact.model ? { model: String(fact.model) } : {}),
|
|
111
|
+
...(normalizeEngine(fact.engine) ? { engine: normalizeEngine(fact.engine) } : {}),
|
|
112
|
+
...(normalizeScm(fact.scm) ? { scm: normalizeScm(fact.scm) } : {}),
|
|
63
113
|
...(fact.size ? { size: sizeOf(fact.size) } : {}),
|
|
64
114
|
...(fact.roleKind ? { roleKind: ROLE_KINDS.includes(fact.roleKind) ? fact.roleKind : 'ic' } : {}),
|
|
65
115
|
...(Array.isArray(fact.tools) && fact.tools.length ? { tools: [...new Set(fact.tools.map(String))].sort() } : {}),
|
|
@@ -75,6 +125,7 @@ export async function makeEntry(fact, prev, { now = () => Date.now(), subtle } =
|
|
|
75
125
|
}
|
|
76
126
|
|
|
77
127
|
const clamp01 = (n) => Math.max(0, Math.min(1, Number(n) || 0));
|
|
128
|
+
const r3 = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
|
|
78
129
|
const sizeOf = (s) => ({ ms: Math.max(0, Math.round(Number(s.ms) || 0)), steps: Math.max(0, Math.round(Number(s.steps) || 0)), tools: Math.max(0, Math.round(Number(s.tools) || 0)), findings: Math.max(0, Math.round(Number(s.findings) || 0)), tokens: Math.max(0, Math.round(Number(s.tokens) || 0)) });
|
|
79
130
|
|
|
80
131
|
/** Does every link hold? Returns `{ ok, at }` — `at` is the seq of the first broken entry. */
|
|
@@ -120,13 +171,53 @@ export function summarize(entries, { recent = 5 } = {}) {
|
|
|
120
171
|
const tools = new Set(); const withAgents = new Set(); const created = new Set();
|
|
121
172
|
const roles = { ic: 0, orchestrator: 0, manager: 0, 'manager-of-managers': 0 };
|
|
122
173
|
const models = new Map();
|
|
174
|
+
// Per engine: how many tasks, how they went, how big, and the ratings that were ABOUT a
|
|
175
|
+
// task on it. A rating names its task by `about` (the seq of the entry it rates), by
|
|
176
|
+
// taskId, or — failing both — by runId when that run had exactly one task on the card.
|
|
177
|
+
const engines = new Map(); // key -> { engine, tasks, done, failed, tokens, ratings[] }
|
|
178
|
+
const engineOfEntry = new Map(); // seq -> key
|
|
179
|
+
const byTask = new Map(); const byRun = new Map(); // taskId -> seq, runId -> [seq]
|
|
180
|
+
const scm = { tasks: 0, commits: 0, prs: 0, merged: 0 };
|
|
123
181
|
for (const e of list) {
|
|
124
182
|
for (const t of e.tools || []) tools.add(t);
|
|
125
183
|
for (const a of e.with || []) withAgents.add(a);
|
|
126
184
|
for (const a of e.created || []) created.add(a);
|
|
127
185
|
if (e.roleKind && (e.kind === 'task.done' || e.kind === 'task.failed' || e.kind === 'role')) roles[e.roleKind] = (roles[e.roleKind] || 0) + 1;
|
|
128
186
|
if (e.model) models.set(e.model, (models.get(e.model) || 0) + 1);
|
|
187
|
+
if (e.kind === 'task.done' || e.kind === 'task.failed') {
|
|
188
|
+
const key = engineKey(e.engine);
|
|
189
|
+
if (key) {
|
|
190
|
+
const row = engines.get(key) || { engine: normalizeEngine(e.engine), tasks: 0, done: 0, failed: 0, tokens: 0, ratings: [] };
|
|
191
|
+
row.tasks += 1; row[e.kind === 'task.done' ? 'done' : 'failed'] += 1; row.tokens += e.size?.tokens || 0;
|
|
192
|
+
engines.set(key, row);
|
|
193
|
+
engineOfEntry.set(e.seq, key);
|
|
194
|
+
if (e.taskId) byTask.set(`${e.runId || ''}/${e.taskId}`, e.seq);
|
|
195
|
+
if (e.runId) byRun.set(e.runId, [...(byRun.get(e.runId) || []), e.seq]);
|
|
196
|
+
}
|
|
197
|
+
if (e.scm) { scm.tasks += 1; scm.commits += e.scm.commits || 0; if (e.scm.pr) scm.prs += 1; if (e.scm.merged) scm.merged += 1; }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const e of list) {
|
|
201
|
+
if (e.kind !== 'rating' || !e.rating) continue;
|
|
202
|
+
const seq = e.rating.about != null ? e.rating.about
|
|
203
|
+
: e.taskId && byTask.has(`${e.runId || ''}/${e.taskId}`) ? byTask.get(`${e.runId || ''}/${e.taskId}`)
|
|
204
|
+
: e.runId && (byRun.get(e.runId) || []).length === 1 ? byRun.get(e.runId)[0] : null;
|
|
205
|
+
const key = seq != null ? engineOfEntry.get(seq) : null;
|
|
206
|
+
if (key) engines.get(key).ratings.push(e.rating.score);
|
|
129
207
|
}
|
|
208
|
+
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
|
209
|
+
const byEngine = [...engines.values()].sort((a, b) => b.tasks - a.tasks).map((r) => ({
|
|
210
|
+
key: engineKey(r.engine), ...r.engine, tasks: r.tasks, done: r.done, failed: r.failed,
|
|
211
|
+
failRate: r.tasks ? Math.round((r.failed / r.tasks) * 1000) / 1000 : 0,
|
|
212
|
+
tokens: r.tasks ? Math.round(r.tokens / r.tasks) : 0, // mean per task — the cost proxy until the ledger prices it
|
|
213
|
+
rating: { avg: mean(r.ratings), count: r.ratings.length },
|
|
214
|
+
}));
|
|
215
|
+
// 1 − spread of rating across engines it was rated on (≥ 2): low spread = robust to routing;
|
|
216
|
+
// high spread = it NEEDS a particular engine, a fact a recruiter respects, not a penalty.
|
|
217
|
+
const ratedEngines = byEngine.filter((r) => r.rating.avg != null).map((r) => r.rating.avg);
|
|
218
|
+
const engineIndependence = ratedEngines.length >= 2 ? Math.round((1 - (Math.max(...ratedEngines) - Math.min(...ratedEngines))) * 1000) / 1000 : null;
|
|
219
|
+
// Leverage (rating above the engine's own mean) and efficiency (rating ÷ cost) wait on the
|
|
220
|
+
// model ledger (§13.2, with A1): they need every engine's mean, which one card cannot know.
|
|
130
221
|
const ratings = list.filter((e) => e.kind === 'rating' && e.rating).map((e) => e.rating.score);
|
|
131
222
|
const avg = ratings.length ? ratings.reduce((a, b) => a + b, 0) / ratings.length : null;
|
|
132
223
|
const recentRatings = ratings.slice(-recent);
|
|
@@ -142,6 +233,9 @@ export function summarize(entries, { recent = 5 } = {}) {
|
|
|
142
233
|
created: [...created],
|
|
143
234
|
roles,
|
|
144
235
|
models: [...models.entries()].sort((a, b) => b[1] - a[1]).map(([m, n]) => ({ model: m, tasks: n })),
|
|
236
|
+
byEngine,
|
|
237
|
+
engineIndependence,
|
|
238
|
+
scm,
|
|
145
239
|
rating: { avg, count: ratings.length, recent: recentRatings.length ? recentRatings.reduce((a, b) => a + b, 0) / recentRatings.length : null },
|
|
146
240
|
refs,
|
|
147
241
|
since: list[0]?.at || null,
|
|
@@ -156,7 +250,7 @@ export function summarize(entries, { recent = 5 } = {}) {
|
|
|
156
250
|
* summary is `summarize()`'s. Returns `{ score, reasons }` in [0, 1] — needs first (a type
|
|
157
251
|
* without the tools cannot do the job), track record second, size third.
|
|
158
252
|
*/
|
|
159
|
-
export function fit(job, type, summary = null) {
|
|
253
|
+
export function fit(job, type, summary = null, { qualityOf = null, costOf = null, adjust = true } = {}) {
|
|
160
254
|
const needs = job?.needs || {};
|
|
161
255
|
const have = (xs) => new Set((xs || []).map((x) => String(x).toLowerCase()));
|
|
162
256
|
const skills = have(type?.skills); const tools = have(type?.tools); const grants = have(type?.grants);
|
|
@@ -174,11 +268,18 @@ export function fit(job, type, summary = null) {
|
|
|
174
268
|
const needScore = (cSkills * 0.5 + cTools * 0.3 + cGrants * 0.2);
|
|
175
269
|
if (needScore === 1) reasons.push('has every skill, tool and grant the job names');
|
|
176
270
|
let record = 0.5; // a fresh type is neither trusted nor distrusted
|
|
271
|
+
let adjusted = null;
|
|
177
272
|
if (summary && summary.entries) {
|
|
178
273
|
const doneRate = summary.jobsDone + summary.jobsFailed ? summary.jobsDone / (summary.jobsDone + summary.jobsFailed) : 0.5;
|
|
179
|
-
|
|
274
|
+
// The track record uses the MODEL-ADJUSTED rating when the caller can say what the
|
|
275
|
+
// engines were worth (§13.3): an agent rated 0.82 mostly on a weak engine ranks above
|
|
276
|
+
// one rated 0.82 on a frontier model. The reasons say so, and a person can turn it off.
|
|
277
|
+
adjusted = adjust && qualityOf && summary.rating.avg != null ? adjustSummary(summary, { qualityOf, costOf: costOf || undefined }) : null;
|
|
278
|
+
const rated = summary.rating.avg == null ? 0.5 : (adjusted?.adjusted ?? summary.rating.avg);
|
|
180
279
|
record = doneRate * 0.5 + rated * 0.5;
|
|
181
|
-
reasons.push(`${summary.jobsDone} done, ${summary.jobsFailed} failed${summary.rating.avg != null ? `, rated ${Math.round(summary.rating.avg * 100)}%` : ''}`);
|
|
280
|
+
reasons.push(`${summary.jobsDone} done, ${summary.jobsFailed} failed${summary.rating.avg != null ? (adjusted && adjusted.adjusted !== adjusted.raw ? `, rated ${Math.round(adjusted.raw * 100)}% raw, ${Math.round(adjusted.adjusted * 100)}% adjusted — ${adjusted.basis[0] || 'engine-corrected'}` : `, rated ${Math.round(summary.rating.avg * 100)}%`) : ''}`);
|
|
281
|
+
if (adjusted?.leverage != null && adjusted.leverage > 0.05) reasons.push(`adds ${adjusted.leverage} over its engines' own quality`);
|
|
282
|
+
if (adjusted?.efficiency) reasons.push(`cleared the bar cheapest on ${adjusted.efficiency.engine}`);
|
|
182
283
|
if (summary.roles.orchestrator + summary.roles.manager + summary.roles['manager-of-managers'] > 0) reasons.push(`has led: ${summary.roles.orchestrator} as orchestrator, ${summary.roles.manager} as manager`);
|
|
183
284
|
} else {
|
|
184
285
|
reasons.push('no record yet');
|
|
@@ -187,5 +288,48 @@ export function fit(job, type, summary = null) {
|
|
|
187
288
|
const sizeScore = !wantSize ? 1 : Math.min(1, (summary?.size?.largestSteps || 0) / wantSize) * 0.5 + 0.5;
|
|
188
289
|
if (wantSize && (summary?.size?.largestSteps || 0) < wantSize) reasons.push(`largest task so far ${summary?.size?.largestSteps || 0} steps; this one is ~${wantSize}`);
|
|
189
290
|
const score = Math.round((needScore * 0.6 + record * 0.3 + sizeScore * 0.1) * 1000) / 1000;
|
|
190
|
-
return { score, reasons, parts: { needs: needScore, record, size: sizeScore } };
|
|
291
|
+
return { score, reasons, parts: { needs: needScore, record, size: sizeScore }, ...(adjusted ? { adjusted } : {}) };
|
|
191
292
|
}
|
|
293
|
+
|
|
294
|
+
// ── Agent scores, normalised by engine (§13.3) — what the model ledger's cards make possible ──
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The model-adjusted view of an agent's card (scorecard.js `summarize()`), given what its
|
|
298
|
+
* engines are worth: `qualityOf(key)` → the engine's quality in [0, 1] (the card's mean
|
|
299
|
+
* rating for the job kind when observed, else the router's guess) or null when unknown.
|
|
300
|
+
*
|
|
301
|
+
* leverage rating on an engine minus that engine's quality, weighted by tasks — what
|
|
302
|
+
* the agent's prompt and tools add that the model does not supply on its own
|
|
303
|
+
* adjusted the raw rating corrected for the engines it ran on: work done on a weak
|
|
304
|
+
* engine counts for more, on a strong one for less; `k` bounds the correction
|
|
305
|
+
* efficiency adjusted rating ÷ cost per task on the cheapest engine that cleared `bar`
|
|
306
|
+
* (`costOf(key)` → $/task or a token proxy; null when nothing is priced)
|
|
307
|
+
*
|
|
308
|
+
* Returns `{ raw, adjusted, leverage, efficiency, basis[] }` with `basis` the reasons a
|
|
309
|
+
* person reads ("60 % of its tasks ran on a 0.3-quality engine").
|
|
310
|
+
*/
|
|
311
|
+
export function adjustSummary(summary, { qualityOf = () => null, costOf = () => null, reference = 0.6, k = 0.3, bar = 0.5 } = {}) {
|
|
312
|
+
const raw = summary?.rating?.avg ?? null;
|
|
313
|
+
const rows = (summary?.byEngine || []).filter((r) => r.key);
|
|
314
|
+
const known = rows.map((r) => ({ ...r, quality: qualityOf(r.key) })).filter((r) => Number.isFinite(r.quality));
|
|
315
|
+
const totalTasks = known.reduce((n, r) => n + r.tasks, 0);
|
|
316
|
+
const basis = [];
|
|
317
|
+
if (raw == null || !known.length || !totalTasks) return { raw, adjusted: raw, leverage: null, efficiency: null, basis: raw == null ? ['not rated yet'] : ['engines not rated yet — raw rating used'] };
|
|
318
|
+
// Correction: how far below the reference the engines it ran on sit, task-weighted.
|
|
319
|
+
const correction = k * known.reduce((s, r) => s + (r.tasks / totalTasks) * (reference - r.quality), 0);
|
|
320
|
+
const adjusted = Math.max(0, Math.min(1, raw + correction));
|
|
321
|
+
const weak = known.filter((r) => r.quality < reference);
|
|
322
|
+
if (weak.length) basis.push(`${Math.round((weak.reduce((n, r) => n + r.tasks, 0) / totalTasks) * 100)} % of its tasks ran on ${weak.length === 1 ? `a ${weak[0].quality}-quality engine` : 'engines below the reference'}`);
|
|
323
|
+
const strong = known.filter((r) => r.quality > reference);
|
|
324
|
+
if (strong.length && !weak.length) basis.push(`ran on engines above the reference (${strong.map((r) => r.quality).join(', ')})`);
|
|
325
|
+
// Leverage over the engines it was rated on.
|
|
326
|
+
const rated = known.filter((r) => r.rating?.avg != null);
|
|
327
|
+
const ratedTasks = rated.reduce((n, r) => n + r.rating.count, 0);
|
|
328
|
+
const leverage = ratedTasks ? r3(rated.reduce((s, r) => s + (r.rating.count / ratedTasks) * (r.rating.avg - r.quality), 0)) : null;
|
|
329
|
+
if (leverage != null) basis.push(`${leverage >= 0 ? '+' : ''}${leverage} over its engines' own quality`);
|
|
330
|
+
// Efficiency on the cheapest engine that cleared the bar.
|
|
331
|
+
const cleared = rated.filter((r) => r.rating.avg >= bar).map((r) => ({ ...r, cost: costOf(r.key) ?? (r.tokens || null) })).filter((r) => r.cost != null && r.cost > 0).sort((a, b) => a.cost - b.cost);
|
|
332
|
+
const efficiency = cleared.length ? { value: r3(adjusted / cleared[0].cost), engine: cleared[0].key, costPerTask: cleared[0].cost } : null;
|
|
333
|
+
return { raw: r3(raw), adjusted: r3(adjusted), leverage, efficiency, basis };
|
|
334
|
+
}
|
|
335
|
+
|