@chatpanel/events 0.81.1 → 0.82.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/index.js CHANGED
@@ -196,6 +196,7 @@ export { defineTeam, validateTeam, normalizeTeam, normalizeGrants, grantAllows,
196
196
  export { fixedPlan, parsePlan, plannerPrompt, waves, breakCycles, TEAM_PLAN_SCHEMA } from './team-plan.js';
197
197
  export { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, FINDINGS_SCHEMA, FINDING_KINDS, THREAD_KINDS, THREAD_STATUSES, POST_KINDS, POST_STATUSES, ASK_TYPES, emptyBoardState, foldBoard, findingsOf } from './team-board.js';
198
198
  export { boardToolProvider, boardToolSpec, createAnswerBox, withBoardTool, BOARD_TOOL_NAME, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
199
+ export { createRunCache, withRunCache } from './team-cache.js';
199
200
  export { runTeam, resumeTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
200
201
  export { teamToolProvider, teamToolSpec, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
201
202
  export { teamLine, teamLanes } from './team-trail.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.81.1",
3
+ "version": "0.82.0",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -91,6 +91,7 @@
91
91
  "./tags.js": "./tags.js",
92
92
  "./team-board.js": "./team-board.js",
93
93
  "./board-tool.js": "./board-tool.js",
94
+ "./team-cache.js": "./team-cache.js",
94
95
  "./team-plan.js": "./team-plan.js",
95
96
  "./team-run.js": "./team-run.js",
96
97
  "./team-tool.js": "./team-tool.js",
@@ -207,6 +208,7 @@
207
208
  "tags.js",
208
209
  "team-board.js",
209
210
  "board-tool.js",
211
+ "team-cache.js",
210
212
  "team-plan.js",
211
213
  "team-run.js",
212
214
  "team-tool.js",
package/team-cache.js ADDED
@@ -0,0 +1,63 @@
1
+ // One lookup per run — a run-scoped cache over read-only tools.
2
+ //
3
+ // Three members of a travel team searched "Snoqualmie Valley School District calendar" five
4
+ // times between them. Each member's own turn already dedupes its own repeats; the RUN did
5
+ // not, and members in one wave cannot read each other's findings yet. So: a read-only call
6
+ // with the same name and arguments, from any member, runs once per run; the rest get the
7
+ // first answer back, marked as shared. Only tools that declare themselves read-only (MCP
8
+ // annotations, the shared retrieval tools) are cached; a dispatcher's paging (`get_result`)
9
+ // and the board are per-member and never are.
10
+
11
+ const NEVER = new Set(['get_result', 'board', 'team', 'recipe']);
12
+ const READ_ONLY_NAME_RE = /^(find|web_search|history_search|history_get_source|history_list|meeting_live_transcript|read|fetch|get_|list_|search)/i;
13
+
14
+ function stableKey(name, input) {
15
+ const walk = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? Object.keys(v).sort().reduce((o, k) => { o[k] = walk(v[k]); return o; }, {}) : Array.isArray(v) ? v.map(walk) : v);
16
+ try { return `${name} ${JSON.stringify(walk(input ?? {}))}`; } catch { return `${name} ?`; }
17
+ }
18
+
19
+ export function createRunCache() {
20
+ const hits = new Map(); // key -> { result, by, at }
21
+ let shared = 0;
22
+ return {
23
+ get: (k) => hits.get(k) || null,
24
+ set: (k, v) => { hits.set(k, v); },
25
+ delete: (k) => { hits.delete(k); },
26
+ countShared: () => { shared += 1; },
27
+ get size() { return hits.size; },
28
+ get shared() { return shared; },
29
+ };
30
+ }
31
+
32
+ function isReadOnly(toolset, name) {
33
+ if (NEVER.has(name)) return false;
34
+ const spec = (toolset.specs || []).find((s) => s.name === name);
35
+ if (spec?.annotations?.readOnlyHint === true) return true;
36
+ if (spec?.annotations && spec.annotations.readOnlyHint === false) return false;
37
+ const t = toolset.traits instanceof Map ? toolset.traits.get(name) : null;
38
+ if (t && typeof t.readOnly === 'boolean') return t.readOnly;
39
+ return READ_ONLY_NAME_RE.test(name);
40
+ }
41
+
42
+ /** The toolset with its read-only calls served from the run's cache. */
43
+ export function withRunCache(toolset, cache, { role = '' } = {}) {
44
+ if (!toolset || !cache) return toolset;
45
+ return {
46
+ ...toolset,
47
+ execute: async (name, input, ...rest) => {
48
+ if (!isReadOnly(toolset, name)) return toolset.execute(name, input, ...rest);
49
+ const key = stableKey(name, input);
50
+ const hit = cache.get(key);
51
+ if (hit) {
52
+ // Members in one wave ask at the same moment: the second waits on the first's call.
53
+ cache.countShared();
54
+ const result = await hit.promise;
55
+ const note = `[shared: ${hit.by || 'another member'} already ran this in this run]\n`;
56
+ return typeof result === 'string' ? note + result : result;
57
+ }
58
+ const promise = Promise.resolve().then(() => toolset.execute(name, input, ...rest));
59
+ cache.set(key, { promise, by: role, at: Date.now() });
60
+ try { return await promise; } catch (e) { cache.delete(key); throw e; }
61
+ },
62
+ };
63
+ }
package/team-plan.js CHANGED
@@ -52,7 +52,11 @@ export function plannerPrompt(team, request) {
52
52
  /** One task per role, in declaration order, with the roles' own dependencies. */
53
53
  export function fixedPlan(team, request) {
54
54
  const req = String(request || '').trim();
55
- return (team.roles || []).map((r) => ({
55
+ // The judge's work IS the merge: it reads the whole board and writes the answer. Giving it
56
+ // a task as well made a planner plan twice — once blind, in the first wave, and once at
57
+ // the end — and the first, done with no findings to read, was pure duplicate research.
58
+ const judge = team.merge === 'judge' ? team.judge : null;
59
+ return (team.roles || []).filter((r) => r.id !== judge || (team.roles || []).length === 1).map((r) => ({
56
60
  id: `t_${r.id}`,
57
61
  role: r.id,
58
62
  title: r.name || r.id,
package/team-run.js CHANGED
@@ -21,6 +21,7 @@ import { createBudget } from './budget.js';
21
21
  import { fixedPlan, plannerPrompt, parsePlan, waves } from './team-plan.js';
22
22
  import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER } from './team-board.js';
23
23
  import { boardToolProvider, createAnswerBox, withBoardTool, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
24
+ import { createRunCache, withRunCache } from './team-cache.js';
24
25
  import { converge } from './promotion.js';
25
26
 
26
27
  export const RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'waiting', 'completed', 'partial', 'over-budget', 'stopped', 'failed']);
@@ -106,6 +107,7 @@ export async function runTeam({
106
107
  const board = createBoard({ now, state: resume?.board || null, onEvent: (type, ev) => say(type, ev) });
107
108
  // No answer box from the host means nobody can answer: asks are off, a member that asks is
108
109
  // told to proceed on its own assumption at once, and the budget stop is final.
110
+ const runCache = createRunCache(); // one lookup per run, across members
109
111
  const box = answers || createAnswerBox();
110
112
  const askMs = answers ? askTimeoutMs : 0;
111
113
  const startedAt = resume?.startedAt || now();
@@ -144,7 +146,7 @@ export async function runTeam({
144
146
 
145
147
  const finish = (status, extra = {}) => {
146
148
  const usage = budget.snapshot();
147
- const out = { runId: id, team: t.name, status, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.all(), threads: board.state(), usage, startedAt, endedAt: now(), ...extra };
149
+ const out = { runId: id, team: t.name, status, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.all(), threads: board.state(), usage, lookups: { distinct: runCache.size, shared: runCache.shared }, startedAt, endedAt: now(), ...extra };
148
150
  // What a resume needs, on the record: the plan, what finished, the board, the spend.
149
151
  if (status === 'waiting') out.checkpoint = { runId: id, startedAt, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.state(), budget: { cap: budget.cap, spent: usage.spent } };
150
152
  say('run.done', { status, usage, proposal: out.proposal || null, failedTaskIds: tasksOut.filter((x) => x.status === 'failed').map((x) => x.id), waitingTaskIds: tasksOut.filter((x) => x.status === 'waiting').map((x) => x.id), ...(out.checkpoint ? { checkpoint: out.checkpoint } : {}) });
@@ -206,7 +208,7 @@ export async function runTeam({
206
208
  return a;
207
209
  } : null,
208
210
  });
209
- const tools = withBoardTool(await toolsFor(role), boardTool);
211
+ const tools = withBoardTool(withRunCache(await toolsFor(role), runCache, { role: role.id }), boardTool);
210
212
  // A model that is not there — not deployed, no key, gone — is not the task failing:
211
213
  // the next model on the roster is appointed and the task tried again, up to three
212
214
  // models. Anything else (a refusal, a timeout, a bad request) fails the task.
@@ -215,6 +217,8 @@ export async function runTeam({
215
217
  const m = modelFor(role, exclude);
216
218
  if (!m?.model) throw new Error(exclude.size ? `no model left for role "${role.id}" after ${[...exclude].join(', ')}` : `no model for role "${role.id}"`);
217
219
  if (attempt > 1) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude] });
220
+ // Who is doing this task, for a ledger that shows the lanes — said per attempt.
221
+ say('task.model', { taskId: task.id, role: role.id, model: m.model, attempt });
218
222
  const res = await callModel({
219
223
  runId: id, taskId: task.id, role: role.id, model: m.model, mode: m.mode || role.mode,
220
224
  system: role.prompt, prompt, tools, signal: taskAc.signal,
@@ -245,6 +249,8 @@ export async function runTeam({
245
249
  const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
246
250
  tasksOut.push(row);
247
251
  say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length, ...(askedAndWaiting ? { threadId: askedAndWaiting } : {}) });
252
+ // The spend so far, after every task — a ledger reads it live instead of at the end.
253
+ say('run.usage', { usage: budget.snapshot() });
248
254
  if (budget.exhausted()) overBudget = true;
249
255
  });
250
256
  // Over budget with work left: ask the person ONCE for more, on the board, before stopping.
@@ -274,13 +280,32 @@ export async function runTeam({
274
280
  const m = modelFor(judge);
275
281
  if (m?.model && budget.canAfford({ tokens: 0 })) {
276
282
  const prompt = [
277
- `You are the ${judge.name || judge.id} of team "${t.name}". Review the team's findings for the request below and write the final answer accurate, concise, and only what the findings support. Flag anything the members disagreed on.`,
283
+ `You are the ${judge.name || judge.id} of team "${t.name}". The members' work is on the board below (and in the board tool). Write the team's FINAL ANSWER to the request: complete, well organised, only what the findings support, with the refs they came from. Say plainly what was not found or assumed. Flag anything the members disagreed on. Do not research from scratch — verify a figure with a tool only where the board is silent or contradictory.`,
278
284
  `Request: ${String(request || '').trim()}`,
279
285
  boardText(board),
280
286
  ].join('\n\n');
281
- const res = await callModel({ runId: id, taskId: 'merge', role: judge.id, model: m.model, mode: 'model', system: judge.prompt, prompt, tools: undefined, signal });
282
- if (res?.usage) budget.charge(res.usage);
283
- proposal = res?.ok ? { kind: 'answer', text: String(res.text || ''), by: judge.id } : mergeCheap(t, board.all(), tasksOut);
287
+ // The judge works with its own grants (it may verify a figure) and the board and it
288
+ // is a run member like the others: a model that is not there rotates.
289
+ say('task.started', { taskId: 'merge', role: judge.id, title: `merge (${judge.name || judge.id})` });
290
+ const judgeTools = withBoardTool(withRunCache(await toolsFor(judge), runCache, { role: judge.id }), boardToolProvider({ board, role: judge.id, taskId: 'merge', taskIds: null }));
291
+ let res = null;
292
+ const excl = new Set();
293
+ for (let attempt = 1; attempt <= MAX_APPOINTMENTS; attempt++) {
294
+ const mm = attempt === 1 ? m : modelFor(judge, excl);
295
+ if (!mm?.model) break;
296
+ if (attempt > 1) say('task.reappointed', { taskId: 'merge', role: judge.id, model: mm.model, after: [...excl] });
297
+ say('task.model', { taskId: 'merge', role: judge.id, model: mm.model, attempt });
298
+ 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 }) });
299
+ if (res?.usage) budget.charge(res.usage);
300
+ if (res?.ok && String(res.text || '').trim()) break;
301
+ const err = res?.ok ? 'the model returned no answer' : (res?.error || 'the model did not answer');
302
+ if (stopped() || !isModelUnavailable(err)) break;
303
+ excl.add(mm.model);
304
+ }
305
+ const judged = res?.ok && String(res.text || '').trim();
306
+ 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 });
307
+ say('run.usage', { usage: budget.snapshot() });
308
+ proposal = judged ? { kind: 'answer', text: String(res.text || ''), by: judge.id } : mergeCheap(t, board.all(), tasksOut);
284
309
  } else {
285
310
  proposal = mergeCheap(t, board.all(), tasksOut);
286
311
  }
package/team-tool.js CHANGED
@@ -36,7 +36,8 @@ 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>. merge: judge | converge | concat | first. A budget is required.',
39
+ + 'grants: none | data | web | history | mcp | mcp:<server>. merge: judge | converge | concat | first. A budget is required. '
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.',
40
41
  parameters: {
41
42
  type: 'object',
42
43
  properties: {
package/team-trail.js CHANGED
@@ -35,6 +35,9 @@ export function teamLanes(prev, ev) {
35
35
  case 'task.started': lanes.tasks[ev.taskId] = { ...(lanes.tasks[ev.taskId] || { id: ev.taskId, role: ev.role, title: ev.title }), status: 'running' }; break;
36
36
  case 'task.delta': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], text: ev.text }; break;
37
37
  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;
38
+ case 'task.model': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], model: ev.model }; break;
39
+ 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;
40
+ case 'run.usage': lanes.usage = ev.usage; break;
38
41
  case 'task.waiting': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], status: 'waiting', waitingOn: ev.threadId }; lanes.waiting = [...(lanes.waiting || []), ev.threadId]; break;
39
42
  case 'task.done': case 'task.failed': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], status: ev.status || 'ok', ms: ev.ms }; break;
40
43
  case 'board.thread-status': if (ev.status !== 'waiting' && lanes.waiting) lanes.waiting = lanes.waiting.filter((x) => x !== ev.threadId); break;