@chatpanel/events 0.85.0 → 0.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent.js +248 -0
- package/attribution.js +132 -0
- package/client-prefs.js +10 -1
- package/engine.js +131 -0
- package/gate.js +74 -0
- package/index.js +11 -2
- package/job.js +148 -0
- package/model-candidates.js +376 -0
- package/model-ledger.js +204 -0
- package/model-picker.js +3 -1
- package/package.json +19 -1
- package/project.js +170 -0
- package/route-strategies.js +232 -0
- package/scm-connection.js +180 -0
- package/scorecard.js +148 -4
- package/team-run.js +41 -6
- package/team-tool.js +16 -6
- package/team-trail.js +6 -0
- package/team.js +104 -9
- package/voice-speaker.js +98 -0
package/team-run.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// says so (`status: 'over-budget'`). Stop is one signal, fanned out.
|
|
18
18
|
|
|
19
19
|
import { normalizeTeam } from './team.js';
|
|
20
|
+
import { normalizeEngine, normalizeScm } from './scorecard.js';
|
|
20
21
|
import { createBudget } from './budget.js';
|
|
21
22
|
import { fixedPlan, plannerPrompt, parsePlan, waves } from './team-plan.js';
|
|
22
23
|
import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER } from './team-board.js';
|
|
@@ -79,9 +80,15 @@ export function dryRunTeam(team, request, { appoint = null } = {}) {
|
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
82
|
* @param callModel `async ({ runId, taskId, role, model, mode, system, prompt, tools, signal, onDelta }) =>
|
|
82
|
-
* { ok, text, usage?, error?, aborted? }` — the host's model turn
|
|
83
|
+
* { ok, text, usage?, error?, aborted?, scm? }` — the host's model turn; `scm`
|
|
84
|
+
* is what a harness did in a git checkout (`{ repo, branch, head, headAfter,
|
|
85
|
+
* commits }`), when the bridge reported one
|
|
83
86
|
* @param toolsFor `(role) => toolset | undefined` — narrowed to the role's grants by the host
|
|
84
|
-
* @param appoint `(role) => { model, mode } | null` — the host's
|
|
87
|
+
* @param appoint `(role) => { model, mode, engine?, reasons?, alternatives? } | null` — the host's
|
|
88
|
+
* roster through cowriter-router. `engine` (`{ kind: 'model'|'harness', id,
|
|
89
|
+
* model? }`) says WHAT the model id is, so the record can split by it;
|
|
90
|
+
* `reasons` and `alternatives` are why this one and who else could have —
|
|
91
|
+
* said as `task.routed`, which every run records from here on (pillars §13)
|
|
85
92
|
* @param runRecipe `async (name, params) => result` for `mode: 'recipe'` roles (optional)
|
|
86
93
|
* @param emit `(type, payload)` — run.started · plan.ready · task.started · task.finding ·
|
|
87
94
|
* task.done · task.failed · run.merging · run.done; the host forwards them to
|
|
@@ -129,6 +136,21 @@ export async function runTeam({
|
|
|
129
136
|
return (appoint ? appoint(r, { exclude }) : null) || (r.model && !exclude?.has(r.model) ? { model: r.model, mode: r.mode } : null);
|
|
130
137
|
};
|
|
131
138
|
const MAX_APPOINTMENTS = 3;
|
|
139
|
+
// The routing decision, on the record: which engine, why, who else could have. A host that
|
|
140
|
+
// does not say the kind gets `model` — the honest default for a bare id; the pilot's hosts
|
|
141
|
+
// both say. Exploration (a tier cheaper on purpose) is the project loop's, later; false here.
|
|
142
|
+
const routeOf = (m, role, { attempt = 1, exclude = null, handoff = null } = {}) => {
|
|
143
|
+
const reasons = Array.isArray(m.reasons) ? m.reasons.map(String) : [];
|
|
144
|
+
if (handoff) reasons.unshift(`handed off by ${handoff.by}${handoff.reason ? ` — ${handoff.reason}` : ''}`);
|
|
145
|
+
else if (!reasons.length && role.model && m.model === role.model) reasons.push('pinned by the role');
|
|
146
|
+
if (attempt > 1 && exclude?.size) reasons.push(`after ${[...exclude].join(', ')} (unavailable)`);
|
|
147
|
+
return {
|
|
148
|
+
engine: normalizeEngine(m.engine || { id: m.model }),
|
|
149
|
+
reasons,
|
|
150
|
+
alternatives: (Array.isArray(m.alternatives) ? m.alternatives : []).slice(0, 5).map((a) => normalizeEngine(a)).filter(Boolean),
|
|
151
|
+
exploration: false,
|
|
152
|
+
};
|
|
153
|
+
};
|
|
132
154
|
// A model that was not there for one member is not there for the next: what failed as
|
|
133
155
|
// unavailable anywhere in this run is skipped by every later appointment. Two members
|
|
134
156
|
// each spent two minutes finding out the same agent was down.
|
|
@@ -199,6 +221,8 @@ export async function runTeam({
|
|
|
199
221
|
// the record. Only a task that has never been attempted starts from the bare prompt.
|
|
200
222
|
let transcript = was ? [...was.transcript] : [];
|
|
201
223
|
const attempts = was?.attempts ? [...was.attempts] : [];
|
|
224
|
+
let routed = null; // the last routing decision, on the task row
|
|
225
|
+
let scm = null; // what the last attempt did in a checkout
|
|
202
226
|
// The task's own abort: an ask nobody answered in time stops THIS member's turn (the
|
|
203
227
|
// run then checkpoints), without stopping the run's other members. A person's hand-off
|
|
204
228
|
// aborts it too, and names where the task continues.
|
|
@@ -250,6 +274,7 @@ export async function runTeam({
|
|
|
250
274
|
let lastModel = was?.attempts?.at?.(-1)?.model || null;
|
|
251
275
|
for (let attempt = 1; ; attempt++) {
|
|
252
276
|
let m;
|
|
277
|
+
const handoffNow = handoffTo;
|
|
253
278
|
if (handoffTo) {
|
|
254
279
|
// A person's hand-off names the model; the task continues there whatever the
|
|
255
280
|
// roster would have chosen. Said on the board, so everyone knows who has it.
|
|
@@ -267,7 +292,9 @@ export async function runTeam({
|
|
|
267
292
|
if (attempt > 1 && lastErr) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude], error: lastErr });
|
|
268
293
|
// Who is doing this task, for a ledger that shows the lanes — said per attempt.
|
|
269
294
|
say('task.model', { taskId: task.id, role: role.id, model: m.model, attempt });
|
|
270
|
-
|
|
295
|
+
routed = routeOf(m, role, { attempt, exclude, handoff: handoffNow });
|
|
296
|
+
say('task.routed', { taskId: task.id, role: role.id, attempt, ...routed });
|
|
297
|
+
attempts.push({ model: m.model, engine: routed.engine, at: now(), continued: !!note });
|
|
271
298
|
const sent = messagesFor({ transcript }, { prompt, note });
|
|
272
299
|
// The record grows AS THE ATTEMPT GOES: a host that reports each wire message the
|
|
273
300
|
// moment it exists (a tool call, its result) puts it on the record then, so a
|
|
@@ -283,6 +310,8 @@ export async function runTeam({
|
|
|
283
310
|
});
|
|
284
311
|
usage = res?.usage || null;
|
|
285
312
|
if (usage) budget.charge(usage);
|
|
313
|
+
// What the attempt did in a checkout, when the host's harness reported one (§14).
|
|
314
|
+
if (normalizeScm(res?.scm)) { scm = normalizeScm(res.scm); say('task.scm', { taskId: task.id, role: role.id, ...scm }); }
|
|
286
315
|
// Whatever the attempt did is the task's now — on the record, before any verdict.
|
|
287
316
|
// What the host already reported step by step is not reported again.
|
|
288
317
|
transcript = mergeTranscript(sent, res);
|
|
@@ -327,14 +356,14 @@ export async function runTeam({
|
|
|
327
356
|
if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role.id, finding: f }); }
|
|
328
357
|
const thread = board.threadForTask(task.id);
|
|
329
358
|
if (thread && status !== 'waiting') board.setThreadStatus(thread.id, 'resolved');
|
|
330
|
-
const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
|
|
359
|
+
const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(routed ? { routed } : {}), ...(scm ? { scm } : {}), ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
|
|
331
360
|
tasksOut.push(row);
|
|
332
361
|
say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length, ...(askedAndWaiting ? { threadId: askedAndWaiting } : {}) });
|
|
333
362
|
// The fact for the member's scorecard (scorecard.js): how big, with what, alongside whom,
|
|
334
363
|
// in which role — produced here, attested by the store, never written by the agent.
|
|
335
364
|
if (status === 'ok' || status === 'failed') {
|
|
336
365
|
say('task.scored', {
|
|
337
|
-
agentId: role.agent || role.id, taskId: task.id, role: role.id, model: lastModelOf(attempts), outcome: status === 'ok' ? 'task.done' : 'task.failed',
|
|
366
|
+
agentId: role.agent || role.id, taskId: task.id, role: role.id, model: lastModelOf(attempts), engine: routed?.engine || null, scm: scm || undefined, outcome: status === 'ok' ? 'task.done' : 'task.failed',
|
|
338
367
|
size: { ms: row.ms, steps: (row.transcript || []).length, tools: (row.transcript || []).filter((m) => m.role === 'tool').length, findings: findings.length, tokens: usage ? Number(usage.input_tokens || usage.prompt_tokens || 0) + Number(usage.output_tokens || usage.completion_tokens || 0) : 0 },
|
|
339
368
|
roleKind: 'ic', tools: toolNamesOf(row.transcript), with: t.roles.filter((r) => r.id !== role.id).map((r) => r.agent || r.id),
|
|
340
369
|
refs: [`run:${id}`, ...(board.threadForTask(task.id) ? [`thread:${board.threadForTask(task.id).id}`] : [])], error: error || undefined,
|
|
@@ -383,11 +412,15 @@ export async function runTeam({
|
|
|
383
412
|
let res = null;
|
|
384
413
|
const excl = new Set();
|
|
385
414
|
let judgeErr = '';
|
|
415
|
+
let judgeModel = null; // the appointment that answered (or the last one tried)
|
|
416
|
+
let judgeRoute = null;
|
|
386
417
|
for (let attempt = 1; attempt <= MAX_APPOINTMENTS; attempt++) {
|
|
387
418
|
const mm = attempt === 1 ? (runExclude.has(m?.model) ? modelFor(judge, excluding(excl)) : m) : modelFor(judge, excluding(excl));
|
|
388
419
|
if (!mm?.model) break;
|
|
389
420
|
if (attempt > 1) say('task.reappointed', { taskId: 'merge', role: judge.id, model: mm.model, after: [...excl], error: judgeErr });
|
|
390
421
|
say('task.model', { taskId: 'merge', role: judge.id, model: mm.model, attempt });
|
|
422
|
+
judgeModel = mm; judgeRoute = routeOf(mm, judge, { attempt, exclude: excl });
|
|
423
|
+
say('task.routed', { taskId: 'merge', role: judge.id, attempt, ...judgeRoute });
|
|
391
424
|
res = await callModel({ runId: id, taskId: 'merge', role: judge.id, model: mm.model, mode: 'model', system: judge.prompt, prompt, tools: judgeTools, signal, onDelta: (delta, full) => say('task.delta', { taskId: 'merge', role: judge.id, delta, text: full }) });
|
|
392
425
|
if (res?.usage) budget.charge(res.usage);
|
|
393
426
|
if (res?.ok && String(res.text || '').trim()) break;
|
|
@@ -397,7 +430,9 @@ export async function runTeam({
|
|
|
397
430
|
}
|
|
398
431
|
const judged = res?.ok && String(res.text || '').trim();
|
|
399
432
|
say(judged ? 'task.done' : 'task.failed', { taskId: 'merge', role: judge.id, status: judged ? 'ok' : 'failed', error: judged ? null : (res?.error || 'the judge did not answer'), findings: 0 });
|
|
400
|
-
|
|
433
|
+
const judgeScm = normalizeScm(res?.scm);
|
|
434
|
+
if (judgeScm) say('task.scm', { taskId: 'merge', role: judge.id, ...judgeScm });
|
|
435
|
+
say('task.scored', { agentId: judge.id, taskId: 'merge', role: judge.id, model: judgeModel?.model || m?.model, engine: judgeRoute?.engine || null, scm: judgeScm || undefined, outcome: judged ? 'task.done' : 'task.failed', size: { ms: 0, steps: 1, tools: 0, findings: board.all().length, tokens: 0 }, roleKind: 'orchestrator', tools: [], with: t.roles.filter((r) => r.id !== judge.id).map((r) => r.agent || r.id), refs: [`run:${id}`] });
|
|
401
436
|
say('run.usage', { usage: budget.snapshot() });
|
|
402
437
|
proposal = judged ? { kind: 'answer', text: String(res.text || ''), by: judge.id } : mergeCheap(t, board.all(), tasksOut);
|
|
403
438
|
} else {
|
package/team-tool.js
CHANGED
|
@@ -36,7 +36,7 @@ export function teamToolSpec(teams) {
|
|
|
36
36
|
+ '{"action":"dry_run","name":"<team>","request":"…"} shows roles, models, tools and budget without running; '
|
|
37
37
|
+ '{"action":"save","team":{…}} proposes a NEW team after a task that would benefit from several roles — the user approves it on a card. '
|
|
38
38
|
+ 'A team: {"name":"research" (a short identifier: letters, digits, - _; used as /research),"description":"…","roles":[{"id":"researcher","prompt":"…","prefer":"balanced","grants":["data","web"]},{"id":"writer","prompt":"…","prefer":"strong","grants":["none"]}],"merge":"judge","judge":"writer","budget":{"tokens":40000,"ms":300000}}. '
|
|
39
|
-
+ 'grants: none | data | web | history | mcp | mcp:<server
|
|
39
|
+
+ 'grants: none | data | web | history | mcp | mcp:<server> | shell | fs:write | scm:read | scm:push | scm:pr. A role may say "agent":"<id>" instead of a prompt to stand for an agent from the pool. merge: judge | converge | concat | first. A budget is required. '
|
|
40
40
|
+ 'Order the work with "dependsOn": a role that builds on another\'s findings (a budget checker on a researcher) lists it, so it runs after and reads the board instead of searching again. The judge does not need a task of its own - the merge is its work.',
|
|
41
41
|
parameters: {
|
|
42
42
|
type: 'object',
|
|
@@ -70,8 +70,16 @@ const json = (v) => JSON.stringify(v);
|
|
|
70
70
|
* @param confirmSave `async (detail, team) => 'allow' | 'deny'`; absent = save refused
|
|
71
71
|
* @param saveTeam `async (team) => void`
|
|
72
72
|
*/
|
|
73
|
-
|
|
73
|
+
/**
|
|
74
|
+
* `resolve` is the host's `(team) => team` that fills roles standing for agents from the
|
|
75
|
+
* pool (agent.js resolveTeam) — applied before a dry run and before a run, never to what is
|
|
76
|
+
* saved: the stored team keeps its references, the run gets the cards as they are now.
|
|
77
|
+
*/
|
|
78
|
+
export function teamToolProvider({ teams = [], run = null, appoint = null, confirmSave = null, saveTeam = null, resolve = null } = {}) {
|
|
74
79
|
const byName = new Map(usable(teams).map((t) => [t.name, t]));
|
|
80
|
+
// A team whose roles stand for agents is filled from the pool on the way to a run; a
|
|
81
|
+
// resolver that throws (an agent missing from the pool) is the tool's error, not a crash.
|
|
82
|
+
const resolved = (t) => (typeof resolve === 'function' ? resolve(t) : t);
|
|
75
83
|
let bound = null;
|
|
76
84
|
// One run per team+request per turn. A run that failed, answered with nothing, or ran
|
|
77
85
|
// out of budget comes back as a result the model must REPORT — asking for it again in the
|
|
@@ -97,7 +105,7 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
|
|
|
97
105
|
if (byName.has(team.name)) return json({ error: `A team named "${team.name}" already exists. Pick another name.` });
|
|
98
106
|
if (!confirmSave || !saveTeam) return json({ error: 'Saving a team needs the user\'s approval, which this surface cannot ask for. Describe the team and suggest saving it from the side panel or the desktop.' });
|
|
99
107
|
const norm = normalizeTeam(team);
|
|
100
|
-
const dry = dryRunTeam(norm, '', { appoint });
|
|
108
|
+
const dry = dryRunTeam(resolved(norm), '', { appoint });
|
|
101
109
|
const decision = await confirmSave(describeTeamForApproval(norm, dry), norm);
|
|
102
110
|
if (decision !== 'allow') return json({ error: `The user did not save "${norm.name}". Do not propose it again this turn.`, declined: true });
|
|
103
111
|
const stored = { ...norm, createdAt: Date.now() };
|
|
@@ -111,7 +119,8 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
|
|
|
111
119
|
const request = String(input?.request || '').trim();
|
|
112
120
|
|
|
113
121
|
if (action === 'dry_run') {
|
|
114
|
-
|
|
122
|
+
let dry;
|
|
123
|
+
try { dry = dryRunTeam(resolved(team), request, { appoint }); } catch (e) { return json({ error: e?.message || String(e) }); }
|
|
115
124
|
return json({ name: team.name, ok: dry.ok, missing: dry.missing, roles: dry.roles, plan: dry.plan, tasks: dry.tasks, merge: dry.merge, budget: dry.budget });
|
|
116
125
|
}
|
|
117
126
|
if (action === 'run') {
|
|
@@ -123,9 +132,10 @@ export function teamToolProvider({ teams = [], run = null, appoint = null, confi
|
|
|
123
132
|
const key = team.name;
|
|
124
133
|
const prior = ran.get(key);
|
|
125
134
|
if (prior) return json({ error: `The "${team.name}" team already ran in this turn (run ${prior.runId}, ${prior.status}). Do not run it again, even with a different request: report what it produced — ${prior.summary} — with its proposal, and ask the user how to proceed.`, runId: prior.runId, status: prior.status, tasks: prior.tasks, proposal: prior.proposal });
|
|
126
|
-
|
|
135
|
+
let dry;
|
|
136
|
+
try { dry = dryRunTeam(resolved(team), request, { appoint }); } catch (e) { return json({ error: e?.message || String(e) }); }
|
|
127
137
|
if (!dry.ok) return json({ error: `No model is available for role(s): ${dry.missing.join(', ')}.`, roles: dry.roles });
|
|
128
|
-
const result = await run({ team, request, toolset: bound });
|
|
138
|
+
const result = await run({ team: resolved(team), request, toolset: bound });
|
|
129
139
|
const findings = (result.board || []).map((f) => ({ role: f.role, kind: f.kind, text: f.text, refs: f.refs }));
|
|
130
140
|
const tasks = (result.tasks || []).map((x) => ({ id: x.id, role: x.role, status: x.status, ms: x.ms, findings: (x.findings || []).length, error: x.error || undefined }));
|
|
131
141
|
const failed = tasks.filter((x) => x.status !== 'ok');
|
package/team-trail.js
CHANGED
|
@@ -19,6 +19,10 @@ export function teamLine(ev) {
|
|
|
19
19
|
case 'task.handoff': return { type: 'status', text: `${role} handed off ${ev.from ? `from ${ev.from} ` : ''}to ${ev.to} by ${ev.by || 'person'}${ev.reason ? ` — ${ev.reason}` : ''}` };
|
|
20
20
|
case 'task.step': return null;
|
|
21
21
|
case 'task.scored': return null;
|
|
22
|
+
// The route is the lane's business (task.model already names it); the reasons are a line
|
|
23
|
+
// only when there are any — a re-appointment says its own.
|
|
24
|
+
case 'task.routed': return ev.reasons?.length && ev.attempt === 1 ? { type: 'status', text: `${role} → ${ev.engine?.id || '?'}${ev.engine?.model ? `/${ev.engine.model}` : ''} (${ev.reasons.join('; ')})` } : null;
|
|
25
|
+
case 'task.scm': return ev.commits ? { type: 'status', text: `${role} committed ${ev.commits} on ${ev.branch || 'a branch'}${ev.headAfter ? ` @ ${String(ev.headAfter).slice(0, 7)}` : ''}` } : null;
|
|
22
26
|
case 'task.reappointed': return { type: 'status', text: `${role} → ${ev.model} (${(ev.after || []).join(', ')} unavailable${ev.error ? `: ${String(ev.error).slice(0, 120)}` : ''})` };
|
|
23
27
|
case 'task.tool': return { type: 'tool', name: ev.name, text: `${role} ran ${ev.name}${ev.text ? ` — ${ev.text}` : ''}` };
|
|
24
28
|
case 'task.finding': return { type: 'status', text: `${role}: ${String(ev.finding?.text || '').slice(0, 140)}` };
|
|
@@ -40,6 +44,8 @@ export function teamLanes(prev, ev) {
|
|
|
40
44
|
case 'task.delta': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], text: ev.text }; break;
|
|
41
45
|
case 'task.finding': lanes.findings += 1; if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], findings: (lanes.tasks[ev.taskId].findings || 0) + 1 }; break;
|
|
42
46
|
case 'task.model': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], model: ev.model }; break;
|
|
47
|
+
case 'task.routed': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], engine: ev.engine || null }; break;
|
|
48
|
+
case 'task.scm': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], scm: { branch: ev.branch, commits: ev.commits || 0, head: ev.headAfter || ev.head } }; break;
|
|
43
49
|
case 'task.handoff': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], model: ev.to, handoffs: (lanes.tasks[ev.taskId].handoffs || 0) + 1 }; break;
|
|
44
50
|
case 'task.step': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], steps: (lanes.tasks[ev.taskId].steps || 0) + (ev.steps || []).length }; break;
|
|
45
51
|
case 'task.tool': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], tools: (lanes.tasks[ev.taskId].tools || 0) + 1, lastTool: ev.text ? `${ev.name} ${ev.text}` : ev.name }; break;
|
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
|
+
}
|