@chatpanel/gateway 0.6.87 → 0.6.91
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/agent.js +250 -0
- 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 +150 -0
- package/src/model-ledger.js +229 -0
- package/src/project-store.js +139 -0
- package/src/project.js +171 -0
- package/src/recruit.js +425 -0
- package/src/recruiting.js +93 -0
- package/src/scorecard-store.js +1 -1
- package/src/scorecard.js +148 -4
- package/src/server.js +156 -9
- package/src/team-store.js +4 -1
- package/src/team.js +303 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.91",
|
|
4
4
|
"description": "Local privacy gateway \u2014 redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/agent.js — edit there, then copy over.
|
|
2
|
+
// An AGENT, as data — the pool an org recruits from. Nothing runs here.
|
|
3
|
+
//
|
|
4
|
+
// A team's roles were inlined: each carried its own prompt, tier and grants, so the same
|
|
5
|
+
// "researcher" existed once per team and an edit landed in one of them. An agent is the role
|
|
6
|
+
// definition promoted out of the team (F8 §8 A1, architecture-pillars.md §9): a persistent
|
|
7
|
+
// identity — name, purpose, prompt, skills, grants, egress class, ENGINE, memory namespace —
|
|
8
|
+
// with a scorecard the runner writes and the store attests (scorecard.js). One agent stands
|
|
9
|
+
// in many teams; a team's role says `agent: <id>` and `resolveTeam` fills the role from the
|
|
10
|
+
// pool at run time, so the runner (team-run.js) is unchanged.
|
|
11
|
+
//
|
|
12
|
+
// The engine is a field on the card, never a nav item: `model` / `harness` / `auto` /
|
|
13
|
+
// `assistant` (engine.js). The built-in ASSISTANT is the agent behind every plain chat: its
|
|
14
|
+
// engine is whichever model the chat is on, which is why `engineOf` takes the chat's model.
|
|
15
|
+
//
|
|
16
|
+
// Trust is derived, never declared (the team.js rule): `builtin` only when the host says so.
|
|
17
|
+
// Grants are the team's vocabulary (team.js GRANT_RE), including the work grants a harness
|
|
18
|
+
// engine may hold; `page` is never grantable. The pool is shared through the client-prefs
|
|
19
|
+
// document (`agents` section) like teams, skills and recipes.
|
|
20
|
+
|
|
21
|
+
import { normalizeGrants, GRANT_RE, TeamError, normalizeTeam, slugTeamName } from './team.js';
|
|
22
|
+
import { normalizeEngineSpec, validateEngineSpec, describeEngine, engineRef, tierOf } from './engine.js';
|
|
23
|
+
|
|
24
|
+
export const AGENT_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
25
|
+
export const APPLIES_TO = Object.freeze(['jobs', 'meetings', 'notes']);
|
|
26
|
+
export const EGRESS_CLASSES = Object.freeze(['redacted', 'delegated']);
|
|
27
|
+
export const MAX_SKILLS = 32;
|
|
28
|
+
export const ASSISTANT_ID = 'assistant';
|
|
29
|
+
|
|
30
|
+
export class AgentError extends Error {
|
|
31
|
+
constructor(code, message) { super(message); this.name = 'AgentError'; this.code = code; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
35
|
+
const list = (xs, n, max) => [...new Set((Array.isArray(xs) ? xs : typeof xs === 'string' ? xs.split(/[,\s]+/) : []).map((x) => String(x || '').trim().slice(0, n)).filter(Boolean))].slice(0, max);
|
|
36
|
+
|
|
37
|
+
export function validateAgent(agent) {
|
|
38
|
+
const errors = [];
|
|
39
|
+
if (!isRecord(agent)) return { ok: false, errors: ['agent must be an object'] };
|
|
40
|
+
if (!AGENT_ID_RE.test(String(agent.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
|
|
41
|
+
if (!String(agent.name || agent.id || '').trim()) errors.push('name: what to call it');
|
|
42
|
+
if (!String(agent.prompt || '').trim() && String(agent.id) !== ASSISTANT_ID) errors.push('prompt: what this agent does');
|
|
43
|
+
errors.push(...validateEngineSpec(agent.engine, 'engine'));
|
|
44
|
+
const bad = (Array.isArray(agent.grants) ? agent.grants : []).filter((g) => !GRANT_RE.test(String(g)));
|
|
45
|
+
if (bad.length) errors.push(`grants: not grantable: ${bad.join(', ')}${bad.some((g) => /^page/.test(String(g))) ? ' (a tab is one person\'s; an agent may not act on it)' : ''}`);
|
|
46
|
+
if (agent.egress !== undefined && agent.egress !== null && !EGRESS_CLASSES.includes(agent.egress)) errors.push(`egress: one of ${EGRESS_CLASSES.join(', ')}`);
|
|
47
|
+
if (agent.appliesTo !== undefined && (!Array.isArray(agent.appliesTo) || agent.appliesTo.some((a) => !APPLIES_TO.includes(a)))) errors.push(`appliesTo: a list of ${APPLIES_TO.join(', ')}`);
|
|
48
|
+
if (agent.skills !== undefined && !Array.isArray(agent.skills) && typeof agent.skills !== 'string') errors.push('skills: a list of skill names');
|
|
49
|
+
return { ok: errors.length === 0, errors };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The stored form. Defaults filled, grants normalized, engine normalized, trust derived. The
|
|
54
|
+
* memory namespace defaults to the agent's own (`agent:<id>`); a team may point several
|
|
55
|
+
* agents at one shared namespace by naming it.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeAgent(agent, { builtin = false } = {}) {
|
|
58
|
+
const v = validateAgent(agent);
|
|
59
|
+
if (!v.ok) throw new AgentError('INVALID', v.errors.join('; '));
|
|
60
|
+
const id = String(agent.id);
|
|
61
|
+
return {
|
|
62
|
+
id,
|
|
63
|
+
name: String(agent.name || id).trim().slice(0, 60),
|
|
64
|
+
purpose: String(agent.purpose || '').trim().slice(0, 300),
|
|
65
|
+
prompt: String(agent.prompt || '').trim().slice(0, 8000),
|
|
66
|
+
skills: list(agent.skills, 80, MAX_SKILLS),
|
|
67
|
+
grants: normalizeGrants(agent.grants),
|
|
68
|
+
engine: normalizeEngineSpec(agent.engine),
|
|
69
|
+
...(EGRESS_CLASSES.includes(agent.egress) ? { egress: agent.egress } : {}),
|
|
70
|
+
appliesTo: Array.isArray(agent.appliesTo) && agent.appliesTo.length ? [...new Set(agent.appliesTo.filter((a) => APPLIES_TO.includes(a)))] : ['jobs'],
|
|
71
|
+
memoryScope: String(agent.memoryScope || '').trim().slice(0, 120) || `agent:${id}`,
|
|
72
|
+
...(agent.workdir ? { workdir: String(agent.workdir).trim().slice(0, 400) } : {}),
|
|
73
|
+
createdBy: String(agent.createdBy || 'person').slice(0, 80),
|
|
74
|
+
enabled: agent.enabled !== false,
|
|
75
|
+
...(agent.origin && isRecord(agent.origin) ? { origin: { ...agent.origin } } : {}),
|
|
76
|
+
...(builtin ? { builtin: true } : {}),
|
|
77
|
+
...(agent.createdAt ? { createdAt: agent.createdAt } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function defineAgent(agent, opts) { return Object.freeze(normalizeAgent(agent, opts)); }
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The built-in Assistant — the agent behind every plain chat. Its engine is the chat's model
|
|
85
|
+
* (`engineOf` resolves it); its grants are whatever the chat has; it applies everywhere.
|
|
86
|
+
*/
|
|
87
|
+
export function assistantAgent({ grants = ['data', 'web', 'history', 'mcp'] } = {}) {
|
|
88
|
+
return defineAgent({
|
|
89
|
+
id: ASSISTANT_ID, name: 'Assistant', purpose: 'The chat itself: answers, uses the tools you connected, remembers what you tell it.',
|
|
90
|
+
prompt: '', engine: 'assistant', grants, appliesTo: ['jobs', 'meetings', 'notes'], createdBy: 'chatpanel',
|
|
91
|
+
}, { builtin: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* WHAT RUNS this agent's turns, resolved: the Assistant's engine is the chat's model (the
|
|
96
|
+
* host passes `chatModel` as `{ providerId?, model }` or a string), which is why the
|
|
97
|
+
* Assistant has no engine of its own. Everything else returns its normalized spec. A legacy
|
|
98
|
+
* role (no `engine`) reads as `model` when it pins one, else `auto` at its tier.
|
|
99
|
+
*/
|
|
100
|
+
export function engineOf(agentOrRole, { chatModel = null } = {}) {
|
|
101
|
+
const a = agentOrRole || {};
|
|
102
|
+
if (a.engine !== undefined && a.engine !== null) {
|
|
103
|
+
const s = normalizeEngineSpec(a.engine);
|
|
104
|
+
if (s.kind !== 'assistant') return s;
|
|
105
|
+
if (chatModel) return normalizeEngineSpec(typeof chatModel === 'string' ? chatModel : { kind: 'model', providerId: chatModel.providerId, model: chatModel.model || chatModel.id });
|
|
106
|
+
return { kind: 'auto', policy: { prefer: 'balanced' } };
|
|
107
|
+
}
|
|
108
|
+
if (a.model) return normalizeEngineSpec({ kind: 'model', model: a.model });
|
|
109
|
+
const prefer = { cheap: 'cheapest-that-clears', strong: 'best-quality', balanced: 'balanced' }[a.prefer] || 'balanced';
|
|
110
|
+
return { kind: 'auto', policy: { prefer } };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The capability strip's first column (§8): what kind of thing this agent is, in a word. */
|
|
114
|
+
export function describeAgent(agent, opts) {
|
|
115
|
+
const a = agent || {};
|
|
116
|
+
return `${a.name || a.id} — ${describeEngine(a.engine, opts)} · tools: ${(a.grants || ['none']).join(', ')}${a.skills?.length ? ` · skills: ${a.skills.join(', ')}` : ''}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function slugAgentId(name) { return slugTeamName(name); }
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A team as the runner needs it: every role that says `agent` is filled from the pool —
|
|
123
|
+
* prompt, grants, skills, engine, working directory — and the role's own fields narrow it.
|
|
124
|
+
*
|
|
125
|
+
* • prompt the agent's, then the role's ("In this team: …") when the role adds one
|
|
126
|
+
* • grants the agent's, narrowed to the role's when the role lists any (a team may not
|
|
127
|
+
* widen an agent; it may lend less)
|
|
128
|
+
* • engine the role's override when it has one, else the agent's, with the Assistant
|
|
129
|
+
* resolved to `chatModel`; `prefer` follows for today's appointers
|
|
130
|
+
* • model what `callModel` is handed — `targetFor(engine)` when the host maps engines
|
|
131
|
+
* to its target ids (the extension's endpoint ids, the desktop's gateway ids),
|
|
132
|
+
* else the harness id or the model name
|
|
133
|
+
*
|
|
134
|
+
* A role naming an agent not in the pool throws NO_AGENT: a team is not run with a hole in
|
|
135
|
+
* it. Returns the normalized team plus `agents` — the resolved cards, by role id.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveTeam(team, pool = [], { chatModel = null, targetFor = null } = {}) {
|
|
138
|
+
const t = normalizeTeam(team);
|
|
139
|
+
const byId = new Map((Array.isArray(pool) ? pool : []).filter((a) => a && a.id).map((a) => [String(a.id), a]));
|
|
140
|
+
const agents = {};
|
|
141
|
+
const roles = t.roles.map((r) => {
|
|
142
|
+
if (!r.agent) return r;
|
|
143
|
+
const raw = byId.get(r.agent) || (r.agent === ASSISTANT_ID ? assistantAgent() : null);
|
|
144
|
+
if (!raw) throw new TeamError('NO_AGENT', `role "${r.id}" names agent "${r.agent}", which is not in the pool`);
|
|
145
|
+
const a = normalizeAgent(raw, { builtin: !!raw.builtin });
|
|
146
|
+
if (a.enabled === false) throw new TeamError('NO_AGENT', `agent "${a.id}" is disabled`);
|
|
147
|
+
agents[r.id] = a;
|
|
148
|
+
const engine = engineOf(r.engine ? { engine: r.engine } : a, { chatModel });
|
|
149
|
+
const ref = engineRef(engine);
|
|
150
|
+
const target = ref ? (targetFor ? targetFor(engine, a) : (engine.kind === 'harness' ? engine.harnessId : engine.model)) : null;
|
|
151
|
+
const grants = !Array.isArray(r.grants) ? a.grants
|
|
152
|
+
: a.grants.includes('none') ? ['none']
|
|
153
|
+
: normalizeGrants(r.grants.filter((g) => a.grants.includes(g) || (g.startsWith('mcp:') && a.grants.includes('mcp'))));
|
|
154
|
+
const prompt = [a.prompt, r.prompt ? `In this team: ${r.prompt}` : ''].filter(Boolean).join('\n\n').slice(0, 8000);
|
|
155
|
+
return {
|
|
156
|
+
...r,
|
|
157
|
+
name: r.name === r.id ? a.name : r.name,
|
|
158
|
+
prompt,
|
|
159
|
+
grants,
|
|
160
|
+
engine,
|
|
161
|
+
prefer: engine.kind === 'auto' ? tierOf(engine) : r.prefer,
|
|
162
|
+
...(target ? { model: r.model || target } : {}),
|
|
163
|
+
...(a.skills.length ? { skills: [...a.skills] } : {}),
|
|
164
|
+
...(a.workdir ? { workdir: a.workdir } : {}),
|
|
165
|
+
...(a.egress ? { egress: a.egress } : {}),
|
|
166
|
+
...(a.memoryScope ? { memoryScope: a.memoryScope } : {}),
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
return { ...t, roles, agents };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── The standing org and the editor's form ────────────────────────────────────────────────
|
|
173
|
+
//
|
|
174
|
+
// "ChatPanel Engineering" (architecture-pillars.md §12.2) — the first standing agents,
|
|
175
|
+
// offered as starters the way `research` and `review` are for teams. Every Implementer is
|
|
176
|
+
// this ONE card with a different working directory: the org repo's `agents/*.json` (§14.3)
|
|
177
|
+
// is where a company keeps one per repo. Engines are `auto` / `harness:<id>` so a starter
|
|
178
|
+
// does not name a provider a person may not have; the Harness engines say `claude` because
|
|
179
|
+
// that is the bridge's id for Claude Code and the pilot's choice — change it on the card.
|
|
180
|
+
|
|
181
|
+
export const STARTER_AGENTS = Object.freeze([
|
|
182
|
+
{ id: 'architect', name: 'Architect', purpose: 'Reads the docs and the repos; writes the project page and the jobs.',
|
|
183
|
+
prompt: 'You are the Architect. Read the feature doc, ROADMAP.md, naming-revamp.md and architecture-pillars.md before deciding anything. Write the project page (goal, done-when, budget) and post one job per repo that must change, saying which repo and what the guard is. Propose a new agent type only when no one in the pool fits. Never run a shell.',
|
|
184
|
+
skills: [], grants: ['data', 'history'], engine: { kind: 'auto', policy: { prefer: 'best-quality' } }, appliesTo: ['jobs'] },
|
|
185
|
+
{ id: 'implementer', name: 'Implementer', purpose: 'Builds one job on a branch in one repo; runs the guard; posts on the thread. Never pushes to main, never publishes.',
|
|
186
|
+
prompt: 'You are an Implementer. Work only in the repository you were given, on the branch named for this job. Read the job thread first. Make the change, run the repository\'s guard (tools/test-*.mjs or npm test) until it passes, commit with a message that says what and why, and post a summary with the diff stat on the job thread. Do not push to main, do not publish, do not touch another repository.',
|
|
187
|
+
skills: [], grants: ['shell', 'fs:write', 'scm:read', 'scm:push', 'scm:pr'], engine: { kind: 'harness', harnessId: 'claude' }, appliesTo: ['jobs'] },
|
|
188
|
+
{ id: 'reviewer', name: 'Reviewer', purpose: 'Reads the branch diff; posts findings on the thread. Approve or reject is a person\'s.',
|
|
189
|
+
prompt: 'You are the Reviewer. Read the diff of the job\'s branch against its base. Post each finding as a reply on the job thread: what, where (file:line), why it matters, what to do instead. Do not edit files. Do not approve or reject — say what you found and let a person decide.',
|
|
190
|
+
skills: ['review'], grants: ['shell', 'scm:read'], engine: { kind: 'harness', harnessId: 'claude' }, appliesTo: ['jobs'] },
|
|
191
|
+
{ id: 'tester', name: 'Tester', purpose: 'Runs the repository\'s guard on the branch; reports the result. No writes.',
|
|
192
|
+
prompt: 'You are the Tester. Check out the job\'s branch in its worktree and run the repository\'s guard (tools/test-*.mjs, npm test, the build). Report pass/fail with the failing output verbatim. Do not change any file.',
|
|
193
|
+
skills: [], grants: ['shell', 'scm:read'], engine: { kind: 'harness', harnessId: 'claude' }, appliesTo: ['jobs'] },
|
|
194
|
+
{ id: 'librarian', name: 'Librarian', purpose: 'Before every job: what was already done for this, and where.',
|
|
195
|
+
prompt: 'You are the Librarian. Before a job starts, search the runs, boards, briefs and docs for work that already covers it. Reply on the job thread with "already done in …" pointers and what could be reused or extended. Never do the job yourself.',
|
|
196
|
+
skills: [], grants: ['data', 'history'], engine: { kind: 'auto', policy: { prefer: 'cheapest-that-clears' } }, appliesTo: ['jobs'] },
|
|
197
|
+
{ id: 'scribe', name: 'Scribe', purpose: 'Writes what the run taught into the roadmap and the status row — proposed, a person lands it.',
|
|
198
|
+
prompt: 'You are the Scribe. From the run\'s board and scorecards, write the "what this run taught" paragraph for the feature doc, the ROADMAP.md entry and the IMPLEMENTATION-STATUS.md row. Post them as a proposal on the thread; a person lands them. Say plainly what did not work.',
|
|
199
|
+
skills: [], grants: ['data', 'history'], engine: { kind: 'auto', policy: { prefer: 'balanced' } }, appliesTo: ['jobs', 'notes'] },
|
|
200
|
+
{ id: 'release', name: 'Release', purpose: 'Version bump and changelog on the merged branch; asks before publish or push.',
|
|
201
|
+
prompt: 'You are Release. On the merged branch: bump the version, write the changelog line, run the guard. Before `npm publish` or any `git push`, stop and ask on the thread — a person answers. Never publish or push without that answer.',
|
|
202
|
+
skills: [], grants: ['shell', 'fs:write', 'scm:read', 'scm:push'], engine: { kind: 'harness', harnessId: 'claude' }, appliesTo: ['jobs'] },
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
/** Fresh copies — a starter is a template, never the stored record. */
|
|
206
|
+
export function starterAgents() {
|
|
207
|
+
return STARTER_AGENTS.map((a) => JSON.parse(JSON.stringify(a)));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A blank agent for the editor. */
|
|
211
|
+
export function blankAgent() {
|
|
212
|
+
return { id: '', name: '', purpose: '', prompt: '', skills: [], grants: ['none'], engine: { kind: 'auto', policy: { prefer: 'balanced' } }, appliesTo: ['jobs'], memoryScope: '' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The editor's form → an agent, or the errors. Grants and skills come as text ("web, data",
|
|
217
|
+
* "review, graphify"); the engine as the form's own shape (`{ kind, model, providerId,
|
|
218
|
+
* harnessId, prefer }`) or a string; a blank id is slugged from the name.
|
|
219
|
+
*/
|
|
220
|
+
export function agentFromForm(form) {
|
|
221
|
+
const f = form || {};
|
|
222
|
+
const id = String(f.id || '').trim() || slugAgentId(f.name);
|
|
223
|
+
const engine = isRecord(f.engine)
|
|
224
|
+
? (f.engine.kind === 'auto' ? { kind: 'auto', policy: { prefer: f.engine.prefer || f.engine.policy?.prefer || 'balanced', ...(f.engine.policy || {}) } } : f.engine)
|
|
225
|
+
: f.engine;
|
|
226
|
+
const agent = {
|
|
227
|
+
id,
|
|
228
|
+
name: String(f.name || '').trim() || id,
|
|
229
|
+
purpose: String(f.purpose || '').trim(),
|
|
230
|
+
prompt: String(f.prompt || ''),
|
|
231
|
+
skills: list(f.skills, 80, MAX_SKILLS),
|
|
232
|
+
grants: list(f.grants, 80, 32).length ? list(f.grants, 80, 32) : ['none'],
|
|
233
|
+
engine,
|
|
234
|
+
...(f.egress ? { egress: f.egress } : {}),
|
|
235
|
+
appliesTo: Array.isArray(f.appliesTo) && f.appliesTo.length ? f.appliesTo : ['jobs'],
|
|
236
|
+
...(f.memoryScope ? { memoryScope: f.memoryScope } : {}),
|
|
237
|
+
...(f.workdir ? { workdir: f.workdir } : {}),
|
|
238
|
+
createdBy: f.createdBy || 'person',
|
|
239
|
+
enabled: f.enabled !== false,
|
|
240
|
+
...(f.origin && isRecord(f.origin) ? { origin: f.origin } : {}),
|
|
241
|
+
...(f.createdAt ? { createdAt: f.createdAt } : {}),
|
|
242
|
+
};
|
|
243
|
+
const v = validateAgent(agent);
|
|
244
|
+
return v.ok ? { ok: true, agent: normalizeAgent(agent) } : { ok: false, errors: v.errors };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Which agents apply to jobs — the pool a job board recruits from. */
|
|
248
|
+
export function poolFor(agents, surface = 'jobs') {
|
|
249
|
+
return (Array.isArray(agents) ? agents : []).filter((a) => a && a.enabled !== false && (Array.isArray(a.appliesTo) ? a.appliesTo : ['jobs']).includes(surface));
|
|
250
|
+
}
|
package/src/budget.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/budget.js — edit there, then copy over.
|
|
2
|
+
// A budget — the number a run may not exceed, and the record of what it spent.
|
|
3
|
+
//
|
|
4
|
+
// There was no spend cap anywhere. A live monitor is declared class C and starts model turns
|
|
5
|
+
// for the length of a meeting; a spoken "keep an eye on X" arms that with nothing bounding
|
|
6
|
+
// it; `jobs.js` caps a per-day job COUNT, not spend. The class declarations on every intent
|
|
7
|
+
// and rule were made for exactly this, and nothing read them.
|
|
8
|
+
//
|
|
9
|
+
// A budget is a VALUE: declared on the thing that spends (a team, a schedule, a monitor),
|
|
10
|
+
// charged by whatever runs it, and carried on the run record so the ledger can say what a
|
|
11
|
+
// run cost in the same units it was capped in. Four dimensions, because the expensive thing
|
|
12
|
+
// differs by executor: tokens and calls for a model, wall time for an agent that thinks for
|
|
13
|
+
// minutes, cost when the gateway can report it. Any dimension may be absent; an absent one is
|
|
14
|
+
// not enforced. A budget with NO dimension is not a budget — `validateBudget` refuses it, and
|
|
15
|
+
// a team without one does not run (F8, O1).
|
|
16
|
+
//
|
|
17
|
+
// Pure. `now` is injected so a wall-time cap is testable.
|
|
18
|
+
|
|
19
|
+
export const BUDGET_DIMENSIONS = Object.freeze(['tokens', 'calls', 'ms', 'usd']);
|
|
20
|
+
|
|
21
|
+
export class BudgetError extends Error {
|
|
22
|
+
constructor(code, message) { super(message); this.name = 'BudgetError'; this.code = code; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `{ ok, errors }` — a budget must cap at least one thing, and every cap must be a positive number. */
|
|
26
|
+
export function validateBudget(b) {
|
|
27
|
+
const errors = [];
|
|
28
|
+
if (!b || typeof b !== 'object') return { ok: false, errors: ['budget must be an object'] };
|
|
29
|
+
let any = false;
|
|
30
|
+
for (const k of BUDGET_DIMENSIONS) {
|
|
31
|
+
if (b[k] === undefined || b[k] === null) continue;
|
|
32
|
+
const n = Number(b[k]);
|
|
33
|
+
if (!Number.isFinite(n) || n <= 0) errors.push(`${k}: a positive number`);
|
|
34
|
+
else any = true;
|
|
35
|
+
}
|
|
36
|
+
for (const k of Object.keys(b)) if (!BUDGET_DIMENSIONS.includes(k)) errors.push(`${k}: not a budget dimension (${BUDGET_DIMENSIONS.join(', ')})`);
|
|
37
|
+
if (!any) errors.push('a budget must cap at least one of tokens, calls, ms, usd');
|
|
38
|
+
return { ok: errors.length === 0, errors };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Only the declared dimensions, as numbers. */
|
|
42
|
+
export function normalizeBudget(b) {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const k of BUDGET_DIMENSIONS) {
|
|
45
|
+
const n = Number(b?.[k]);
|
|
46
|
+
if (Number.isFinite(n) && n > 0) out[k] = n;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The usage a model call reports, in the shapes the providers use, as one record:
|
|
53
|
+
* `{ tokens, calls, usd }`. `ms` is measured by the budget itself.
|
|
54
|
+
*/
|
|
55
|
+
export function usageOf(u = {}) {
|
|
56
|
+
const tokens = Number(u.tokens ?? u.total_tokens ?? ((Number(u.input_tokens ?? u.prompt_tokens) || 0) + (Number(u.output_tokens ?? u.completion_tokens) || 0)));
|
|
57
|
+
return { tokens: Number.isFinite(tokens) ? tokens : 0, calls: Number(u.calls ?? 1) || 0, usd: Number(u.usd ?? u.cost) || 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A live budget for one run.
|
|
62
|
+
*
|
|
63
|
+
* charge(usage) add a model call's usage; returns what is left
|
|
64
|
+
* canAfford(estimate) false when an estimated call would cross a cap — ask BEFORE calling
|
|
65
|
+
* exhausted() the dimension that ran out, or null
|
|
66
|
+
* snapshot() { cap, spent, remaining, exhausted } for the run record and the meter
|
|
67
|
+
*/
|
|
68
|
+
export function createBudget(declared, { now = () => Date.now() } = {}) {
|
|
69
|
+
const v = validateBudget(declared);
|
|
70
|
+
if (!v.ok) throw new BudgetError('INVALID', v.errors.join('; '));
|
|
71
|
+
const cap = { ...normalizeBudget(declared) };
|
|
72
|
+
const startedAt = now();
|
|
73
|
+
const spent = { tokens: 0, calls: 0, usd: 0 };
|
|
74
|
+
const elapsed = () => now() - startedAt;
|
|
75
|
+
const remaining = () => {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const k of BUDGET_DIMENSIONS) {
|
|
78
|
+
if (cap[k] === undefined) continue;
|
|
79
|
+
out[k] = Math.max(0, cap[k] - (k === 'ms' ? elapsed() : spent[k]));
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
};
|
|
83
|
+
const exhausted = () => {
|
|
84
|
+
for (const k of BUDGET_DIMENSIONS) {
|
|
85
|
+
if (cap[k] === undefined) continue;
|
|
86
|
+
if ((k === 'ms' ? elapsed() : spent[k]) >= cap[k]) return k;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
cap,
|
|
92
|
+
charge(usage) {
|
|
93
|
+
const u = usageOf(usage);
|
|
94
|
+
spent.tokens += u.tokens; spent.calls += u.calls; spent.usd += u.usd;
|
|
95
|
+
return remaining();
|
|
96
|
+
},
|
|
97
|
+
canAfford(estimate = {}) {
|
|
98
|
+
if (exhausted()) return false;
|
|
99
|
+
const e = usageOf({ calls: 1, ...estimate });
|
|
100
|
+
for (const k of ['tokens', 'calls', 'usd']) {
|
|
101
|
+
if (cap[k] !== undefined && spent[k] + e[k] > cap[k]) return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
},
|
|
105
|
+
remaining,
|
|
106
|
+
exhausted,
|
|
107
|
+
/** A person raised the cap mid-run (a budget ask answered "allow"): by a factor, once. */
|
|
108
|
+
raise(factor = 1.5) {
|
|
109
|
+
const f = Math.max(1, Number(factor) || 1);
|
|
110
|
+
for (const k of Object.keys(cap)) if (cap[k] !== undefined) cap[k] = Math.ceil(cap[k] * f);
|
|
111
|
+
return { ...cap };
|
|
112
|
+
},
|
|
113
|
+
snapshot() {
|
|
114
|
+
return { cap, spent: { ...spent, ms: elapsed() }, remaining: remaining(), exhausted: exhausted() };
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The engines' ledgers — every fact the runner and a host observed about a MODEL or a
|
|
2
|
+
// HARNESS, chained and attested here (model-ledger.js), one chain per engine key.
|
|
3
|
+
//
|
|
4
|
+
// The scorecard store's twin. A scorecard says what an AGENT did; a ledger says how an
|
|
5
|
+
// ENGINE behaved while doing it — did it answer, how fast, how much, was the JSON valid, was
|
|
6
|
+
// the verdict good. The facts come from the run store's fold (a finished task is a `call`,
|
|
7
|
+
// a re-appointment is a `declined` on the engine that was left, a hand-off a
|
|
8
|
+
// `rotated-from`), from a host that timed its own chat calls (`POST /v1/engines/:key/entries`),
|
|
9
|
+
// and from a person (a rating on a task lands on the engine that served it; a price they
|
|
10
|
+
// typed). Nothing is ever edited. The same store key attests both stores, under its own
|
|
11
|
+
// label, so a scorecard's mark cannot be replayed as a ledger's.
|
|
12
|
+
//
|
|
13
|
+
// Read: `GET /v1/engines` (every card), `GET /v1/engines/:key/card` (the card, the chain
|
|
14
|
+
// on request, whether it verifies). The card is what a client feeds `applyCard` — observed
|
|
15
|
+
// quality, latency and cost over the name-based guess once there is enough history.
|
|
16
|
+
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import { createHmac, webcrypto } from 'node:crypto';
|
|
21
|
+
import { makeLedgerEntry, verifyChain, attest, verifyAttested, summarizeEngine, ledgerKey, LEDGER_ENTRY_KINDS, DECLINE_REASONS } from './model-ledger.js';
|
|
22
|
+
import { normalizeEngine } from './scorecard.js';
|
|
23
|
+
|
|
24
|
+
const DIR = join(os.homedir(), '.chatpanel');
|
|
25
|
+
const STORE_PATH = process.env.CHATPANEL_ENGINES_STORE || join(DIR, 'engines.json');
|
|
26
|
+
const MAX_ENTRIES_PER_ENGINE = 20000;
|
|
27
|
+
|
|
28
|
+
/** Why an engine declined, read from the error the runner recorded. */
|
|
29
|
+
export function declineReasonOf(error) {
|
|
30
|
+
const m = String(error || '');
|
|
31
|
+
if (/rate|429|overloaded|capacity|too many/i.test(m)) return 'rate';
|
|
32
|
+
if (/401|403|unauthori[sz]ed|no api key|invalid.*key|forbidden|not configured/i.test(m)) return 'auth';
|
|
33
|
+
if (/credit|quota|billing|insufficient|402/i.test(m)) return 'credits';
|
|
34
|
+
if (/timed? ?out|ETIMEDOUT|deadline/i.test(m)) return 'timeout';
|
|
35
|
+
if (/context|too long|maximum.*tokens|token limit/i.test(m)) return 'context';
|
|
36
|
+
if (/not[_ ]found|404|not deployed|unavailable|does not exist|unknown model|ECONNREFUSED|could ?n.t reach|closed the connection|exited|502|503|500/i.test(m)) return 'unavailable';
|
|
37
|
+
return 'other';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class EngineLedgerStore {
|
|
41
|
+
constructor({ storePath = STORE_PATH, key = null, now = () => Date.now() } = {}) {
|
|
42
|
+
this.path = storePath;
|
|
43
|
+
this.now = now;
|
|
44
|
+
this._mark = key ? createHmac('sha256', key).update('chatpanel:engine-ledger:attest:v1').digest() : null;
|
|
45
|
+
this.chains = new Map(); // key -> [entries]
|
|
46
|
+
this._routed = new Map(); // `${runId}/${taskId}` -> the engine last routed to (for declines and rotations)
|
|
47
|
+
this._queue = Promise.resolve();
|
|
48
|
+
}
|
|
49
|
+
load() {
|
|
50
|
+
try {
|
|
51
|
+
if (existsSync(this.path)) {
|
|
52
|
+
const doc = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
53
|
+
for (const [k, entries] of Object.entries(doc?.chains || {})) if (Array.isArray(entries)) this.chains.set(k, entries);
|
|
54
|
+
}
|
|
55
|
+
} catch { this.chains = new Map(); }
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
save() {
|
|
59
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
60
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
61
|
+
writeFileSync(tmp, JSON.stringify({ v: 1, chains: Object.fromEntries(this.chains) }), { mode: 0o600 });
|
|
62
|
+
renameSync(tmp, this.path);
|
|
63
|
+
}
|
|
64
|
+
/** Append one fact to an engine's chain: made, chained, attested, saved. Serialised per store. */
|
|
65
|
+
append(fact) {
|
|
66
|
+
const run = async () => {
|
|
67
|
+
const engine = normalizeEngine(fact?.engine);
|
|
68
|
+
if (!engine) throw new Error('model-ledger: engine required');
|
|
69
|
+
if (!LEDGER_ENTRY_KINDS.includes(fact?.kind)) throw new Error(`model-ledger: kind must be one of ${LEDGER_ENTRY_KINDS.join(', ')}`);
|
|
70
|
+
const key = ledgerKey(engine);
|
|
71
|
+
const chain = this.chains.get(key) || [];
|
|
72
|
+
if (chain.length >= MAX_ENTRIES_PER_ENGINE) throw new Error('model-ledger: chain is full');
|
|
73
|
+
let entry = await makeLedgerEntry({ ...fact, engine, at: fact.at || this.now() }, chain.at(-1) || null, { now: this.now, subtle: webcrypto.subtle });
|
|
74
|
+
if (this._mark) entry = await attest(entry, this._mark, { subtle: webcrypto.subtle });
|
|
75
|
+
chain.push(entry);
|
|
76
|
+
this.chains.set(key, chain);
|
|
77
|
+
this.save();
|
|
78
|
+
return entry;
|
|
79
|
+
};
|
|
80
|
+
const p = this._queue.then(run, run);
|
|
81
|
+
this._queue = p.catch(() => {});
|
|
82
|
+
return p;
|
|
83
|
+
}
|
|
84
|
+
/** The card, and on request the chain and whether it verifies. */
|
|
85
|
+
async get(key, { entries = false, minCalls, now } = {}) {
|
|
86
|
+
const chain = this.chains.get(String(key || '')) || [];
|
|
87
|
+
const out = { key: String(key || ''), card: summarizeEngine(chain, { minCalls, now: now || this.now() }) };
|
|
88
|
+
if (entries) {
|
|
89
|
+
out.entries = chain;
|
|
90
|
+
out.verified = await verifyChain(chain, { subtle: webcrypto.subtle });
|
|
91
|
+
out.attested = this._mark ? await verifyAttested(chain, this._mark, { subtle: webcrypto.subtle }) : { ok: false, attested: 0, of: chain.length };
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/** Every engine's card, without the chains. */
|
|
96
|
+
list({ minCalls, now } = {}) {
|
|
97
|
+
return [...this.chains.entries()].map(([key, chain]) => summarizeEngine(chain, { minCalls, now: now || this.now() })).filter((c) => c.key);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A run store event, as the fold sees it. `task.routed` is remembered per task; a later
|
|
101
|
+
* `task.reappointed` is a decline on what was routed before it, `task.handoff` a rotation,
|
|
102
|
+
* and `task.scored` the call itself (the harness's or the model's whole task).
|
|
103
|
+
*/
|
|
104
|
+
fromRunEvent(ev, run) {
|
|
105
|
+
const type = String(ev?.type || '');
|
|
106
|
+
const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : {};
|
|
107
|
+
const runId = run?.id || p.runId || '';
|
|
108
|
+
const slot = `${runId}/${p.taskId || ''}`;
|
|
109
|
+
if (type === 'task.routed') {
|
|
110
|
+
const engine = normalizeEngine(p.engine);
|
|
111
|
+
if (engine) this._routed.set(slot, engine);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (type === 'task.reappointed' || type === 'task.handoff') {
|
|
115
|
+
const from = this._routed.get(slot);
|
|
116
|
+
if (!from) return null;
|
|
117
|
+
const fact = type === 'task.reappointed'
|
|
118
|
+
? { engine: from, kind: 'declined', at: ev.at, runId, taskId: p.taskId, declined: { reason: declineReasonOf(p.error), error: p.error } }
|
|
119
|
+
: { engine: from, kind: 'rotated-from', at: ev.at, runId, taskId: p.taskId, rotated: { to: p.to ? { id: p.to } : undefined, reason: p.reason || `handed off by ${p.by || 'a person'}` } };
|
|
120
|
+
return this.append(fact).catch(() => null);
|
|
121
|
+
}
|
|
122
|
+
if (type === 'task.scored') {
|
|
123
|
+
const engine = normalizeEngine(p.engine);
|
|
124
|
+
if (!engine) return null;
|
|
125
|
+
const ok = p.outcome !== 'task.failed';
|
|
126
|
+
return this.append({
|
|
127
|
+
engine, kind: 'call', at: ev.at, runId, taskId: p.taskId, agentId: p.agentId,
|
|
128
|
+
call: { ok, totalMs: p.size?.ms, tokens: p.size?.tokens || undefined, empty: !ok && /no answer|did not answer|returned nothing|empty/i.test(String(p.error || '')) },
|
|
129
|
+
refs: p.refs,
|
|
130
|
+
}).catch(() => null);
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* A rating a person gave an AGENT'S task (scorecard-store.js) lands on the engine that
|
|
136
|
+
* served it too: `chain` is the agent's scorecard, `entry` the rating just appended.
|
|
137
|
+
*/
|
|
138
|
+
fromRating(chain, entry) {
|
|
139
|
+
if (!entry?.rating || !Array.isArray(chain)) return null;
|
|
140
|
+
const r = entry.rating;
|
|
141
|
+
const task = r.about != null ? chain.find((e) => e.seq === r.about)
|
|
142
|
+
: entry.taskId ? chain.find((e) => (e.kind === 'task.done' || e.kind === 'task.failed') && e.taskId === entry.taskId && (!entry.runId || e.runId === entry.runId))
|
|
143
|
+
: entry.runId ? (() => { const xs = chain.filter((e) => (e.kind === 'task.done' || e.kind === 'task.failed') && e.runId === entry.runId); return xs.length === 1 ? xs[0] : null; })() : null;
|
|
144
|
+
if (!task?.engine) return null;
|
|
145
|
+
return this.append({ engine: task.engine, kind: 'rating', at: entry.at, runId: task.runId, taskId: task.taskId, agentId: entry.agentId, rating: { by: r.by, score: r.score, jobKind: entry.jobKind, agentId: entry.agentId } }).catch(() => null);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function createEngineLedgerStore(opts) { return new EngineLedgerStore(opts).load(); }
|
|
150
|
+
export { DECLINE_REASONS };
|