@chatpanel/gateway 0.6.86 → 0.6.90
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/package.json +1 -1
- package/src/budget.js +117 -0
- package/src/engine-ledger-store.js +150 -0
- package/src/engine.js +132 -0
- package/src/gate.js +75 -0
- package/src/job.js +149 -0
- package/src/model-ledger.js +205 -0
- package/src/project-store.js +139 -0
- package/src/project.js +171 -0
- package/src/scorecard-store.js +92 -0
- package/src/scorecard.js +336 -0
- package/src/server.js +143 -10
- package/src/team-store.js +8 -2
- package/src/team.js +303 -0
package/src/scorecard.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/scorecard.js — edit there, then copy over.
|
|
2
|
+
// An agent's scorecard — an immutable record of what it actually did, and matching on it.
|
|
3
|
+
//
|
|
4
|
+
// A scorecard is a CHAIN of entries, one per fact, append-only: a task it finished (how big,
|
|
5
|
+
// with which tools, alongside whom, in which role), a rating a job gave it, an agent it
|
|
6
|
+
// created, an interaction. Every entry carries the hash of the one before and its own hash,
|
|
7
|
+
// so an edit anywhere breaks every link after it; the gateway's store adds its own mark
|
|
8
|
+
// (an HMAC over the hash with a key only the store holds) so an entry a client — or an
|
|
9
|
+
// agent — wrote for itself shows as unattested. The facts are produced by the runner and
|
|
10
|
+
// attested by the store, never written by the agent: the only way to a better scorecard is
|
|
11
|
+
// the work.
|
|
12
|
+
//
|
|
13
|
+
// `summarize` turns the chain into the card a recruiter reads; `fit` scores an agent type
|
|
14
|
+
// against a job's needs with that record — the same function the evaluator starts from, so
|
|
15
|
+
// an application's fit has reasons a person can read and overrule.
|
|
16
|
+
//
|
|
17
|
+
// The model is a variable, not a constant (architecture-pillars.md §13): every task fact also
|
|
18
|
+
// says which ENGINE did it — a model endpoint or a harness (a CLI coding agent), and for a
|
|
19
|
+
// harness the model it was asked to run — so `summarize` can split the card by engine
|
|
20
|
+
// (`byEngine`) and a recruiter can tell an agent that did well on a small model from one
|
|
21
|
+
// that was carried by a large one. And where the task ran in a git checkout (§14), the fact
|
|
22
|
+
// carries `scm`: the branch and HEAD before and after, commits made, a PR when one was
|
|
23
|
+
// opened, and whether it merged — the one outcome that does not come from a judge.
|
|
24
|
+
//
|
|
25
|
+
// Dependency-free: hashing is `crypto.subtle` (browser, Node, a phone), injectable for tests.
|
|
26
|
+
|
|
27
|
+
export const SCORECARD_ENTRY_KINDS = Object.freeze(['task.done', 'task.failed', 'rating', 'created', 'interaction', 'role']);
|
|
28
|
+
export const ROLE_KINDS = Object.freeze(['ic', 'orchestrator', 'manager', 'manager-of-managers']);
|
|
29
|
+
export const SCORECARD_VERSION = 1;
|
|
30
|
+
export const ENGINE_KINDS = Object.freeze(['model', 'harness']);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* An engine as the record keeps it: `{ kind, id, model?, label? }`. `kind` is `model` (an
|
|
34
|
+
* endpoint the client calls) or `harness` (a CLI coding agent the bridge runs — `id` is the
|
|
35
|
+
* harness, `model` the model it was asked to run, when one was named). A host that does not
|
|
36
|
+
* say the kind gets `model`, which is the honest default for a bare model id. A string is
|
|
37
|
+
* an id.
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeEngine(e) {
|
|
40
|
+
if (!e) return null;
|
|
41
|
+
const src = typeof e === 'string' ? { id: e } : e;
|
|
42
|
+
const id = String(src.id || src.harnessId || src.model || '').trim();
|
|
43
|
+
if (!id) return null;
|
|
44
|
+
const kind = ENGINE_KINDS.includes(src.kind) ? src.kind : (src.harnessId ? 'harness' : 'model');
|
|
45
|
+
const model = src.model != null && String(src.model).trim() && String(src.model) !== id ? String(src.model).trim() : undefined;
|
|
46
|
+
return { kind, id, ...(model ? { model } : {}), ...(src.label && String(src.label) !== id ? { label: String(src.label).slice(0, 120) } : {}) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One key per engine — what `byEngine` groups on and what the model ledger will be keyed by. */
|
|
50
|
+
export function engineKey(e) {
|
|
51
|
+
const n = normalizeEngine(e);
|
|
52
|
+
return n ? `${n.kind}:${n.id}${n.model ? `/${n.model}` : ''}` : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What a task did in a checkout, as the record keeps it. Strings clipped, counts rounded. */
|
|
56
|
+
export function normalizeScm(s) {
|
|
57
|
+
if (!s || typeof s !== 'object') return null;
|
|
58
|
+
const str = (v, n = 200) => (v == null || v === '' ? undefined : String(v).slice(0, n));
|
|
59
|
+
const out = {
|
|
60
|
+
repo: str(s.repo, 300), remote: str(s.remote, 300), base: str(s.base, 120), branch: str(s.branch, 120),
|
|
61
|
+
head: str(s.head, 64), headAfter: str(s.headAfter, 64),
|
|
62
|
+
commits: s.commits != null ? Math.max(0, Math.round(Number(s.commits) || 0)) : undefined,
|
|
63
|
+
pr: str(s.pr, 300),
|
|
64
|
+
merged: s.merged === true ? true : s.merged === false ? false : undefined,
|
|
65
|
+
dirty: s.dirty === true ? true : s.dirty === false ? false : undefined,
|
|
66
|
+
};
|
|
67
|
+
for (const k of Object.keys(out)) if (out[k] === undefined) delete out[k];
|
|
68
|
+
return Object.keys(out).length ? out : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const enc = new TextEncoder();
|
|
72
|
+
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
73
|
+
|
|
74
|
+
/** Canonical JSON: keys sorted at every level, so the same fact hashes the same everywhere. */
|
|
75
|
+
export function canonical(v) {
|
|
76
|
+
if (v === null || typeof v !== 'object') return JSON.stringify(v);
|
|
77
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
78
|
+
return `{${Object.keys(v).sort().map((k) => (v[k] === undefined ? null : `${JSON.stringify(k)}:${canonical(v[k])}`)).filter(Boolean).join(',')}}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** SHA-256 over text, as hex; `subtle` is injectable (a runtime without it passes its own). */
|
|
82
|
+
export async function sha256(text, { subtle = globalThis.crypto?.subtle } = {}) {
|
|
83
|
+
if (!subtle) throw new Error('scorecard: no crypto.subtle — pass one');
|
|
84
|
+
return hex(await subtle.digest('SHA-256', enc.encode(String(text))));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The fields a hash covers — everything but the hash and the store's mark. */
|
|
88
|
+
function hashable(e) {
|
|
89
|
+
const { hash: _h, sig: _s, ...rest } = e;
|
|
90
|
+
return rest;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A new entry chained onto `prev` (the last entry, or null for the first). Pure apart from
|
|
95
|
+
* the digest: the caller (the store) decides whether it is attested.
|
|
96
|
+
*/
|
|
97
|
+
export async function makeEntry(fact, prev, { now = () => Date.now(), subtle } = {}) {
|
|
98
|
+
if (!fact || typeof fact !== 'object') throw new Error('scorecard: an entry needs a fact');
|
|
99
|
+
if (!SCORECARD_ENTRY_KINDS.includes(fact.kind)) throw new Error(`scorecard: kind must be one of ${SCORECARD_ENTRY_KINDS.join(', ')}`);
|
|
100
|
+
if (!fact.agentId) throw new Error('scorecard: agentId required');
|
|
101
|
+
const e = {
|
|
102
|
+
v: SCORECARD_VERSION,
|
|
103
|
+
seq: prev ? prev.seq + 1 : 0,
|
|
104
|
+
agentId: String(fact.agentId),
|
|
105
|
+
kind: fact.kind,
|
|
106
|
+
at: Number(fact.at) || now(),
|
|
107
|
+
...(fact.projectId ? { projectId: String(fact.projectId) } : {}),
|
|
108
|
+
...(fact.jobId ? { jobId: String(fact.jobId) } : {}),
|
|
109
|
+
...(fact.runId ? { runId: String(fact.runId) } : {}),
|
|
110
|
+
...(fact.taskId ? { taskId: String(fact.taskId) } : {}),
|
|
111
|
+
...(fact.model ? { model: String(fact.model) } : {}),
|
|
112
|
+
...(normalizeEngine(fact.engine) ? { engine: normalizeEngine(fact.engine) } : {}),
|
|
113
|
+
...(normalizeScm(fact.scm) ? { scm: normalizeScm(fact.scm) } : {}),
|
|
114
|
+
...(fact.size ? { size: sizeOf(fact.size) } : {}),
|
|
115
|
+
...(fact.roleKind ? { roleKind: ROLE_KINDS.includes(fact.roleKind) ? fact.roleKind : 'ic' } : {}),
|
|
116
|
+
...(Array.isArray(fact.tools) && fact.tools.length ? { tools: [...new Set(fact.tools.map(String))].sort() } : {}),
|
|
117
|
+
...(Array.isArray(fact.with) && fact.with.length ? { with: [...new Set(fact.with.map(String))].sort() } : {}),
|
|
118
|
+
...(Array.isArray(fact.created) && fact.created.length ? { created: [...new Set(fact.created.map(String))] } : {}),
|
|
119
|
+
...(fact.rating ? { rating: { by: String(fact.rating.by || 'person'), score: clamp01(fact.rating.score), ...(fact.rating.note ? { note: String(fact.rating.note).slice(0, 500) } : {}), ...(fact.rating.about != null ? { about: Number(fact.rating.about) } : {}) } } : {}),
|
|
120
|
+
...(Array.isArray(fact.refs) && fact.refs.length ? { refs: fact.refs.map(String).slice(0, 12) } : {}),
|
|
121
|
+
...(fact.error ? { error: String(fact.error).slice(0, 300) } : {}),
|
|
122
|
+
prev: prev ? prev.hash : null,
|
|
123
|
+
};
|
|
124
|
+
e.hash = await sha256(canonical(hashable(e)), { subtle });
|
|
125
|
+
return e;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const clamp01 = (n) => Math.max(0, Math.min(1, Number(n) || 0));
|
|
129
|
+
const r3 = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
|
|
130
|
+
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)) });
|
|
131
|
+
|
|
132
|
+
/** Does every link hold? Returns `{ ok, at }` — `at` is the seq of the first broken entry. */
|
|
133
|
+
export async function verifyChain(entries, { subtle } = {}) {
|
|
134
|
+
let prev = null;
|
|
135
|
+
for (const e of entries || []) {
|
|
136
|
+
if (!e || typeof e !== 'object') return { ok: false, at: prev ? prev.seq + 1 : 0, why: 'not an entry' };
|
|
137
|
+
if ((prev ? prev.seq + 1 : 0) !== e.seq) return { ok: false, at: e.seq, why: 'seq' };
|
|
138
|
+
if ((prev ? prev.hash : null) !== e.prev) return { ok: false, at: e.seq, why: 'prev' };
|
|
139
|
+
const h = await sha256(canonical(hashable(e)), { subtle });
|
|
140
|
+
if (h !== e.hash) return { ok: false, at: e.seq, why: 'hash' };
|
|
141
|
+
prev = e;
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, at: null, length: (entries || []).length };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The store's mark. `key` is raw bytes only the store holds; an HMAC-SHA-256 over the hash.
|
|
148
|
+
* Anyone can re-hash the chain; only the store can mark it, so an entry written elsewhere
|
|
149
|
+
* is honest about being unattested. Injectable `subtle` again.
|
|
150
|
+
*/
|
|
151
|
+
export async function attest(entry, key, { subtle = globalThis.crypto?.subtle } = {}) {
|
|
152
|
+
const k = await subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
153
|
+
return { ...entry, sig: hex(await subtle.sign('HMAC', k, enc.encode(entry.hash))) };
|
|
154
|
+
}
|
|
155
|
+
export async function verifyAttested(entries, key, { subtle = globalThis.crypto?.subtle } = {}) {
|
|
156
|
+
const k = await subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const e of entries || []) {
|
|
159
|
+
const sig = e?.sig ? new Uint8Array(e.sig.match(/../g).map((x) => parseInt(x, 16))) : null;
|
|
160
|
+
out.push(!!sig && await subtle.verify('HMAC', k, sig, enc.encode(e.hash)));
|
|
161
|
+
}
|
|
162
|
+
return { ok: out.every(Boolean), attested: out.filter(Boolean).length, of: out.length };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The card a recruiter reads. */
|
|
166
|
+
export function summarize(entries, { recent = 5 } = {}) {
|
|
167
|
+
const list = (entries || []).filter((e) => e && e.kind);
|
|
168
|
+
const done = list.filter((e) => e.kind === 'task.done');
|
|
169
|
+
const failed = list.filter((e) => e.kind === 'task.failed');
|
|
170
|
+
const sum = (k) => done.reduce((n, e) => n + (e.size?.[k] || 0), 0);
|
|
171
|
+
const largest = done.reduce((m, e) => Math.max(m, e.size?.steps || 0), 0);
|
|
172
|
+
const tools = new Set(); const withAgents = new Set(); const created = new Set();
|
|
173
|
+
const roles = { ic: 0, orchestrator: 0, manager: 0, 'manager-of-managers': 0 };
|
|
174
|
+
const models = new Map();
|
|
175
|
+
// Per engine: how many tasks, how they went, how big, and the ratings that were ABOUT a
|
|
176
|
+
// task on it. A rating names its task by `about` (the seq of the entry it rates), by
|
|
177
|
+
// taskId, or — failing both — by runId when that run had exactly one task on the card.
|
|
178
|
+
const engines = new Map(); // key -> { engine, tasks, done, failed, tokens, ratings[] }
|
|
179
|
+
const engineOfEntry = new Map(); // seq -> key
|
|
180
|
+
const byTask = new Map(); const byRun = new Map(); // taskId -> seq, runId -> [seq]
|
|
181
|
+
const scm = { tasks: 0, commits: 0, prs: 0, merged: 0 };
|
|
182
|
+
for (const e of list) {
|
|
183
|
+
for (const t of e.tools || []) tools.add(t);
|
|
184
|
+
for (const a of e.with || []) withAgents.add(a);
|
|
185
|
+
for (const a of e.created || []) created.add(a);
|
|
186
|
+
if (e.roleKind && (e.kind === 'task.done' || e.kind === 'task.failed' || e.kind === 'role')) roles[e.roleKind] = (roles[e.roleKind] || 0) + 1;
|
|
187
|
+
if (e.model) models.set(e.model, (models.get(e.model) || 0) + 1);
|
|
188
|
+
if (e.kind === 'task.done' || e.kind === 'task.failed') {
|
|
189
|
+
const key = engineKey(e.engine);
|
|
190
|
+
if (key) {
|
|
191
|
+
const row = engines.get(key) || { engine: normalizeEngine(e.engine), tasks: 0, done: 0, failed: 0, tokens: 0, ratings: [] };
|
|
192
|
+
row.tasks += 1; row[e.kind === 'task.done' ? 'done' : 'failed'] += 1; row.tokens += e.size?.tokens || 0;
|
|
193
|
+
engines.set(key, row);
|
|
194
|
+
engineOfEntry.set(e.seq, key);
|
|
195
|
+
if (e.taskId) byTask.set(`${e.runId || ''}/${e.taskId}`, e.seq);
|
|
196
|
+
if (e.runId) byRun.set(e.runId, [...(byRun.get(e.runId) || []), e.seq]);
|
|
197
|
+
}
|
|
198
|
+
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; }
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const e of list) {
|
|
202
|
+
if (e.kind !== 'rating' || !e.rating) continue;
|
|
203
|
+
const seq = e.rating.about != null ? e.rating.about
|
|
204
|
+
: e.taskId && byTask.has(`${e.runId || ''}/${e.taskId}`) ? byTask.get(`${e.runId || ''}/${e.taskId}`)
|
|
205
|
+
: e.runId && (byRun.get(e.runId) || []).length === 1 ? byRun.get(e.runId)[0] : null;
|
|
206
|
+
const key = seq != null ? engineOfEntry.get(seq) : null;
|
|
207
|
+
if (key) engines.get(key).ratings.push(e.rating.score);
|
|
208
|
+
}
|
|
209
|
+
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
|
210
|
+
const byEngine = [...engines.values()].sort((a, b) => b.tasks - a.tasks).map((r) => ({
|
|
211
|
+
key: engineKey(r.engine), ...r.engine, tasks: r.tasks, done: r.done, failed: r.failed,
|
|
212
|
+
failRate: r.tasks ? Math.round((r.failed / r.tasks) * 1000) / 1000 : 0,
|
|
213
|
+
tokens: r.tasks ? Math.round(r.tokens / r.tasks) : 0, // mean per task — the cost proxy until the ledger prices it
|
|
214
|
+
rating: { avg: mean(r.ratings), count: r.ratings.length },
|
|
215
|
+
}));
|
|
216
|
+
// 1 − spread of rating across engines it was rated on (≥ 2): low spread = robust to routing;
|
|
217
|
+
// high spread = it NEEDS a particular engine, a fact a recruiter respects, not a penalty.
|
|
218
|
+
const ratedEngines = byEngine.filter((r) => r.rating.avg != null).map((r) => r.rating.avg);
|
|
219
|
+
const engineIndependence = ratedEngines.length >= 2 ? Math.round((1 - (Math.max(...ratedEngines) - Math.min(...ratedEngines))) * 1000) / 1000 : null;
|
|
220
|
+
// Leverage (rating above the engine's own mean) and efficiency (rating ÷ cost) wait on the
|
|
221
|
+
// model ledger (§13.2, with A1): they need every engine's mean, which one card cannot know.
|
|
222
|
+
const ratings = list.filter((e) => e.kind === 'rating' && e.rating).map((e) => e.rating.score);
|
|
223
|
+
const avg = ratings.length ? ratings.reduce((a, b) => a + b, 0) / ratings.length : null;
|
|
224
|
+
const recentRatings = ratings.slice(-recent);
|
|
225
|
+
const refs = [...new Set(list.flatMap((e) => e.refs || []))].slice(-recent);
|
|
226
|
+
return {
|
|
227
|
+
agentId: list[0]?.agentId || null,
|
|
228
|
+
entries: list.length,
|
|
229
|
+
jobsDone: done.length,
|
|
230
|
+
jobsFailed: failed.length,
|
|
231
|
+
size: { ms: sum('ms'), steps: sum('steps'), tools: sum('tools'), findings: sum('findings'), tokens: sum('tokens'), largestSteps: largest },
|
|
232
|
+
tools: [...tools].sort(),
|
|
233
|
+
workedWith: [...withAgents].sort(),
|
|
234
|
+
created: [...created],
|
|
235
|
+
roles,
|
|
236
|
+
models: [...models.entries()].sort((a, b) => b[1] - a[1]).map(([m, n]) => ({ model: m, tasks: n })),
|
|
237
|
+
byEngine,
|
|
238
|
+
engineIndependence,
|
|
239
|
+
scm,
|
|
240
|
+
rating: { avg, count: ratings.length, recent: recentRatings.length ? recentRatings.reduce((a, b) => a + b, 0) / recentRatings.length : null },
|
|
241
|
+
refs,
|
|
242
|
+
since: list[0]?.at || null,
|
|
243
|
+
last: list.at(-1)?.at || null,
|
|
244
|
+
head: list.at(-1)?.hash || null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* How well an agent TYPE fits a job, with that type's record. `job.needs` is
|
|
250
|
+
* `{ skills[], tools[], grants[] }`; `type` carries `skills[]`, `tools[]`, `grants[]`; the
|
|
251
|
+
* summary is `summarize()`'s. Returns `{ score, reasons }` in [0, 1] — needs first (a type
|
|
252
|
+
* without the tools cannot do the job), track record second, size third.
|
|
253
|
+
*/
|
|
254
|
+
export function fit(job, type, summary = null, { qualityOf = null, costOf = null, adjust = true } = {}) {
|
|
255
|
+
const needs = job?.needs || {};
|
|
256
|
+
const have = (xs) => new Set((xs || []).map((x) => String(x).toLowerCase()));
|
|
257
|
+
const skills = have(type?.skills); const tools = have(type?.tools); const grants = have(type?.grants);
|
|
258
|
+
const reasons = [];
|
|
259
|
+
const coverage = (want, has, label) => {
|
|
260
|
+
const w = (want || []).map((x) => String(x).toLowerCase());
|
|
261
|
+
if (!w.length) return 1;
|
|
262
|
+
const hit = w.filter((x) => has.has(x));
|
|
263
|
+
if (hit.length < w.length) reasons.push(`missing ${label}: ${w.filter((x) => !has.has(x)).join(', ')}`);
|
|
264
|
+
return hit.length / w.length;
|
|
265
|
+
};
|
|
266
|
+
const cSkills = coverage(needs.skills, skills, 'skills');
|
|
267
|
+
const cTools = coverage(needs.tools, tools, 'tools');
|
|
268
|
+
const cGrants = coverage(needs.grants, grants, 'grants');
|
|
269
|
+
const needScore = (cSkills * 0.5 + cTools * 0.3 + cGrants * 0.2);
|
|
270
|
+
if (needScore === 1) reasons.push('has every skill, tool and grant the job names');
|
|
271
|
+
let record = 0.5; // a fresh type is neither trusted nor distrusted
|
|
272
|
+
let adjusted = null;
|
|
273
|
+
if (summary && summary.entries) {
|
|
274
|
+
const doneRate = summary.jobsDone + summary.jobsFailed ? summary.jobsDone / (summary.jobsDone + summary.jobsFailed) : 0.5;
|
|
275
|
+
// The track record uses the MODEL-ADJUSTED rating when the caller can say what the
|
|
276
|
+
// engines were worth (§13.3): an agent rated 0.82 mostly on a weak engine ranks above
|
|
277
|
+
// one rated 0.82 on a frontier model. The reasons say so, and a person can turn it off.
|
|
278
|
+
adjusted = adjust && qualityOf && summary.rating.avg != null ? adjustSummary(summary, { qualityOf, costOf: costOf || undefined }) : null;
|
|
279
|
+
const rated = summary.rating.avg == null ? 0.5 : (adjusted?.adjusted ?? summary.rating.avg);
|
|
280
|
+
record = doneRate * 0.5 + rated * 0.5;
|
|
281
|
+
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)}%`) : ''}`);
|
|
282
|
+
if (adjusted?.leverage != null && adjusted.leverage > 0.05) reasons.push(`adds ${adjusted.leverage} over its engines' own quality`);
|
|
283
|
+
if (adjusted?.efficiency) reasons.push(`cleared the bar cheapest on ${adjusted.efficiency.engine}`);
|
|
284
|
+
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`);
|
|
285
|
+
} else {
|
|
286
|
+
reasons.push('no record yet');
|
|
287
|
+
}
|
|
288
|
+
const wantSize = Number(job?.size?.steps) || 0;
|
|
289
|
+
const sizeScore = !wantSize ? 1 : Math.min(1, (summary?.size?.largestSteps || 0) / wantSize) * 0.5 + 0.5;
|
|
290
|
+
if (wantSize && (summary?.size?.largestSteps || 0) < wantSize) reasons.push(`largest task so far ${summary?.size?.largestSteps || 0} steps; this one is ~${wantSize}`);
|
|
291
|
+
const score = Math.round((needScore * 0.6 + record * 0.3 + sizeScore * 0.1) * 1000) / 1000;
|
|
292
|
+
return { score, reasons, parts: { needs: needScore, record, size: sizeScore }, ...(adjusted ? { adjusted } : {}) };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Agent scores, normalised by engine (§13.3) — what the model ledger's cards make possible ──
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The model-adjusted view of an agent's card (scorecard.js `summarize()`), given what its
|
|
299
|
+
* engines are worth: `qualityOf(key)` → the engine's quality in [0, 1] (the card's mean
|
|
300
|
+
* rating for the job kind when observed, else the router's guess) or null when unknown.
|
|
301
|
+
*
|
|
302
|
+
* leverage rating on an engine minus that engine's quality, weighted by tasks — what
|
|
303
|
+
* the agent's prompt and tools add that the model does not supply on its own
|
|
304
|
+
* adjusted the raw rating corrected for the engines it ran on: work done on a weak
|
|
305
|
+
* engine counts for more, on a strong one for less; `k` bounds the correction
|
|
306
|
+
* efficiency adjusted rating ÷ cost per task on the cheapest engine that cleared `bar`
|
|
307
|
+
* (`costOf(key)` → $/task or a token proxy; null when nothing is priced)
|
|
308
|
+
*
|
|
309
|
+
* Returns `{ raw, adjusted, leverage, efficiency, basis[] }` with `basis` the reasons a
|
|
310
|
+
* person reads ("60 % of its tasks ran on a 0.3-quality engine").
|
|
311
|
+
*/
|
|
312
|
+
export function adjustSummary(summary, { qualityOf = () => null, costOf = () => null, reference = 0.6, k = 0.3, bar = 0.5 } = {}) {
|
|
313
|
+
const raw = summary?.rating?.avg ?? null;
|
|
314
|
+
const rows = (summary?.byEngine || []).filter((r) => r.key);
|
|
315
|
+
const known = rows.map((r) => ({ ...r, quality: qualityOf(r.key) })).filter((r) => Number.isFinite(r.quality));
|
|
316
|
+
const totalTasks = known.reduce((n, r) => n + r.tasks, 0);
|
|
317
|
+
const basis = [];
|
|
318
|
+
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'] };
|
|
319
|
+
// Correction: how far below the reference the engines it ran on sit, task-weighted.
|
|
320
|
+
const correction = k * known.reduce((s, r) => s + (r.tasks / totalTasks) * (reference - r.quality), 0);
|
|
321
|
+
const adjusted = Math.max(0, Math.min(1, raw + correction));
|
|
322
|
+
const weak = known.filter((r) => r.quality < reference);
|
|
323
|
+
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'}`);
|
|
324
|
+
const strong = known.filter((r) => r.quality > reference);
|
|
325
|
+
if (strong.length && !weak.length) basis.push(`ran on engines above the reference (${strong.map((r) => r.quality).join(', ')})`);
|
|
326
|
+
// Leverage over the engines it was rated on.
|
|
327
|
+
const rated = known.filter((r) => r.rating?.avg != null);
|
|
328
|
+
const ratedTasks = rated.reduce((n, r) => n + r.rating.count, 0);
|
|
329
|
+
const leverage = ratedTasks ? r3(rated.reduce((s, r) => s + (r.rating.count / ratedTasks) * (r.rating.avg - r.quality), 0)) : null;
|
|
330
|
+
if (leverage != null) basis.push(`${leverage >= 0 ? '+' : ''}${leverage} over its engines' own quality`);
|
|
331
|
+
// Efficiency on the cheapest engine that cleared the bar.
|
|
332
|
+
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);
|
|
333
|
+
const efficiency = cleared.length ? { value: r3(adjusted / cleared[0].cost), engine: cleared[0].key, costPerTask: cleared[0].cost } : null;
|
|
334
|
+
return { raw: r3(raw), adjusted: r3(adjusted), leverage, efficiency, basis };
|
|
335
|
+
}
|
|
336
|
+
|
package/src/server.js
CHANGED
|
@@ -34,7 +34,10 @@ import { installTimestampedConsole } from './log.js';
|
|
|
34
34
|
import { saveBackupSecret, clearBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
|
|
35
35
|
import { createMemoryStore } from './memory-store.js';
|
|
36
36
|
import { createPrefsStore } from './prefs-store.js';
|
|
37
|
-
import { createTeamStore } from './team-store.js';
|
|
37
|
+
import { createTeamStore, loadOrCreateKey as loadTeamKey } from './team-store.js';
|
|
38
|
+
import { createScorecardStore } from './scorecard-store.js';
|
|
39
|
+
import { createEngineLedgerStore } from './engine-ledger-store.js';
|
|
40
|
+
import { createProjectStore } from './project-store.js';
|
|
38
41
|
import { createHistoryStore } from './sqlite-store.js';
|
|
39
42
|
import { ingestBackups } from './backup-ingest.js';
|
|
40
43
|
import * as nerEngine from './ner-engine.js';
|
|
@@ -58,7 +61,7 @@ import * as openai from './openai.js';
|
|
|
58
61
|
import * as responses from './responses.js';
|
|
59
62
|
import * as anthropic from './anthropic.js';
|
|
60
63
|
|
|
61
|
-
export const VERSION = '0.6.
|
|
64
|
+
export const VERSION = '0.6.90';
|
|
62
65
|
|
|
63
66
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
64
67
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -67,7 +70,10 @@ export const VERSION = '0.6.86';
|
|
|
67
70
|
const historyStore = await createHistoryStore();
|
|
68
71
|
const memoryStore = await createMemoryStore();
|
|
69
72
|
const prefsStore = createPrefsStore();
|
|
70
|
-
const
|
|
73
|
+
const scorecards = createScorecardStore({ key: loadTeamKey() });
|
|
74
|
+
const engines = createEngineLedgerStore({ key: loadTeamKey() });
|
|
75
|
+
const projectStore = createProjectStore({ key: loadTeamKey() });
|
|
76
|
+
const teamStore = createTeamStore({ scorecards, engines });
|
|
71
77
|
// Who is watching prefs change — a client with a live subscription is told the moment a
|
|
72
78
|
// section is written by the other client, instead of waiting for its next focus.
|
|
73
79
|
const prefsWatchers = new Set();
|
|
@@ -163,6 +169,12 @@ function fmtTimings(t) {
|
|
|
163
169
|
// restore model output → harness[restore] → user response (non-stream; for
|
|
164
170
|
// streams restore is inline per chunk, so it's folded into stream)
|
|
165
171
|
// total end-to-end through the gateway
|
|
172
|
+
/** `model:<id>[/<model>]` / `harness:<id>[/<model>]` → the record's engine, or null. */
|
|
173
|
+
function engineFromKey(key) {
|
|
174
|
+
const m = /^(model|harness):([^/]+)(?:\/(.+))?$/.exec(String(key || ''));
|
|
175
|
+
return m ? { kind: m[1], id: m[2], ...(m[3] ? { model: m[3] } : {}) } : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
166
178
|
function mkTrace(sink) {
|
|
167
179
|
const start = performance.now();
|
|
168
180
|
const timings = {};
|
|
@@ -395,8 +407,12 @@ async function pumpRelay(res, s, shaper, trace) {
|
|
|
395
407
|
}
|
|
396
408
|
|
|
397
409
|
// New tool-enabled turn: open the bridge with the client's tools as MCP specs.
|
|
398
|
-
async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg, isPro, tools, harness = null, trace = null) {
|
|
410
|
+
async function startRelay(req, res, { kind, adapter, agent, run = null }, body, vault, cfg, isPro, tools, harness = null, trace = null) {
|
|
399
411
|
const { messages, system } = adapter.toTurn(body);
|
|
412
|
+
// The model half of `claude/opus` and the team role's run options travel with a
|
|
413
|
+
// tool-using turn the same as with a plain one (handleBridge below).
|
|
414
|
+
const { agentModel } = parseAgentModel(body?.model, cfg);
|
|
415
|
+
const options = bridgeAgentOptions(cfg, { ...(agentModel ? { model: agentModel } : {}), ...(run || {}) });
|
|
400
416
|
const token = readBridgeToken(cfg.bridge.token);
|
|
401
417
|
const shaper = shaperFor(kind, body?.model || agent);
|
|
402
418
|
// Full tier for everyone here (the free allowance is enforced in the main
|
|
@@ -410,7 +426,7 @@ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg,
|
|
|
410
426
|
// redaction in the main handler), so toTurn() carried it here — nothing to add.
|
|
411
427
|
let resp;
|
|
412
428
|
try {
|
|
413
|
-
resp = await openBridgeChat({ bridgeUrl, agent, token, messages, system, specs: toolsToSpecs(tools), options
|
|
429
|
+
resp = await openBridgeChat({ bridgeUrl, agent, token, messages, system, specs: toolsToSpecs(tools), options, signal: undefined });
|
|
414
430
|
} catch (e) { endRelaySession(s.id); trace?.commit(); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
|
|
415
431
|
s.reader = resp.body.getReader();
|
|
416
432
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
@@ -434,7 +450,7 @@ async function resumeRelay(res, s, toolContent, model, trace = null) {
|
|
|
434
450
|
return pumpRelay(res, s, shaper, trace);
|
|
435
451
|
}
|
|
436
452
|
|
|
437
|
-
async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness, trace }, body, vault, cfg, isPro) {
|
|
453
|
+
async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness, trace, run = null }, body, vault, cfg, isPro) {
|
|
438
454
|
if (!redactable) {
|
|
439
455
|
trace?.commit();
|
|
440
456
|
return sendJson(res, 404, { error: `endpoint ${pathname} not supported by the bridge backend` });
|
|
@@ -453,7 +469,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
453
469
|
}
|
|
454
470
|
const tools = adapter.extractTools(body);
|
|
455
471
|
if (tools.length && body?.stream === true) {
|
|
456
|
-
return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools, harness, trace);
|
|
472
|
+
return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg), run }, body, vault, cfg, isPro, tools, harness, trace);
|
|
457
473
|
}
|
|
458
474
|
}
|
|
459
475
|
|
|
@@ -479,7 +495,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
479
495
|
bridgeUrl: await resolveBridgeUrl(cfg), agent, token, messages, system, signal: ac.signal,
|
|
480
496
|
// Permissions and working directory from the gateway's config (the desktop's Settings →
|
|
481
497
|
// Engine → Agents), plus the model half of `claude/opus` when the caller named one.
|
|
482
|
-
options: bridgeAgentOptions(cfg, agentModel ? { model: agentModel } : {}),
|
|
498
|
+
options: bridgeAgentOptions(cfg, { ...(agentModel ? { model: agentModel } : {}), ...(run || {}) }),
|
|
483
499
|
};
|
|
484
500
|
|
|
485
501
|
if (!wantStream) {
|
|
@@ -706,7 +722,7 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
706
722
|
// Client preferences travel between the extension and the desktop through here, and an
|
|
707
723
|
// MCP server entry can carry an Authorization header — so READS are gated too, unlike
|
|
708
724
|
// history and memory. A drive-by page must not learn what tools the user connected.
|
|
709
|
-
if ((pathname === '/v1/prefs' || pathname.startsWith('/v1/prefs/') || pathname.startsWith('/v1/teams')) && !isAdminAuthorized(req)) {
|
|
725
|
+
if ((pathname === '/v1/prefs' || pathname.startsWith('/v1/prefs/') || pathname.startsWith('/v1/teams') || pathname.startsWith('/v1/agents') || pathname.startsWith('/v1/projects')) && !isAdminAuthorized(req)) {
|
|
710
726
|
return sendJson(res, 403, { error: { message: 'prefs: extension origin or gateway token required', type: 'forbidden' } });
|
|
711
727
|
}
|
|
712
728
|
// The access log is who-read-what — sensitive, and writable only by the local MCP
|
|
@@ -820,6 +836,104 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
820
836
|
return undefined;
|
|
821
837
|
}
|
|
822
838
|
|
|
839
|
+
// --- PROJECTS. The page a goal starts on and everything done for it (project-store.js):
|
|
840
|
+
// GET /v1/projects[?limit&status] → { ok, projects } newest activity first, jobs counted
|
|
841
|
+
// POST /v1/projects { id, project, by } → { ok, project } open a record (idempotent) / update the page
|
|
842
|
+
// GET /v1/projects/jobs → { ok, jobs } the job board: every open posting across projects
|
|
843
|
+
// GET /v1/projects/:id[?events=1] → { ok, project } the record: jobs, runs, spend, decisions, report
|
|
844
|
+
// POST /v1/projects/:id/events { events } → { ok, project } the executive loop appends (status, run.linked, run.spent, decision, report)
|
|
845
|
+
// POST /v1/projects/:id/jobs { job, by } → { ok, project } post a job
|
|
846
|
+
// POST /v1/projects/:id/jobs/:jobId { patch, by } → { ok, project } move it along its machine / applications / recruited / result
|
|
847
|
+
// GET /v1/projects/:id/events[?after] (SSE) hello, replay, then live
|
|
848
|
+
// DELETE /v1/projects/:id
|
|
849
|
+
if (pathname === '/v1/projects' && req.method === 'GET') return sendJson(res, 200, { ok: true, projects: projectStore.list({ limit: url.searchParams.get('limit') || 50, status: url.searchParams.get('status') || '' }) });
|
|
850
|
+
if (pathname === '/v1/projects/jobs' && req.method === 'GET') return sendJson(res, 200, { ok: true, jobs: projectStore.openJobs() });
|
|
851
|
+
if (pathname === '/v1/projects' && req.method === 'POST') {
|
|
852
|
+
try {
|
|
853
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
854
|
+
return sendJson(res, 200, { ok: true, project: projectStore.create({ id: body.id || body.project?.id, project: body.project || null, by: body.by }) });
|
|
855
|
+
} catch (e) { return sendJson(res, 400, { error: { message: `project: ${e.message}`, type: 'project_error' } }); }
|
|
856
|
+
}
|
|
857
|
+
{
|
|
858
|
+
const m = /^\/v1\/projects\/([a-zA-Z0-9_-]{1,64})(\/events|\/jobs(?:\/([a-zA-Z0-9_-]{1,64}))?)?$/.exec(pathname);
|
|
859
|
+
if (m) {
|
|
860
|
+
const id = m[1]; const sub = m[2] || ''; const jobId = m[3] || '';
|
|
861
|
+
const notFound = () => sendJson(res, 404, { error: { message: `no project ${id}`, type: 'not_found' } });
|
|
862
|
+
if (!sub && req.method === 'GET') { const p = projectStore.get(id, { events: url.searchParams.get('events') === '1' }); return p ? sendJson(res, 200, { ok: true, project: p }) : notFound(); }
|
|
863
|
+
if (!sub && req.method === 'DELETE') return sendJson(res, 200, { ok: true, removed: projectStore.remove(id) });
|
|
864
|
+
if (req.method === 'POST' && (sub === '/events' || sub.startsWith('/jobs'))) {
|
|
865
|
+
try {
|
|
866
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
867
|
+
const by = String(body.by || 'person').slice(0, 80);
|
|
868
|
+
const project = sub === '/events' ? projectStore.append(id, body.events || [])
|
|
869
|
+
: jobId ? projectStore.updateJob(id, jobId, body.patch || body, { by })
|
|
870
|
+
: projectStore.postJob(id, body.job || body, { by });
|
|
871
|
+
return sendJson(res, 200, { ok: true, project });
|
|
872
|
+
} catch (e) { return sendJson(res, e.message.startsWith('no ') ? 404 : 400, { error: { message: `project: ${e.message}`, type: 'project_error' } }); }
|
|
873
|
+
}
|
|
874
|
+
if (sub === '/events' && req.method === 'GET') {
|
|
875
|
+
if (!projectStore.get(id)) return notFound();
|
|
876
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
877
|
+
const sendEv = (ev) => res.write(`data: ${JSON.stringify(ev)}\n\n`);
|
|
878
|
+
let last = url.searchParams.has('after') ? Number(url.searchParams.get('after')) : -1;
|
|
879
|
+
sendEv({ seq: -1, type: 'hello', at: Date.now(), payload: { project: projectStore.get(id), after: last } });
|
|
880
|
+
const off = projectStore.watch(id, (ev) => { if (ev.seq > last) { last = ev.seq; sendEv(ev); } });
|
|
881
|
+
for (const ev of projectStore.eventsSince(id, last)) { last = ev.seq; sendEv(ev); }
|
|
882
|
+
const beat = setInterval(() => { try { res.write(': keep\n\n'); } catch { /* closed */ } }, 25_000);
|
|
883
|
+
req.on('close', () => { clearInterval(beat); off(); });
|
|
884
|
+
return undefined;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
// --- SCORECARDS. Every agent's attested record (scorecard-store.js): the chain, its card,
|
|
889
|
+
// whether it verifies; a person's rating appended from either client.
|
|
890
|
+
if (pathname === '/v1/agents/scorecards' && req.method === 'GET') return sendJson(res, 200, { ok: true, agents: scorecards.list() });
|
|
891
|
+
{
|
|
892
|
+
const m = /^\/v1\/agents\/([a-zA-Z0-9_.:@+-]{1,120})\/scorecard$/.exec(pathname);
|
|
893
|
+
if (m) {
|
|
894
|
+
const agentId = decodeURIComponent(m[1]);
|
|
895
|
+
if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...(await scorecards.get(agentId)) });
|
|
896
|
+
if (req.method === 'POST') {
|
|
897
|
+
try {
|
|
898
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
899
|
+
const entry = await scorecards.append({ agentId, kind: 'rating', runId: body.runId, taskId: body.taskId, jobId: body.jobId, rating: { by: String(body.by || 'person').slice(0, 40), score: body.score, note: body.note, about: body.about }, refs: body.refs });
|
|
900
|
+
// The verdict is the engine's too: it lands on the ledger of what served the task.
|
|
901
|
+
engines.fromRating(scorecards.chains.get(agentId) || [], { ...entry, jobKind: body.jobKind ? String(body.jobKind).slice(0, 60) : undefined });
|
|
902
|
+
return sendJson(res, 200, { ok: true, entry });
|
|
903
|
+
} catch (e) { return sendJson(res, 400, { error: { message: `scorecard: ${e.message}`, type: 'scorecard_error' } }); }
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
// --- ENGINES. Every model's / harness's attested ledger (engine-ledger-store.js): the
|
|
908
|
+
// card a client feeds applyCard, the chain on request; a host's observed call, a
|
|
909
|
+
// person's price or capability proof appended from either client.
|
|
910
|
+
// GET /v1/engines[?minCalls] → { ok, engines: [card] }
|
|
911
|
+
// GET /v1/engines/:key/card[?entries=1] → { ok, key, card, entries?, verified?, attested? }
|
|
912
|
+
// POST /v1/engines/:key/entries { kind, engine, call|rating|price|capability|declined, … }
|
|
913
|
+
if (pathname === '/v1/engines' && req.method === 'GET') {
|
|
914
|
+
const minCalls = Number(url.searchParams.get('minCalls')) || undefined;
|
|
915
|
+
return sendJson(res, 200, { ok: true, engines: engines.list({ minCalls }) });
|
|
916
|
+
}
|
|
917
|
+
{
|
|
918
|
+
const m = /^\/v1\/engines\/(.+)\/(card|entries)$/.exec(pathname);
|
|
919
|
+
if (m) {
|
|
920
|
+
const key = decodeURIComponent(m[1]).slice(0, 300);
|
|
921
|
+
if (m[2] === 'card' && req.method === 'GET') {
|
|
922
|
+
const minCalls = Number(url.searchParams.get('minCalls')) || undefined;
|
|
923
|
+
return sendJson(res, 200, { ok: true, ...(await engines.get(key, { entries: url.searchParams.get('entries') === '1', minCalls })) });
|
|
924
|
+
}
|
|
925
|
+
if (m[2] === 'entries' && req.method === 'POST') {
|
|
926
|
+
try {
|
|
927
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
928
|
+
const engine = body.engine || engineFromKey(key);
|
|
929
|
+
if (!engine) return sendJson(res, 400, { error: { message: 'model-ledger: engine required', type: 'model_ledger_error' } });
|
|
930
|
+
const entry = await engines.append({ ...body, engine, kind: body.kind || 'call' });
|
|
931
|
+
if (entry.key !== key) return sendJson(res, 400, { error: { message: `model-ledger: entry is for ${entry.key}, not ${key}`, type: 'model_ledger_error' } });
|
|
932
|
+
return sendJson(res, 200, { ok: true, entry });
|
|
933
|
+
} catch (e) { return sendJson(res, 400, { error: { message: `model-ledger: ${e.message}`, type: 'model_ledger_error' } }); }
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
823
937
|
// --- TEAM RUNS. The board every client can read (team-store.js).
|
|
824
938
|
// GET /v1/teams/runs[?limit&team] → { ok, runs } newest first, no boards
|
|
825
939
|
// POST /v1/teams/runs { id, team, request, client } → { ok, run }
|
|
@@ -2012,6 +2126,10 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
2012
2126
|
const hint = {
|
|
2013
2127
|
destination: String(req.headers['x-chatpanel-destination'] || legacy?.destination || '').trim(),
|
|
2014
2128
|
reach: String(req.headers['x-chatpanel-reach'] || legacy?.reach || '').trim(),
|
|
2129
|
+
// A TEAM ROLE'S RUN, for the bridge (pillars §14.2): `{ grants, workspace, connectionId }`
|
|
2130
|
+
// — URI-encoded JSON in a header (the body belongs to the provider), the legacy body
|
|
2131
|
+
// field also honoured. Only the bridge path reads it; an API destination never sees it.
|
|
2132
|
+
run: readRunHint(req.headers['x-chatpanel-run'], legacy?.run),
|
|
2015
2133
|
};
|
|
2016
2134
|
const dest = resolveDestination(body?.model, cfg, r.kind, { destination: hint.destination });
|
|
2017
2135
|
// An EXPLICIT destination that does not resolve is an error, not an invitation to fall
|
|
@@ -2038,10 +2156,25 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
2038
2156
|
}
|
|
2039
2157
|
return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol, harness, trace }, outBody, vault);
|
|
2040
2158
|
}
|
|
2041
|
-
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness, trace }, body, vault, cfg, isPro);
|
|
2159
|
+
return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness, trace, run: hint.run }, body, vault, cfg, isPro);
|
|
2042
2160
|
});
|
|
2043
2161
|
}
|
|
2044
2162
|
|
|
2163
|
+
/** `{ grants[], workspace{ repo, projectId, jobId, base? }, connectionId }` from the header or the legacy field — shaped, never a token. */
|
|
2164
|
+
function readRunHint(header, legacy) {
|
|
2165
|
+
let raw = legacy && typeof legacy === 'object' ? legacy : null;
|
|
2166
|
+
if (!raw && header) { try { raw = JSON.parse(decodeURIComponent(String(header))); } catch { raw = null; } }
|
|
2167
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
2168
|
+
const out = {};
|
|
2169
|
+
if (Array.isArray(raw.grants)) out.grants = raw.grants.map((g) => String(g).slice(0, 80)).slice(0, 32);
|
|
2170
|
+
if (raw.workspace && typeof raw.workspace === 'object' && raw.workspace.repo) {
|
|
2171
|
+
const w = raw.workspace;
|
|
2172
|
+
out.workspace = { repo: String(w.repo).slice(0, 400), projectId: String(w.projectId || '').slice(0, 120), jobId: String(w.jobId || '').slice(0, 120), ...(w.base ? { base: String(w.base).slice(0, 120) } : {}) };
|
|
2173
|
+
}
|
|
2174
|
+
if (raw.connectionId) out.connectionId = String(raw.connectionId).slice(0, 64);
|
|
2175
|
+
return Object.keys(out).length ? out : null;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2045
2178
|
export function start(cfg = loadConfig()) {
|
|
2046
2179
|
installTimestampedConsole(); // every gateway log line gets a clock — before anything logs
|
|
2047
2180
|
ensureGatewayToken(); // M2: load/create the admin-route token (best-effort)
|
package/src/team-store.js
CHANGED
|
@@ -34,7 +34,7 @@ export const STALE_AFTER_MS = 5 * 60_000;
|
|
|
34
34
|
const RUN_ID_RE = /^[a-zA-Z0-9_-]{4,64}$/;
|
|
35
35
|
const LIVE = new Set(LIVE_RUN_STATUSES);
|
|
36
36
|
|
|
37
|
-
function loadOrCreateKey() {
|
|
37
|
+
export function loadOrCreateKey() {
|
|
38
38
|
try { if (existsSync(KEY_PATH)) return Buffer.from(readFileSync(KEY_PATH, 'utf8').trim(), 'base64'); } catch { /* regenerate */ }
|
|
39
39
|
const key = randomBytes(32);
|
|
40
40
|
mkdirSync(dirname(KEY_PATH), { recursive: true, mode: 0o700 });
|
|
@@ -58,7 +58,9 @@ const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)
|
|
|
58
58
|
export function applyEvent(run, ev) { return foldRun(run, ev); }
|
|
59
59
|
|
|
60
60
|
export class TeamStore {
|
|
61
|
-
constructor({ storePath = STORE_PATH, now = () => Date.now(), staleAfterMs = STALE_AFTER_MS } = {}) {
|
|
61
|
+
constructor({ storePath = STORE_PATH, now = () => Date.now(), staleAfterMs = STALE_AFTER_MS, scorecards = null, engines = null } = {}) {
|
|
62
|
+
this.scorecards = scorecards; // the agents' ledgers (scorecard-store.js), fed by task.scored
|
|
63
|
+
this.engines = engines; // the engines' ledgers (engine-ledger-store.js), fed by task.routed / reappointed / handoff / scored
|
|
62
64
|
this.path = storePath;
|
|
63
65
|
this.now = now;
|
|
64
66
|
this.staleAfterMs = staleAfterMs;
|
|
@@ -131,6 +133,10 @@ export class TeamStore {
|
|
|
131
133
|
const ev = { seq: seq++, type: String(type), at: Number(at) || this.now(), payload };
|
|
132
134
|
run.events.push(ev);
|
|
133
135
|
applyEvent(run, ev);
|
|
136
|
+
// A finished task's fact goes to the member's scorecard — chained and attested there.
|
|
137
|
+
if (this.scorecards && ev.type === 'task.scored') this.scorecards.fromRunEvent(ev, run);
|
|
138
|
+
// …and to the engine's ledger: the call, or the decline / rotation that preceded it.
|
|
139
|
+
if (this.engines && (ev.type === 'task.routed' || ev.type === 'task.reappointed' || ev.type === 'task.handoff' || ev.type === 'task.scored')) this.engines.fromRunEvent(ev, run);
|
|
134
140
|
for (const fn of this.watchers.get(run.id) || []) { try { fn(ev); } catch { /* a dead watcher */ } }
|
|
135
141
|
}
|
|
136
142
|
this.save();
|