@chatpanel/events 0.84.1 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- /** The tool groups a role may hold. `mcp:<server>` narrows to one server; `mcp` is all of them. */
27
- export const GRANTABLE = Object.freeze(['none', 'data', 'web', 'mcp', 'history']);
28
- export const GRANT_RE = /^(none|data|web|history|mcp|mcp:[a-zA-Z0-9_.:-]{1,64})$/;
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 ((r.mode || 'model') !== 'recipe' && !String(r.prompt || '').trim()) errors.push(`${w}.prompt: what this role does`);
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: normalizeGrants(r.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
- grants: String(Array.isArray(r.grants) ? r.grants.join(',') : r.grants || 'none').split(/[,\s]+/).map((g) => g.trim()).filter(Boolean),
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']) {
@@ -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
+ }