@chatpanel/events 0.89.4 → 0.90.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
@@ -213,6 +213,7 @@ export { SCM_KINDS, validateConnection, normalizeConnection, parseRemote, connec
213
213
  export { messagesFor, mergeTranscript, clipTranscript, clipMessage, newSteps, continuationNote, createControl, STEP_MAX_CHARS, TASK_TRANSCRIPT_MAX_CHARS } from './team-task.js';
214
214
  export { runTeam, resumeTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
215
215
  export { teamToolProvider, teamToolSpec, teamToolTimeoutMs, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
216
+ export { workLogFor, workLogText, workLogEvidence, describeCall, WORKLOG_KINDS } from './team-worklog.js';
216
217
  export { teamLine, teamLanes } from './team-trail.js';
217
218
  export { mcpDispatchProvider, MCP_TOOL_NAME } from './mcp-dispatch.js';
218
219
  export { createManifest, ManifestError, SOURCES } from './manifest.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.89.4",
3
+ "version": "0.90.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",
@@ -107,6 +107,7 @@
107
107
  "./team-run.js": "./team-run.js",
108
108
  "./team-tool.js": "./team-tool.js",
109
109
  "./team-trail.js": "./team-trail.js",
110
+ "./team-worklog.js": "./team-worklog.js",
110
111
  "./team.js": "./team.js",
111
112
  "./text-search.js": "./text-search.js",
112
113
  "./theme.js": "./theme.js",
@@ -238,6 +239,7 @@
238
239
  "team-run.js",
239
240
  "team-tool.js",
240
241
  "team-trail.js",
242
+ "team-worklog.js",
241
243
  "team.js",
242
244
  "text-search.js",
243
245
  "theme.js",
package/team-run.js CHANGED
@@ -23,7 +23,7 @@ import { fixedPlan, plannerPrompt, parsePlan, waves } from './team-plan.js';
23
23
  import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER } from './team-board.js';
24
24
  import { boardToolProvider, createAnswerBox, withBoardTool, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
25
25
  import { createRunCache, withRunCache } from './team-cache.js';
26
- import { messagesFor, mergeTranscript, clipTranscript, clipMessage as clipTranscriptOne, newSteps, continuationNote } from './team-task.js';
26
+ import { messagesFor, mergeTranscript, clipTranscript, clipMessage as clipTranscriptOne, newSteps, continuationNote, isThought } from './team-task.js';
27
27
  import { converge } from './promotion.js';
28
28
 
29
29
  export const RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'waiting', 'completed', 'partial', 'over-budget', 'stopped', 'failed']);
@@ -239,9 +239,13 @@ export async function runTeam({
239
239
  if (req.type !== 'handoff' || req.taskId !== task.id) return false;
240
240
  handoffTo = req; taskAc.abort(); return true;
241
241
  }) || null;
242
+ // Every recorded step says when and under which attempt — the board's work log and the
243
+ // scorecard read the task by these, so a step reported after the fact is stamped now.
244
+ let attemptNo = 0;
245
+ const stamp = (m) => clipTranscriptOne({ ...m, at: Number.isFinite(m?.at) ? m.at : now(), attempt: Number.isFinite(m?.attempt) ? m.attempt : attemptNo });
242
246
  const recordSteps = (before, after) => {
243
247
  const added = newSteps(before, after);
244
- if (added.length) say('task.step', { taskId: task.id, role: role.id, steps: added.map((m) => clipTranscriptOne(m)) });
248
+ if (added.length) say('task.step', { taskId: task.id, role: role.id, steps: added.map(stamp) });
245
249
  };
246
250
  try {
247
251
  if (role.mode === 'recipe') {
@@ -299,13 +303,16 @@ export async function runTeam({
299
303
  routed = routeOf(m, role, { attempt, exclude, handoff: handoffNow });
300
304
  say('task.routed', { taskId: task.id, role: role.id, attempt, ...routed });
301
305
  attempts.push({ model: m.model, engine: routed.engine, at: now(), continued: !!note });
306
+ attemptNo = attempts.length;
302
307
  const sent = messagesFor({ transcript }, { prompt, note });
303
308
  // The record grows AS THE ATTEMPT GOES: a host that reports each wire message the
304
309
  // moment it exists (a tool call, its result) puts it on the record then, so a
305
310
  // process that dies mid-attempt leaves the work so far behind it, not nothing.
306
311
  let live = sent;
307
- if (sent.length > transcript.length) recordSteps(transcript, sent);
308
- const onStep = (message) => { if (message && message.role) { live = [...live, message]; say('task.step', { taskId: task.id, role: role.id, steps: [clipTranscriptOne(message)] }); } };
312
+ // (thoughts are on the record but not on the wire, so compare against the wire's view)
313
+ const wireBefore = transcript.filter((m) => !isThought(m));
314
+ if (sent.length > wireBefore.length) recordSteps(wireBefore, sent);
315
+ const onStep = (message) => { if (message && message.role) { live = [...live, message]; say('task.step', { taskId: task.id, role: role.id, steps: [stamp(message)] }); } };
309
316
  const res = await callModel({
310
317
  runId: id, taskId: task.id, role: role.id, model: m.model, mode: m.mode || role.mode,
311
318
  system: role.prompt, prompt, messages: sent, tools, signal: taskAc.signal,
package/team-task.js CHANGED
@@ -26,9 +26,20 @@ export function clipMessage(m) {
26
26
  if (m.tool_calls) out.tool_calls = m.tool_calls.map((c) => ({ id: c.id, type: c.type || 'function', function: { name: c.function?.name, arguments: clipStr(String(c.function?.arguments ?? ''), STEP_MAX_CHARS) } }));
27
27
  if (m.tool_call_id) out.tool_call_id = m.tool_call_id;
28
28
  if (m.name) out.name = m.name;
29
+ // The member's reasoning, when the model streams it: on the record for the board and the
30
+ // scorecard, never sent back on the wire (messagesFor drops it).
31
+ if (typeof m.thought === 'string') out.thought = clipStr(m.thought, STEP_MAX_CHARS);
32
+ // When the step happened and under which attempt — the work log orders by these.
33
+ if (Number.isFinite(m.at)) out.at = m.at;
34
+ if (Number.isFinite(m.attempt)) out.attempt = m.attempt;
29
35
  return out;
30
36
  }
31
37
 
38
+ /** A step that is the member thinking aloud — no content, no call — recorded, not replayed. */
39
+ export function isThought(m) {
40
+ return !!m && m.role === 'assistant' && typeof m.thought === 'string' && m.content == null && !(Array.isArray(m.tool_calls) && m.tool_calls.length);
41
+ }
42
+
32
43
  /** The record's copy of a transcript: clipped per message and bounded as a whole (oldest tool traffic goes first). */
33
44
  export function clipTranscript(messages) {
34
45
  const list = (Array.isArray(messages) ? messages : []).filter((m) => m && m.role && m.role !== 'system').map(clipMessage);
@@ -67,7 +78,7 @@ export function continuationNote({ kind = 'handoff', from = '', to = '', reason
67
78
  * it first) so a transcript never carries a role prompt that a later role might not share.
68
79
  */
69
80
  export function messagesFor(task, { prompt, note = null } = {}) {
70
- const transcript = Array.isArray(task?.transcript) ? task.transcript : [];
81
+ const transcript = (Array.isArray(task?.transcript) ? task.transcript : []).filter((m) => !isThought(m));
71
82
  if (!transcript.length) return [{ role: 'user', content: String(prompt || '') }];
72
83
  const last = transcript[transcript.length - 1];
73
84
  // A transcript that ends in an unanswered tool call cannot be continued as-is: close it.
@@ -0,0 +1,146 @@
1
+ // The work log — a task's thread as the record of the work, not just its posts.
2
+ //
3
+ // A task is a conversation (team-task.js): every step is on the record as it happens — the
4
+ // prompt, the member's text, its reasoning when the model streams it, each tool call and
5
+ // what came back — beside the attempts (which model, when, how it ended), the hand-offs, the
6
+ // posts in its thread, and how the task ended. The board drew the posts alone, and a task
7
+ // that failed before its first post looked like nothing had happened. This folds all of it
8
+ // into ONE ordered timeline, the same in both clients, so what a member did, tried, was told
9
+ // and produced is read in one place — by a person, by the next wave, and by whoever rates
10
+ // the member (evidence first: calls made, findings kept, models burned, budget spent).
11
+ //
12
+ // Pure: a run record (team-record.js) in, entries out. No rendering here.
13
+
14
+ import { isThought } from './team-task.js';
15
+
16
+ export const WORKLOG_KINDS = Object.freeze(['attempt', 'prompt', 'note', 'thought', 'text', 'call', 'result', 'handoff', 'post', 'end']);
17
+
18
+ const num = (v, d = 0) => (Number.isFinite(v) ? v : d);
19
+ const str = (v) => (v == null ? '' : typeof v === 'string' ? v : JSON.stringify(v));
20
+
21
+ function parseArgs(raw) {
22
+ if (raw && typeof raw === 'object') return raw;
23
+ try { return JSON.parse(String(raw || '{}')); } catch { return { raw: String(raw || '') }; }
24
+ }
25
+
26
+ /** One line for a tool call: `find web_search "…"` — the dispatcher's action stands in front. */
27
+ export function describeCall(call) {
28
+ const name = call?.function?.name || call?.name || 'tool';
29
+ const args = parseArgs(call?.function?.arguments ?? call?.arguments);
30
+ const action = args?.action ? ` ${args.action}` : '';
31
+ const inner = args?.args && typeof args.args === 'object' ? args.args : args;
32
+ const first = inner && typeof inner === 'object' ? Object.entries(inner).find(([k, v]) => k !== 'action' && k !== 'args' && (typeof v === 'string' || typeof v === 'number')) : null;
33
+ return `${name}${action}${first ? ` ${first[0]}=${JSON.stringify(String(first[1]).slice(0, 120))}` : ''}`;
34
+ }
35
+
36
+ /**
37
+ * The timeline of one task: `[{ kind, at, by, attempt, ... }]`, oldest first. Every entry has
38
+ * an `at`; a step recorded without one (an older build) inherits its attempt's, so the order
39
+ * still holds. `by` is the role for what the member did, `runner` for the runner's lines, and
40
+ * whoever posted for a post.
41
+ */
42
+ export function workLogFor(run, taskId) {
43
+ const task = (run?.tasks || []).find((t) => t.id === taskId);
44
+ if (!task) return [];
45
+ const by = task.role || task.id;
46
+ const out = [];
47
+ const attempts = Array.isArray(task.attempts) ? task.attempts : [];
48
+ const baseAt = num(task.startedAt, num(attempts[0]?.at, num(run?.startedAt, 0)));
49
+ attempts.forEach((a, i) => out.push({ kind: 'attempt', at: num(a.at, baseAt + i), by: 'runner', attempt: i + 1, model: a.model || '', engine: a.engine || null, continued: !!a.continued, status: a.status || null, error: a.error || null }));
50
+
51
+ // Steps: stamped ones sort by their time; unstamped ones follow their attempt in order.
52
+ const steps = Array.isArray(task.transcript) ? task.transcript : [];
53
+ const calls = new Map();
54
+ let lastAt = baseAt;
55
+ let attemptOf = 1;
56
+ steps.forEach((m, i) => {
57
+ if (!m || !m.role) return;
58
+ if (Number.isFinite(m.attempt)) attemptOf = m.attempt;
59
+ else if (attempts.length) attemptOf = Math.max(attemptOf, 1 + attempts.findLastIndex((x) => num(x.at, 0) <= lastAt + 1));
60
+ // Unstamped: after everything before it, and after its attempt began.
61
+ const at = Number.isFinite(m.at) ? m.at : Math.max(lastAt, num(attempts[attemptOf - 1]?.at, 0)) + 1;
62
+ lastAt = Math.max(lastAt, at);
63
+ const attempt = attemptOf;
64
+ if (m.role === 'user') {
65
+ out.push({ kind: i === 0 ? 'prompt' : 'note', at, by: 'runner', attempt, text: str(m.content) });
66
+ } else if (m.role === 'assistant') {
67
+ if (isThought(m)) { out.push({ kind: 'thought', at, by, attempt, text: m.thought }); return; }
68
+ if (typeof m.content === 'string' && m.content.trim()) out.push({ kind: 'text', at, by, attempt, text: m.content });
69
+ for (const c of Array.isArray(m.tool_calls) ? m.tool_calls : []) {
70
+ const id = c.id || `c${i}`;
71
+ calls.set(id, c);
72
+ out.push({ kind: 'call', at, by, attempt, callId: id, name: c.function?.name || 'tool', args: parseArgs(c.function?.arguments), text: describeCall(c) });
73
+ }
74
+ } else if (m.role === 'tool') {
75
+ const c = m.tool_call_id ? calls.get(m.tool_call_id) : null;
76
+ out.push({ kind: 'result', at, by: 'tool', attempt, callId: m.tool_call_id || null, name: c?.function?.name || m.name || 'tool', text: str(m.content), error: /^error[:\s]/i.test(str(m.content)) });
77
+ }
78
+ });
79
+
80
+ for (const h of Array.isArray(task.handoffs) ? task.handoffs : []) out.push({ kind: 'handoff', at: num(h.at, lastAt), by: h.by || 'person', from: h.from || '', to: h.to || '', text: h.reason || '' });
81
+
82
+ // The thread's posts — a member's findings, a question, an answer, a decision, the runner's notes.
83
+ const thread = (run?.threads?.threads || []).find((t) => t.taskId === taskId && t.kind === 'task');
84
+ if (thread) for (const p of (run.threads.posts || []).filter((x) => x.threadId === thread.id)) out.push({ kind: 'post', at: num(p.at, lastAt), by: p.by || '', post: p, text: p.text || '' });
85
+
86
+ // What the member is saying right now — the attempt's text before it is a step.
87
+ const lastText = [...out].reverse().find((e) => e.kind === 'text');
88
+ if (task.status === 'running' && task.text && task.text !== lastText?.text) out.push({ kind: 'text', at: num(run?.lastEventAt, lastAt + 1), by, attempt: attemptOf, text: task.text, live: true });
89
+
90
+ if (task.endedAt || ['ok', 'failed', 'over-budget', 'stopped', 'waiting'].includes(task.status)) {
91
+ // Findings: the task's count, or the finding posts in its thread — a member's board posts
92
+ // count as its answer (team-run.js), and a record folded from an older run has only those.
93
+ const findings = Math.max(num(task.findings, 0), out.filter((e) => e.kind === 'post' && e.post?.kind === 'finding').length);
94
+ const tools = Math.max(num(task.tools, 0), out.filter((e) => e.kind === 'call').length);
95
+ out.push({ kind: 'end', at: num(task.endedAt, num(run?.lastEventAt, lastAt + 2)), by: 'runner', status: task.status, error: task.error || null, ms: num(task.ms, 0), findings, tools, text: endText({ ...task, findings, tools }, attempts) });
96
+ }
97
+ return out.sort((a, b) => a.at - b.at || WORKLOG_KINDS.indexOf(a.kind) - WORKLOG_KINDS.indexOf(b.kind));
98
+ }
99
+
100
+ function endText(task, attempts) {
101
+ const tried = attempts.length > 1 ? ` after ${attempts.length} models (${attempts.map((a) => a.model).join(' → ')})` : '';
102
+ const s = task.status;
103
+ if (s === 'ok') return `done${tried} · ${task.findings || 0} finding${task.findings === 1 ? '' : 's'} · ${task.tools || 0} tool call${task.tools === 1 ? '' : 's'}`;
104
+ if (s === 'waiting') return 'waiting on a person';
105
+ if (s === 'over-budget') return `stopped at the budget${tried}`;
106
+ if (s === 'stopped') return `stopped${tried}`;
107
+ return `failed${tried}${task.error ? `: ${task.error}` : ''}`;
108
+ }
109
+
110
+ /** The log as text — for a brief, a rating, a test: one line per entry, results shortened. */
111
+ export function workLogText(entries, { resultChars = 200 } = {}) {
112
+ return (entries || []).map((e) => {
113
+ switch (e.kind) {
114
+ case 'attempt': return `▸ attempt ${e.attempt}: ${e.model}${e.continued ? ' (continues)' : ''}`;
115
+ case 'prompt': return `▸ task: ${e.text}`;
116
+ case 'note': return `▸ runner → ${e.text}`;
117
+ case 'thought': return `${e.by} (thinking): ${e.text}`;
118
+ case 'text': return `${e.by}: ${e.text}`;
119
+ case 'call': return `${e.by} → ${e.text}`;
120
+ case 'result': return ` ← ${e.name}: ${e.text.length > resultChars ? `${e.text.slice(0, resultChars)}…` : e.text}`;
121
+ case 'handoff': return `▸ handed from ${e.from} to ${e.to} by ${e.by}${e.text ? ` — ${e.text}` : ''}`;
122
+ case 'post': return `${e.by} posted ${e.post?.kind || 'note'}: ${e.text}`;
123
+ case 'end': return `▸ ${e.text}`;
124
+ default: return '';
125
+ }
126
+ }).filter(Boolean).join('\n');
127
+ }
128
+
129
+ /**
130
+ * What the log says about the work, as numbers — the evidence a rating starts from, before
131
+ * any opinion (a judge's, a peer's, a person's) is added beside it.
132
+ */
133
+ export function workLogEvidence(entries) {
134
+ const ev = { calls: 0, results: 0, resultErrors: 0, thoughts: 0, texts: 0, attempts: 0, handoffs: 0, posts: 0, findings: 0, decided: { approved: 0, rejected: 0 }, status: null, ms: 0 };
135
+ for (const e of entries || []) {
136
+ if (e.kind === 'call') ev.calls++;
137
+ else if (e.kind === 'result') { ev.results++; if (e.error) ev.resultErrors++; }
138
+ else if (e.kind === 'thought') ev.thoughts++;
139
+ else if (e.kind === 'text') ev.texts++;
140
+ else if (e.kind === 'attempt') ev.attempts++;
141
+ else if (e.kind === 'handoff') ev.handoffs++;
142
+ else if (e.kind === 'post') { ev.posts++; if (e.post?.kind === 'finding') ev.findings++; if (e.post?.status === 'approved') ev.decided.approved++; if (e.post?.status === 'rejected') ev.decided.rejected++; }
143
+ else if (e.kind === 'end') { ev.status = e.status; ev.ms = e.ms; ev.findings = Math.max(ev.findings, e.findings || 0); }
144
+ }
145
+ return ev;
146
+ }