@chatpanel/events 0.81.2 → 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.2",
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.
@@ -278,13 +280,32 @@ export async function runTeam({
278
280
  const m = modelFor(judge);
279
281
  if (m?.model && budget.canAfford({ tokens: 0 })) {
280
282
  const prompt = [
281
- `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.`,
282
284
  `Request: ${String(request || '').trim()}`,
283
285
  boardText(board),
284
286
  ].join('\n\n');
285
- const res = await callModel({ runId: id, taskId: 'merge', role: judge.id, model: m.model, mode: 'model', system: judge.prompt, prompt, tools: undefined, signal });
286
- if (res?.usage) budget.charge(res.usage);
287
- 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);
288
309
  } else {
289
310
  proposal = mergeCheap(t, board.all(), tasksOut);
290
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: {