@chatpanel/events 0.85.0 → 0.89.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 +249 -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 +13 -2
- package/job.js +149 -0
- package/model-candidates.js +358 -0
- package/model-ledger.js +228 -0
- package/model-picker.js +3 -1
- package/package.json +21 -1
- package/project.js +170 -0
- package/recruit.js +419 -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
package/team.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// survive an `origin`, and a team a client stores as trusted is stored as nothing of the kind.
|
|
17
17
|
|
|
18
18
|
import { validateBudget, normalizeBudget } from './budget.js';
|
|
19
|
+
import { normalizeEngineSpec, validateEngineSpec, tierOf } from './engine.js';
|
|
19
20
|
|
|
20
21
|
export const TEAM_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
21
22
|
export const ROLE_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/i;
|
|
@@ -23,10 +24,22 @@ export const ROLE_MODES = Object.freeze(['model', 'subagent', 'recipe']);
|
|
|
23
24
|
export const ROLE_PREFERS = Object.freeze(['cheap', 'balanced', 'strong']);
|
|
24
25
|
export const MERGE_POLICIES = Object.freeze(['judge', 'converge', 'concat', 'first']);
|
|
25
26
|
export const PLAN_MODES = Object.freeze(['fixed', 'planner']);
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The tool groups a role may hold. `mcp:<server>` narrows to one server; `mcp` is all of them.
|
|
29
|
+
*
|
|
30
|
+
* The work grants (architecture-pillars.md §14.2) are for an agent whose engine is a harness
|
|
31
|
+
* running in a checkout: `shell` and `fs:write` say so explicitly instead of riding along
|
|
32
|
+
* with the harness; `scm:read` reads the repo and its hub, `scm:push` pushes ITS OWN branch
|
|
33
|
+
* (`cp/<project>/<job>`), `scm:pr` opens a pull request, and `scm:merge` is held by the
|
|
34
|
+
* Gate — grantable only where the org's `gate.json` allows it. A chat-model role that holds
|
|
35
|
+
* one of these holds nothing: only a harness engine can use them, and the bridge enforces it.
|
|
36
|
+
*/
|
|
37
|
+
export const GRANTABLE = Object.freeze(['none', 'data', 'web', 'mcp', 'history', 'shell', 'fs:write', 'scm:read', 'scm:push', 'scm:pr', 'scm:merge']);
|
|
38
|
+
export const WORK_GRANTS = Object.freeze(['shell', 'fs:write', 'scm:read', 'scm:push', 'scm:pr', 'scm:merge']);
|
|
39
|
+
export const GRANT_RE = /^(none|data|web|history|mcp|mcp:[a-zA-Z0-9_.:-]{1,64}|shell|fs:write|scm:(read|push|pr|merge))$/;
|
|
29
40
|
export const MAX_ROLES = 8;
|
|
41
|
+
/** A role that stands for an agent from the pool: `agent` names it (agent.js `AGENT_ID_RE`). */
|
|
42
|
+
export const AGENT_REF_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
|
|
30
43
|
|
|
31
44
|
export class TeamError extends Error {
|
|
32
45
|
constructor(code, message) { super(message); this.name = 'TeamError'; this.code = code; }
|
|
@@ -59,7 +72,11 @@ export function validateTeam(team) {
|
|
|
59
72
|
if (r.mode !== undefined && !ROLE_MODES.includes(r.mode)) errors.push(`${w}.mode: one of ${ROLE_MODES.join(', ')}`);
|
|
60
73
|
if (r.prefer !== undefined && !ROLE_PREFERS.includes(r.prefer)) errors.push(`${w}.prefer: one of ${ROLE_PREFERS.join(', ')}`);
|
|
61
74
|
if ((r.mode || 'model') === 'recipe' && !r.recipe) errors.push(`${w}.recipe: a recipe name is required in recipe mode`);
|
|
62
|
-
if (
|
|
75
|
+
if (r.agent !== undefined && r.agent !== null && !AGENT_REF_RE.test(String(r.agent))) errors.push(`${w}.agent: an agent id`);
|
|
76
|
+
// A role that stands for an agent takes its prompt from the pool (agent.js resolveTeam);
|
|
77
|
+
// a role that stands for nobody must say what it does.
|
|
78
|
+
if ((r.mode || 'model') !== 'recipe' && !r.agent && !String(r.prompt || '').trim()) errors.push(`${w}.prompt: what this role does`);
|
|
79
|
+
errors.push(...validateEngineSpec(r.engine, `${w}.engine`));
|
|
63
80
|
const bad = (Array.isArray(r.grants) ? r.grants : []).filter((g) => !GRANT_RE.test(String(g)));
|
|
64
81
|
if (bad.length) errors.push(`${w}.grants: not grantable: ${bad.join(', ')}${bad.some((g) => /^page/.test(String(g))) ? ' (a tab is one person\'s; a team may not act on it)' : ''}`);
|
|
65
82
|
});
|
|
@@ -89,12 +106,21 @@ export function normalizeTeam(team, { builtin = false } = {}) {
|
|
|
89
106
|
id: String(r.id),
|
|
90
107
|
name: String(r.name || r.id).slice(0, 60),
|
|
91
108
|
mode: ROLE_MODES.includes(r.mode) ? r.mode : 'model',
|
|
92
|
-
prefer: ROLE_PREFERS.includes(r.prefer) ? r.prefer : 'balanced',
|
|
109
|
+
prefer: ROLE_PREFERS.includes(r.prefer) ? r.prefer : (r.engine ? tierOf(r.engine) : 'balanced'),
|
|
93
110
|
...(r.model ? { model: String(r.model) } : {}),
|
|
111
|
+
...(r.agent ? { agent: String(r.agent) } : {}),
|
|
112
|
+
...(r.engine ? { engine: normalizeEngineSpec(r.engine) } : {}),
|
|
94
113
|
prompt: String(r.prompt || '').trim().slice(0, 4000),
|
|
95
|
-
grants:
|
|
114
|
+
// A role that stands for an agent holds the agent's grants unless it narrows them: no
|
|
115
|
+
// list means "the agent's", so the key is left out rather than stored as `none`.
|
|
116
|
+
...(r.agent && !(Array.isArray(r.grants) && r.grants.length) ? {} : { grants: normalizeGrants(r.grants) }),
|
|
96
117
|
...(r.recipe ? { recipe: String(r.recipe) } : {}),
|
|
97
118
|
...(Array.isArray(r.dependsOn) ? { dependsOn: r.dependsOn.map(String).filter((d) => d !== r.id) } : {}),
|
|
119
|
+
// What agent.js resolveTeam fills from the pool; kept so the runner's roles carry it.
|
|
120
|
+
...(Array.isArray(r.skills) && r.skills.length ? { skills: r.skills.map(String).slice(0, 32) } : {}),
|
|
121
|
+
...(r.workdir ? { workdir: String(r.workdir).slice(0, 400) } : {}),
|
|
122
|
+
...(r.egress === 'redacted' || r.egress === 'delegated' ? { egress: r.egress } : {}),
|
|
123
|
+
...(r.memoryScope ? { memoryScope: String(r.memoryScope).slice(0, 120) } : {}),
|
|
98
124
|
})),
|
|
99
125
|
budget: normalizeBudget(team.budget),
|
|
100
126
|
enabled: team.enabled !== false,
|
|
@@ -115,11 +141,25 @@ export function grantAllows(grants, groupId, serverId = '') {
|
|
|
115
141
|
return g.includes(groupId);
|
|
116
142
|
}
|
|
117
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The SCM ladder: `merge` ⊃ `pr` ⊃ `push` ⊃ `read` — a role that may open a PR may push the
|
|
146
|
+
* branch the PR is from, and anyone who may push may read. `push` is the role's OWN branch
|
|
147
|
+
* only; the bridge names it (`cp/<project>/<job>`) and refuses any other.
|
|
148
|
+
*/
|
|
149
|
+
const SCM_LADDER = ['read', 'push', 'pr', 'merge'];
|
|
150
|
+
export function scmAllows(grants, action) {
|
|
151
|
+
const g = normalizeGrants(grants);
|
|
152
|
+
const want = SCM_LADDER.indexOf(String(action || '').replace(/^scm:/, ''));
|
|
153
|
+
if (want < 0 || g.includes('none')) return false;
|
|
154
|
+
const held = Math.max(-1, ...g.filter((x) => x.startsWith('scm:')).map((x) => SCM_LADDER.indexOf(x.slice(4))));
|
|
155
|
+
return held >= want;
|
|
156
|
+
}
|
|
157
|
+
|
|
118
158
|
/** One line a person reads per role: name · tier/model · grants · mode. */
|
|
119
159
|
export function describeRole(r) {
|
|
120
160
|
const who = r.model || r.prefer || 'balanced';
|
|
121
161
|
const grants = (r.grants || ['none']).join(', ');
|
|
122
|
-
return `${r.name || r.id} — ${who}${r.mode && r.mode !== 'model' ? ` (${r.mode})` : ''} · tools: ${grants}`;
|
|
162
|
+
return `${r.name || r.id}${r.agent ? ` (agent: ${r.agent})` : ''} — ${who}${r.mode && r.mode !== 'model' ? ` (${r.mode})` : ''} · tools: ${grants}`;
|
|
123
163
|
}
|
|
124
164
|
|
|
125
165
|
// ── Starters and the editor's form ───────────────────────────────────────────────────────
|
|
@@ -150,6 +190,55 @@ export const STARTER_TEAMS = Object.freeze([
|
|
|
150
190
|
],
|
|
151
191
|
budget: { tokens: 30000, ms: 240000 },
|
|
152
192
|
},
|
|
193
|
+
// ── The engineering teams (architecture-pillars.md §12.2) — roles stand for the standing
|
|
194
|
+
// agents in agent.js STARTER_AGENTS; a role's prompt is the agent's, its engine the agent's.
|
|
195
|
+
// A feature that crosses repos recruits one Implementer per repo (the role says which via
|
|
196
|
+
// its prompt); these starters name one.
|
|
197
|
+
{
|
|
198
|
+
name: 'feature',
|
|
199
|
+
description: 'Architect plans, an Implementer builds on a branch, Reviewer and Tester check, Scribe writes it up. The Architect judges.',
|
|
200
|
+
plan: 'planner', merge: 'judge', judge: 'architect',
|
|
201
|
+
roles: [
|
|
202
|
+
{ id: 'architect', agent: 'architect' },
|
|
203
|
+
{ id: 'implementer', agent: 'implementer', dependsOn: ['architect'] },
|
|
204
|
+
{ id: 'reviewer', agent: 'reviewer', dependsOn: ['implementer'] },
|
|
205
|
+
{ id: 'tester', agent: 'tester', dependsOn: ['implementer'] },
|
|
206
|
+
{ id: 'scribe', agent: 'scribe', dependsOn: ['reviewer', 'tester'] },
|
|
207
|
+
],
|
|
208
|
+
budget: { tokens: 400000, ms: 3600000 },
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'fix',
|
|
212
|
+
description: 'One Implementer fixes it on a branch, the Tester runs the guard, the Scribe notes it.',
|
|
213
|
+
plan: 'fixed', merge: 'concat',
|
|
214
|
+
roles: [
|
|
215
|
+
{ id: 'implementer', agent: 'implementer' },
|
|
216
|
+
{ id: 'tester', agent: 'tester', dependsOn: ['implementer'] },
|
|
217
|
+
{ id: 'scribe', agent: 'scribe', dependsOn: ['tester'] },
|
|
218
|
+
],
|
|
219
|
+
budget: { tokens: 150000, ms: 1800000 },
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: 'docs',
|
|
223
|
+
description: 'The Architect decides what the docs should say; the Scribe proposes the text.',
|
|
224
|
+
plan: 'fixed', merge: 'concat',
|
|
225
|
+
roles: [
|
|
226
|
+
{ id: 'architect', agent: 'architect' },
|
|
227
|
+
{ id: 'scribe', agent: 'scribe', dependsOn: ['architect'] },
|
|
228
|
+
],
|
|
229
|
+
budget: { tokens: 80000, ms: 900000 },
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: 'release',
|
|
233
|
+
description: 'The Tester runs the guard on the merged branch, Release bumps and asks before publishing, the Scribe records the version.',
|
|
234
|
+
plan: 'fixed', merge: 'concat',
|
|
235
|
+
roles: [
|
|
236
|
+
{ id: 'tester', agent: 'tester' },
|
|
237
|
+
{ id: 'release', agent: 'release', dependsOn: ['tester'] },
|
|
238
|
+
{ id: 'scribe', agent: 'scribe', dependsOn: ['release'] },
|
|
239
|
+
],
|
|
240
|
+
budget: { tokens: 60000, ms: 1800000 },
|
|
241
|
+
},
|
|
153
242
|
]);
|
|
154
243
|
|
|
155
244
|
/**
|
|
@@ -163,7 +252,7 @@ export function slugTeamName(name) {
|
|
|
163
252
|
|
|
164
253
|
/** Fresh copies — a starter is a template, never the stored record. */
|
|
165
254
|
export function starterTeams() {
|
|
166
|
-
return STARTER_TEAMS.map((t) => ({ ...t, roles: t.roles.map((r) => ({ ...r, grants: [...r.grants] })), budget: { ...t.budget } }));
|
|
255
|
+
return STARTER_TEAMS.map((t) => ({ ...t, roles: t.roles.map((r) => ({ ...r, ...(r.grants ? { grants: [...r.grants] } : {}), ...(r.dependsOn ? { dependsOn: [...r.dependsOn] } : {}) })), budget: { ...t.budget } }));
|
|
167
256
|
}
|
|
168
257
|
|
|
169
258
|
/** A blank team for the editor: one role, the smallest budget that is still a budget. */
|
|
@@ -176,6 +265,7 @@ export function blankTeam() {
|
|
|
176
265
|
* the budget as numbers that may be blank; a blank judge under `merge: judge` is the last
|
|
177
266
|
* role, which is the writer in every starter.
|
|
178
267
|
*/
|
|
268
|
+
const grantsText = (g) => String(Array.isArray(g) ? g.join(',') : g || '').split(/[,\s]+/).map((x) => x.trim()).filter(Boolean);
|
|
179
269
|
export function teamFromForm(form) {
|
|
180
270
|
const roles = (Array.isArray(form.roles) ? form.roles : []).map((r) => ({
|
|
181
271
|
id: String(r.id || '').trim(),
|
|
@@ -183,7 +273,12 @@ export function teamFromForm(form) {
|
|
|
183
273
|
prompt: String(r.prompt || ''),
|
|
184
274
|
prefer: r.prefer || 'balanced',
|
|
185
275
|
...(r.model ? { model: String(r.model) } : {}),
|
|
186
|
-
|
|
276
|
+
...(r.agent ? { agent: String(r.agent).trim() } : {}),
|
|
277
|
+
...(r.engine ? { engine: r.engine } : {}),
|
|
278
|
+
...(Array.isArray(r.dependsOn) ? { dependsOn: r.dependsOn.map(String) } : {}),
|
|
279
|
+
// Blank grants on a role that stands for an agent mean "the agent's"; on any other role
|
|
280
|
+
// they mean none.
|
|
281
|
+
...(grantsText(r.grants).length ? { grants: grantsText(r.grants) } : r.agent ? {} : { grants: ['none'] }),
|
|
187
282
|
}));
|
|
188
283
|
const budget = {};
|
|
189
284
|
for (const k of ['tokens', 'calls', 'ms', 'usd']) {
|
package/voice-speaker.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// WHOSE VOICE IS THIS — the gate that keeps a conversation to the person having it.
|
|
2
|
+
//
|
|
3
|
+
// In a voice conversation every finalized sentence is SENT, so anything the microphone hears
|
|
4
|
+
// becomes a question: a television, a colleague at the next desk, someone answering their own
|
|
5
|
+
// phone behind you. The engine transcribes them all perfectly and correctly, and the
|
|
6
|
+
// assistant answers the room.
|
|
7
|
+
//
|
|
8
|
+
// The gateway already computes a 512-d speaker fingerprint per committed segment and clusters
|
|
9
|
+
// it into a stable label for the session (`diarize-engine.js`). All that is missing is the
|
|
10
|
+
// DECISION, which is this file: the first voice in a conversation is the person who started
|
|
11
|
+
// it, and later sentences from a different voice are not their turn.
|
|
12
|
+
//
|
|
13
|
+
// THE FAILURE DIRECTION MATTERS MORE THAN THE FEATURE. An assistant that occasionally answers
|
|
14
|
+
// the television is annoying; one that ignores YOU is broken, and from the outside the two
|
|
15
|
+
// look identical — a mic that is open and going nowhere. So every uncertain case sends:
|
|
16
|
+
//
|
|
17
|
+
// · no speaker on the final (diarization off, model missing, embedding failed) → send;
|
|
18
|
+
// · no primary enrolled yet → this is the first voice, enroll it and send;
|
|
19
|
+
// · the primary has not been heard for a long time → adopt whoever is talking now and
|
|
20
|
+
// send, because the phone may have been handed over, or the first voice may have been
|
|
21
|
+
// the television while the user was drawing breath.
|
|
22
|
+
//
|
|
23
|
+
// Only one case holds: a DIFFERENT voice, while the person having the conversation is still
|
|
24
|
+
// in it. That is the case the user asked for and the only one we can be confident about.
|
|
25
|
+
//
|
|
26
|
+
// The engine's own honesty note applies here too: embeddings separate different speakers
|
|
27
|
+
// well, but similar voices can merge — so this gate can let a very similar voice through. It
|
|
28
|
+
// never claims to be security, only to keep the room out of the conversation.
|
|
29
|
+
|
|
30
|
+
/** How long the primary must be silent before another voice may take over the conversation. */
|
|
31
|
+
export const RE_ENROLL_MS = 45_000;
|
|
32
|
+
|
|
33
|
+
/** A label the gateway pins to the microphone channel; never a guess, so always the primary. */
|
|
34
|
+
export const PINNED_SELF = 'You';
|
|
35
|
+
|
|
36
|
+
const labelOf = (speaker) => {
|
|
37
|
+
if (!speaker) return '';
|
|
38
|
+
if (typeof speaker === 'string') return speaker;
|
|
39
|
+
return String(speaker.label || speaker.id || '');
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A per-conversation speaker gate.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
* @param {number} [opts.reEnrollMs] silence after which another voice may take over
|
|
47
|
+
* @param {() => number} [opts.now] injected clock, so the rules are testable without waiting
|
|
48
|
+
*/
|
|
49
|
+
export function createSpeakerGate({ reEnrollMs = RE_ENROLL_MS, now = Date.now } = {}) {
|
|
50
|
+
let primary = ''; // the label of the person having this conversation
|
|
51
|
+
let lastHeard = 0; // when the primary last said something
|
|
52
|
+
let held = 0; // sentences kept out, for the UI to report honestly
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
/** The enrolled voice, or '' before anyone has spoken. */
|
|
56
|
+
primary: () => primary,
|
|
57
|
+
/** How many sentences this gate has held back. */
|
|
58
|
+
heldCount: () => held,
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Should this finalized sentence become a turn?
|
|
62
|
+
*
|
|
63
|
+
* @param {{ speaker?: any }} [final]
|
|
64
|
+
* @returns {{ send: boolean, reason: 'no-speaker'|'enrolled'|'primary'|'adopted'|'other', speaker: string }}
|
|
65
|
+
*/
|
|
66
|
+
admit(final = {}) {
|
|
67
|
+
const label = labelOf(final.speaker);
|
|
68
|
+
// Diarization is optional and fails open — a sentence with no speaker is always sent.
|
|
69
|
+
if (!label) return { send: true, reason: 'no-speaker', speaker: '' };
|
|
70
|
+
|
|
71
|
+
const t = now();
|
|
72
|
+
if (!primary) {
|
|
73
|
+
primary = label;
|
|
74
|
+
lastHeard = t;
|
|
75
|
+
return { send: true, reason: 'enrolled', speaker: label };
|
|
76
|
+
}
|
|
77
|
+
if (label === primary) {
|
|
78
|
+
lastHeard = t;
|
|
79
|
+
return { send: true, reason: 'primary', speaker: label };
|
|
80
|
+
}
|
|
81
|
+
// A different voice. Only take over when the conversation has clearly moved on.
|
|
82
|
+
if (t - lastHeard >= reEnrollMs) {
|
|
83
|
+
primary = label;
|
|
84
|
+
lastHeard = t;
|
|
85
|
+
return { send: true, reason: 'adopted', speaker: label };
|
|
86
|
+
}
|
|
87
|
+
held += 1;
|
|
88
|
+
return { send: false, reason: 'other', speaker: label };
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Forget who was talking. Called when a conversation starts, and by a user who wants the
|
|
93
|
+
* gate to hear them again — a voice it merged or mislabelled must be recoverable without
|
|
94
|
+
* ending the session.
|
|
95
|
+
*/
|
|
96
|
+
reset() { primary = ''; lastHeard = 0; held = 0; },
|
|
97
|
+
};
|
|
98
|
+
}
|