@chatpanel/events 0.89.3 → 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 +2 -1
- package/package.json +3 -1
- package/team-board.js +3 -1
- package/team-run.js +23 -6
- package/team-task.js +12 -1
- package/team-tool.js +13 -0
- package/team-worklog.js +146 -0
package/index.js
CHANGED
|
@@ -212,7 +212,8 @@ export { DEFAULT_MIN_CALLS, cardOverride, applyCard } from './model-candidates.j
|
|
|
212
212
|
export { SCM_KINDS, validateConnection, normalizeConnection, parseRemote, connectionFor, branchFor, worktreeDirFor, credentialEnv, describeConnection, blankConnection, connectionFromForm } from './scm-connection.js';
|
|
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
|
-
export { teamToolProvider, teamToolSpec, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
|
|
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.
|
|
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-board.js
CHANGED
|
@@ -74,7 +74,9 @@ export function parseFindings(text, { role, taskId } = {}) {
|
|
|
74
74
|
// ── threads, posts, asks ────────────────────────────────────────────────────────────────
|
|
75
75
|
|
|
76
76
|
export const THREAD_KINDS = Object.freeze(['task', 'ask', 'discussion', 'proposal']);
|
|
77
|
-
|
|
77
|
+
// `failed`: the task behind the thread ended without an answer (every model on the roster
|
|
78
|
+
// tried, or a hard error) — not `resolved`, which read as "done" on the board.
|
|
79
|
+
export const THREAD_STATUSES = Object.freeze(['open', 'waiting', 'resolved', 'failed', 'approved', 'rejected']);
|
|
78
80
|
export const POST_KINDS = Object.freeze(['finding', 'note', 'question', 'answer', 'draft', 'decision']);
|
|
79
81
|
export const POST_STATUSES = Object.freeze(['open', 'proposed', 'approved', 'rejected']);
|
|
80
82
|
export const ASK_TYPES = Object.freeze(['info', 'budget', 'permission', 'direction']);
|
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']);
|
|
@@ -58,7 +58,11 @@ export function isModelUnavailable(error) {
|
|
|
58
58
|
// Also: a relayed agent that exited, a provider that closed the stream, and a turn that
|
|
59
59
|
// came back with nothing — the model did not answer, and the next one might. A refusal,
|
|
60
60
|
// a timeout the caller set, a bad request or a budget stop are not this.
|
|
61
|
-
|
|
61
|
+
// A server that is not there: Node says ECONNREFUSED, a browser says only "Failed to
|
|
62
|
+
// fetch" (the extension reports it as "network error") — a local model killed mid-run
|
|
63
|
+
// arrived as the latter and ended the task on its first attempt with Claude Code sitting
|
|
64
|
+
// idle on the roster.
|
|
65
|
+
return /model[_ ]not[_ ]found|not found|not deployed|inaccessible|does not exist|no such model|unknown model|unsupported model|not available|unavailable|no api key|not configured|"status":\s*(404|401|403|500|502|503)\b|\b(404|401|403|502|503)\b|exited \d+|returned no answer|did not answer|closed the connection|couldn't reach|could not reach|ECONNREFUSED|ECONNRESET|ENOTFOUND|EHOSTUNREACH|socket hang up|fetch failed|failed to fetch|load failed|network ?error|overloaded|capacity/i.test(m);
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
export function dryRunTeam(team, request, { appoint = null } = {}) {
|
|
@@ -235,9 +239,13 @@ export async function runTeam({
|
|
|
235
239
|
if (req.type !== 'handoff' || req.taskId !== task.id) return false;
|
|
236
240
|
handoffTo = req; taskAc.abort(); return true;
|
|
237
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 });
|
|
238
246
|
const recordSteps = (before, after) => {
|
|
239
247
|
const added = newSteps(before, after);
|
|
240
|
-
if (added.length) say('task.step', { taskId: task.id, role: role.id, steps: added.map(
|
|
248
|
+
if (added.length) say('task.step', { taskId: task.id, role: role.id, steps: added.map(stamp) });
|
|
241
249
|
};
|
|
242
250
|
try {
|
|
243
251
|
if (role.mode === 'recipe') {
|
|
@@ -295,13 +303,16 @@ export async function runTeam({
|
|
|
295
303
|
routed = routeOf(m, role, { attempt, exclude, handoff: handoffNow });
|
|
296
304
|
say('task.routed', { taskId: task.id, role: role.id, attempt, ...routed });
|
|
297
305
|
attempts.push({ model: m.model, engine: routed.engine, at: now(), continued: !!note });
|
|
306
|
+
attemptNo = attempts.length;
|
|
298
307
|
const sent = messagesFor({ transcript }, { prompt, note });
|
|
299
308
|
// The record grows AS THE ATTEMPT GOES: a host that reports each wire message the
|
|
300
309
|
// moment it exists (a tool call, its result) puts it on the record then, so a
|
|
301
310
|
// process that dies mid-attempt leaves the work so far behind it, not nothing.
|
|
302
311
|
let live = sent;
|
|
303
|
-
|
|
304
|
-
const
|
|
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)] }); } };
|
|
305
316
|
const res = await callModel({
|
|
306
317
|
runId: id, taskId: task.id, role: role.id, model: m.model, mode: m.mode || role.mode,
|
|
307
318
|
system: role.prompt, prompt, messages: sent, tools, signal: taskAc.signal,
|
|
@@ -355,7 +366,13 @@ export async function runTeam({
|
|
|
355
366
|
const findings = status === 'ok' ? parseFindings(text, { role: role.id, taskId: task.id }) : [];
|
|
356
367
|
if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role.id, finding: f }); }
|
|
357
368
|
const thread = board.threadForTask(task.id);
|
|
358
|
-
|
|
369
|
+
// The thread says how the task ended. A failure is posted in it as well — a person reading
|
|
370
|
+
// the board sees "researcher failed: network error" where it happened, and what was tried.
|
|
371
|
+
if (thread && status === 'ok') board.setThreadStatus(thread.id, 'resolved');
|
|
372
|
+
else if (thread && status !== 'waiting') {
|
|
373
|
+
board.post({ threadId: thread.id, by: RUNNER, kind: 'note', text: `${role.id} ${status === 'over-budget' ? 'stopped at the budget' : 'failed'}${error ? `: ${String(error).slice(0, 300)}` : ''}${attempts.length > 1 ? ` (after ${attempts.length} models: ${attempts.map((a) => a.model).join(', ')})` : ''}.` });
|
|
374
|
+
board.setThreadStatus(thread.id, 'failed');
|
|
375
|
+
}
|
|
359
376
|
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 } : {}) };
|
|
360
377
|
tasksOut.push(row);
|
|
361
378
|
say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length, ...(askedAndWaiting ? { threadId: askedAndWaiting } : {}) });
|
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.
|
package/team-tool.js
CHANGED
|
@@ -26,10 +26,23 @@ function catalogue(teams) {
|
|
|
26
26
|
return `Saved teams: ${list.map((t) => `${t.name} (${(t.roles || []).map((r) => r.id).join(', ')})${t.description ? ` — ${t.description}` : ''}`).join('; ')}.`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* How long one call of the tool may take: the longest budget among the teams plus the merge,
|
|
31
|
+
* never under two minutes. A relay between a CLI agent and this tool (the bridge's MCP
|
|
32
|
+
* server) times a call by this; without it a 300 s team hit the relay's 120 s default,
|
|
33
|
+
* Claude Code was told "tool call timed out" and ran the team AGAIN while the first run was
|
|
34
|
+
* still working.
|
|
35
|
+
*/
|
|
36
|
+
export function teamToolTimeoutMs(teams) {
|
|
37
|
+
const longest = Math.max(0, ...(teams || []).map((t) => Number(t?.budget?.ms) || 0));
|
|
38
|
+
return Math.max(120_000, (longest || 10 * 60_000) + 60_000);
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
export function teamToolSpec(teams) {
|
|
30
42
|
return {
|
|
31
43
|
name: TEAM_TOOL_NAME,
|
|
32
44
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
45
|
+
timeoutMs: teamToolTimeoutMs(teams),
|
|
33
46
|
description:
|
|
34
47
|
`Saved agent teams — several roles working a request in parallel, merged into one answer. ${catalogue(teams)} `
|
|
35
48
|
+ 'Actions: {"action":"run","name":"<team>","request":"<what to do>"} runs one (streams; may take a while); '
|
package/team-worklog.js
ADDED
|
@@ -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
|
+
}
|