@chatpanel/events 0.2.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/LICENSE +168 -0
- package/README.md +183 -0
- package/adapters.js +83 -0
- package/capability.js +121 -0
- package/citations.js +79 -0
- package/event.js +170 -0
- package/harness.js +101 -0
- package/index.js +44 -0
- package/invariants.js +174 -0
- package/kernel.js +255 -0
- package/loop.js +132 -0
- package/manifest.js +107 -0
- package/mcp-errors.js +87 -0
- package/meeting-analyzers.js +83 -0
- package/order.js +78 -0
- package/package.json +85 -0
- package/ref.js +52 -0
- package/registry.js +240 -0
- package/route-graph.js +115 -0
- package/router.js +831 -0
- package/rules.js +142 -0
- package/search-engines.js +81 -0
- package/sources-retrieval.js +189 -0
- package/sources.js +256 -0
- package/store.js +171 -0
- package/tool-groups.js +81 -0
- package/tool-need.js +96 -0
- package/trajectory.js +509 -0
- package/upcast.js +37 -0
package/trajectory.js
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// One turn, as an ordered, inspectable sequence.
|
|
2
|
+
//
|
|
3
|
+
// THIS LIVES IN THE SHARED PACKAGE ON PURPOSE. A trajectory is not a browser-extension
|
|
4
|
+
// feature: a desktop app, a mobile app and the gateway all need to answer "what happened in
|
|
5
|
+
// this turn, in what order, and where did the time go", and three implementations of that
|
|
6
|
+
// would drift into three different answers to the same question. What is client-specific is
|
|
7
|
+
// only the rendering — the model is shared.
|
|
8
|
+
//
|
|
9
|
+
// TWO REFERENCES, TWO LESSONS.
|
|
10
|
+
//
|
|
11
|
+
// From DevTools: the WATERFALL. A request list that only shows totals cannot answer "why
|
|
12
|
+
// was this slow"; laying the phases out proportionally answers it at a glance, before any
|
|
13
|
+
// clicking. Our phases are setup (assembling tools, connecting to MCP servers), wait (to
|
|
14
|
+
// first token), and work (tool calls and writing). A turn that spent 45s connecting and
|
|
15
|
+
// 2s thinking looks nothing like one that spent 2s connecting and 45s writing, and the
|
|
16
|
+
// old single duration made them identical.
|
|
17
|
+
//
|
|
18
|
+
// From the DeepSeek harness: the ENTRY LIST plus a DETAIL PANE. Every step is one row —
|
|
19
|
+
// system, user, context, tool call, result, answer — and selecting a row shows the whole
|
|
20
|
+
// of it. Rows stay short so the shape of the turn is legible; the detail pane is where
|
|
21
|
+
// length is allowed.
|
|
22
|
+
//
|
|
23
|
+
// What neither does, and we must: content is not here. Each entry carries a `ref`, and the
|
|
24
|
+
// caller resolves it from the blob store when the user actually looks. That keeps a
|
|
25
|
+
// trajectory cheap to build for sixty runs and honest about deleted content — a ref whose
|
|
26
|
+
// blob is gone resolves to "no longer stored" rather than silently showing nothing.
|
|
27
|
+
|
|
28
|
+
import { linearize } from './order.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The name a human should see for a call.
|
|
32
|
+
*
|
|
33
|
+
* A dispatcher registers ONE tool and carries the real action in its arguments, so the raw
|
|
34
|
+
* capability name is `page` for every page call. Showing that turns forty distinct actions
|
|
35
|
+
* into forty identical rows — the same blindness that once stopped the loop guard exempting
|
|
36
|
+
* screenshots. Lives here, not in a renderer, because every client will need it.
|
|
37
|
+
*/
|
|
38
|
+
export function displayName(call) {
|
|
39
|
+
const action = call?.args?.action;
|
|
40
|
+
if (typeof action !== 'string' || !action) return call?.name || 'tool';
|
|
41
|
+
return action === 'describe' && call.args.tool
|
|
42
|
+
? `${call.name}.describe(${call.args.tool})`
|
|
43
|
+
: `${call.name}.${action}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Entry kinds, in the order they conventionally appear. Used for grouping and colour. */
|
|
47
|
+
export const ENTRY_KINDS = Object.freeze(['system', 'user', 'context', 'route', 'tool', 'result', 'reasoning', 'assistant']);
|
|
48
|
+
|
|
49
|
+
const short = (s, n = 120) => {
|
|
50
|
+
const t = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
51
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the ordered entries for one turn.
|
|
56
|
+
*
|
|
57
|
+
* `events` are that turn's events; ordering comes from `linearize`, never from wall time,
|
|
58
|
+
* so a trajectory reads identically on every host and after every export.
|
|
59
|
+
*/
|
|
60
|
+
export function buildTrajectory(events) {
|
|
61
|
+
const ordered = linearize(events || []);
|
|
62
|
+
const entries = [];
|
|
63
|
+
const calls = new Map();
|
|
64
|
+
let startedAt = null;
|
|
65
|
+
|
|
66
|
+
for (const e of ordered) {
|
|
67
|
+
const p = e.payload || {};
|
|
68
|
+
switch (e.type) {
|
|
69
|
+
case 'turn.started':
|
|
70
|
+
startedAt = e.at;
|
|
71
|
+
break;
|
|
72
|
+
|
|
73
|
+
case 'context.assembled':
|
|
74
|
+
entries.push({
|
|
75
|
+
kind: 'context', at: e.at, title: 'Context assembled',
|
|
76
|
+
detail: `${(p.tools || []).length} tool${(p.tools || []).length === 1 ? '' : 's'} · ${p.used || 0} tokens`,
|
|
77
|
+
data: { tools: p.tools || [], tokens: p.used || 0, redaction: !!p.redaction, surface: p.surface },
|
|
78
|
+
});
|
|
79
|
+
break;
|
|
80
|
+
|
|
81
|
+
// The prompt blob holds system + messages + the toolset; split it into readable rows
|
|
82
|
+
// at render time, since only the blob knows what was actually in it.
|
|
83
|
+
// RETRIEVED MATERIAL IS INPUT, and it belongs beside the question rather than buried
|
|
84
|
+
// in a tool result. 'a tool ran' and 'a tool returned five notes' are different facts,
|
|
85
|
+
// and only the second explains the answer.
|
|
86
|
+
case 'context.retrieved':
|
|
87
|
+
entries.push({
|
|
88
|
+
kind: 'context', at: e.at,
|
|
89
|
+
title: p.count ? `Retrieved ${p.count} source${p.count === 1 ? '' : 's'}` : 'Retrieved material',
|
|
90
|
+
detail: [p.tool, p.chars ? `${p.chars} chars` : ''].filter(Boolean).join(' · '),
|
|
91
|
+
data: { tool: p.tool, count: p.count, sources: p.sources || [] },
|
|
92
|
+
});
|
|
93
|
+
break;
|
|
94
|
+
|
|
95
|
+
case 'assistant.prompted':
|
|
96
|
+
entries.push({ kind: 'system', at: e.at, title: 'Prompt', detail: `${p.chars || 0} chars`, ref: p.ref, expandsToMessages: true });
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
// WHICH MODEL, AND WHY. Routing decisions were being recorded and then not shown,
|
|
100
|
+
// which is the least useful place for them: the log knew a turn changed model three
|
|
101
|
+
// times and the view that exists to explain a turn did not mention it.
|
|
102
|
+
case 'policy.changed': {
|
|
103
|
+
if (!String(p.dial || '').startsWith('route.')) break;
|
|
104
|
+
const applied = p.dial === 'route.applied';
|
|
105
|
+
entries.push({
|
|
106
|
+
kind: 'route', at: e.at,
|
|
107
|
+
title: applied ? `Routed to ${p.to}` : `Would route to ${p.to}`,
|
|
108
|
+
detail: (p.reasons || [])[0] || '',
|
|
109
|
+
data: {
|
|
110
|
+
from: p.from, to: p.to, applied,
|
|
111
|
+
agrees: p.agrees, strategy: p.strategy,
|
|
112
|
+
reasons: p.reasons, eligible: p.eligible, rejected: p.rejected,
|
|
113
|
+
// Every candidate and the projected failover chain, carried through so a viewer
|
|
114
|
+
// can DRAW the decision. Passed along rather than rebuilt here: this file turns
|
|
115
|
+
// events into entries, it does not re-derive what the router already decided.
|
|
116
|
+
graph: p.graph || null,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case 'automation.fired': {
|
|
123
|
+
if (p.ruleId !== 'router:failover') {
|
|
124
|
+
entries.push({ kind: 'route', at: e.at, title: `Rule fired: ${p.ruleId}`, detail: `class ${p.classUsed}`, data: p });
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
// A failover is the most consequential thing that can happen mid-turn, and the
|
|
128
|
+
// reason is what makes it readable rather than alarming.
|
|
129
|
+
entries.push({
|
|
130
|
+
kind: 'route', at: e.at,
|
|
131
|
+
title: `Failed over to ${p.to}`,
|
|
132
|
+
detail: `${p.from} declined (${p.reason})`,
|
|
133
|
+
data: p,
|
|
134
|
+
});
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'capability.invoked': {
|
|
139
|
+
const entry = {
|
|
140
|
+
kind: 'tool', at: e.at, title: displayName({ name: p.capability, args: p.args }),
|
|
141
|
+
detail: short(JSON.stringify(p.args || {}), 90),
|
|
142
|
+
key: p.idempotencyKey || e.id, ok: null, ms: null,
|
|
143
|
+
// The facts a reader actually asks for about a call, in one place: when it
|
|
144
|
+
// started in absolute time, what it was given, who asked for it, and whether it
|
|
145
|
+
// could be replayed. Split across two events, they are a join the reader should
|
|
146
|
+
// not have to do.
|
|
147
|
+
data: {
|
|
148
|
+
capability: p.capability,
|
|
149
|
+
args: p.args || {},
|
|
150
|
+
actor: p.actor,
|
|
151
|
+
scope: p.scope,
|
|
152
|
+
effects: p.effects,
|
|
153
|
+
started: new Date(e.at).toISOString(),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
calls.set(entry.key, entry);
|
|
157
|
+
entries.push(entry);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case 'capability.resulted': {
|
|
162
|
+
const call = calls.get(p.idempotencyKey);
|
|
163
|
+
if (call) { call.ok = p.ok; call.ms = p.cost?.ms ?? null; }
|
|
164
|
+
entries.push({
|
|
165
|
+
kind: 'result', at: e.at, title: `${p.capability} → ${p.ok ? 'ok' : 'failed'}`,
|
|
166
|
+
detail: short(p.summary, 120), ok: !!p.ok, ms: p.cost?.ms ?? null,
|
|
167
|
+
data: {
|
|
168
|
+
status: p.ok ? 'completed' : 'failed',
|
|
169
|
+
durationMs: p.cost?.ms ?? null,
|
|
170
|
+
classUsed: p.classUsed,
|
|
171
|
+
finished: new Date(e.at).toISOString(),
|
|
172
|
+
result: p.summary,
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case 'assistant.reasoning':
|
|
179
|
+
entries.push({ kind: 'reasoning', at: e.at, title: 'Reasoning', detail: `${p.chars || 0} chars`, ref: p.ref });
|
|
180
|
+
break;
|
|
181
|
+
|
|
182
|
+
case 'assistant.message':
|
|
183
|
+
entries.push({
|
|
184
|
+
kind: 'assistant', at: e.at, title: 'Answer',
|
|
185
|
+
// What it was BASED ON, on the answer row. The first thing anyone checks when an
|
|
186
|
+
// answer looks wrong is what stood behind it.
|
|
187
|
+
detail: [`${p.chars || 0} chars`, p.citations?.length ? `${p.citations.length} citation${p.citations.length === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '),
|
|
188
|
+
ref: p.ref,
|
|
189
|
+
data: p.citations?.length ? { citations: p.citations } : null,
|
|
190
|
+
});
|
|
191
|
+
break;
|
|
192
|
+
|
|
193
|
+
default:
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const entry of entries) entry.offsetMs = startedAt == null ? null : entry.at - startedAt;
|
|
199
|
+
return entries;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The three phases of a turn, as fractions that sum to 1 — the waterfall.
|
|
204
|
+
*
|
|
205
|
+
* Deliberately three and not more: setup, waiting for the first token, and everything
|
|
206
|
+
* after. Each has a different cause and a different fix, which is the only reason to
|
|
207
|
+
* split a bar at all. `null` when the turn recorded no duration, because a bar drawn from
|
|
208
|
+
* missing numbers is a confident lie.
|
|
209
|
+
*/
|
|
210
|
+
export function phasesOf(run) {
|
|
211
|
+
const total = run?.ms;
|
|
212
|
+
if (!Number.isFinite(total) || total <= 0) return null;
|
|
213
|
+
const setup = Math.max(0, Math.min(run.prepMs || 0, total));
|
|
214
|
+
const wait = Math.max(0, Math.min(run.ttftMs || 0, total - setup));
|
|
215
|
+
const work = Math.max(0, total - setup - wait);
|
|
216
|
+
const pct = (v) => (v / total) * 100;
|
|
217
|
+
return {
|
|
218
|
+
total,
|
|
219
|
+
parts: [
|
|
220
|
+
{ key: 'setup', ms: setup, pct: pct(setup), label: 'Setup — tools and MCP servers' },
|
|
221
|
+
{ key: 'wait', ms: wait, pct: pct(wait), label: 'Waiting for the first word' },
|
|
222
|
+
{ key: 'work', ms: work, pct: pct(work), label: 'Tools and writing' },
|
|
223
|
+
].filter((p) => p.ms > 0),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The three LANES — input, model, tools — as spans across the turn.
|
|
229
|
+
*
|
|
230
|
+
* A single stacked bar says how the time divided; lanes say what was ACTIVE and when, and
|
|
231
|
+
* those are different questions. Two tool calls with model thinking between them is a
|
|
232
|
+
* different shape from one long call, and a stacked bar draws them identically.
|
|
233
|
+
*
|
|
234
|
+
* Spans are positioned as percentages of the turn, so the rendering needs no width.
|
|
235
|
+
*/
|
|
236
|
+
export function lanesOf(entries, run) {
|
|
237
|
+
const total = run?.ms;
|
|
238
|
+
if (!Number.isFinite(total) || total <= 0) return null;
|
|
239
|
+
const pct = (ms) => Math.max(0, Math.min(100, (ms / total) * 100));
|
|
240
|
+
const lanes = { input: [], model: [], tools: [] };
|
|
241
|
+
|
|
242
|
+
for (const e of entries) {
|
|
243
|
+
if (e.offsetMs == null) continue;
|
|
244
|
+
const at = pct(e.offsetMs);
|
|
245
|
+
if (e.kind === 'system' || e.kind === 'user' || e.kind === 'context') {
|
|
246
|
+
lanes.input.push({ left: at, width: Math.max(0.6, pct(200)), label: e.title });
|
|
247
|
+
} else if (e.kind === 'tool') {
|
|
248
|
+
// A call's span is its own duration when known — the result carries it.
|
|
249
|
+
lanes.tools.push({ left: at, width: Math.max(1, pct(e.ms || 400)), label: e.title });
|
|
250
|
+
} else if (e.kind === 'assistant' || e.kind === 'reasoning') {
|
|
251
|
+
lanes.model.push({ left: at, width: Math.max(1, pct(400)), label: e.title });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Waiting for the first word is model time even though nothing was emitted during it —
|
|
255
|
+
// otherwise the lane looks idle for the part of the turn the user most felt.
|
|
256
|
+
if (run.ttftMs > 0) lanes.model.unshift({ left: pct(run.prepMs || 0), width: pct(run.ttftMs), label: 'Waiting for the first word' });
|
|
257
|
+
if (run.prepMs > 0) lanes.input.unshift({ left: 0, width: pct(run.prepMs), label: 'Setup — tools and MCP servers' });
|
|
258
|
+
return lanes;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Derived per-request metrics.
|
|
263
|
+
*
|
|
264
|
+
* A turn is not one model call. In a tool loop the model is asked, answers with a call, is
|
|
265
|
+
* given the result, and is asked again — DSH calls these Request #1, #2, #3, and that
|
|
266
|
+
* numbering is the thing that makes a trajectory readable: "the second request is where it
|
|
267
|
+
* went wrong" is a sentence you can act on, while "the turn went wrong" is not. Our own log
|
|
268
|
+
* already showed this without naming it — 36 turns carried two `context.assembled` events,
|
|
269
|
+
* one per round-trip.
|
|
270
|
+
*
|
|
271
|
+
* Everything here is DERIVED, never stored. Throughput computed at read time cannot
|
|
272
|
+
* disagree with the tokens it came from; a stored copy can, and eventually does.
|
|
273
|
+
*/
|
|
274
|
+
export function requestMetrics(req) {
|
|
275
|
+
const out = Number(req?.tokensOut) || 0;
|
|
276
|
+
const ttft = Number.isFinite(req?.ttftMs) ? req.ttftMs : null;
|
|
277
|
+
const total = Number.isFinite(req?.ms) ? req.ms : null;
|
|
278
|
+
// Generation is the part AFTER the first token: total minus the wait. Dividing tokens by
|
|
279
|
+
// the total instead would blame a slow first token on the model's writing speed.
|
|
280
|
+
const generationMs = total != null && ttft != null ? Math.max(0, total - ttft) : null;
|
|
281
|
+
return {
|
|
282
|
+
tokensIn: Number(req?.tokensIn) || 0,
|
|
283
|
+
tokensOut: out,
|
|
284
|
+
tokensReasoning: Number(req?.tokensReasoning) || 0,
|
|
285
|
+
tokensTotal: (Number(req?.tokensIn) || 0) + out,
|
|
286
|
+
ttftMs: ttft,
|
|
287
|
+
generationMs,
|
|
288
|
+
totalMs: total,
|
|
289
|
+
// Only meaningful with both numbers and real generation time; a throughput computed
|
|
290
|
+
// from a 0ms window is a very large lie.
|
|
291
|
+
throughput: generationMs > 0 && out > 0 ? +(out / (generationMs / 1000)).toFixed(1) : null,
|
|
292
|
+
model: req?.model || null,
|
|
293
|
+
status: req?.status || 'completed',
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Group entries into REQUESTS — one per model round-trip — so the view can show the
|
|
299
|
+
* hierarchy DSH shows: a request, the calls it made, and the result that came back.
|
|
300
|
+
*
|
|
301
|
+
* A new request begins at each prompt (the model being asked again). Tool calls and their
|
|
302
|
+
* results belong to the request that asked for them, which is what makes "hierarchy" a real
|
|
303
|
+
* relationship rather than a label.
|
|
304
|
+
*/
|
|
305
|
+
export function groupRequests(entries) {
|
|
306
|
+
const requests = [];
|
|
307
|
+
let current = null;
|
|
308
|
+
const open = () => {
|
|
309
|
+
current = { index: requests.length + 1, entries: [], calls: [], answer: null, at: null };
|
|
310
|
+
requests.push(current);
|
|
311
|
+
return current;
|
|
312
|
+
};
|
|
313
|
+
for (const e of entries) {
|
|
314
|
+
if (e.kind === 'system' || (!current && e.kind !== 'context')) open();
|
|
315
|
+
if (!current) open();
|
|
316
|
+
if (current.at == null) current.at = e.at;
|
|
317
|
+
current.entries.push(e);
|
|
318
|
+
e.requestIndex = current.index;
|
|
319
|
+
if (e.kind === 'tool') current.calls.push(e);
|
|
320
|
+
if (e.kind === 'assistant') current.answer = e;
|
|
321
|
+
}
|
|
322
|
+
return requests;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Filter entries by a search string, matching title and detail. */
|
|
326
|
+
export function filterEntries(entries, query) {
|
|
327
|
+
const q = String(query || '').trim().toLowerCase();
|
|
328
|
+
if (!q) return entries;
|
|
329
|
+
return entries.filter((e) => `${e.title} ${e.detail || ''}`.toLowerCase().includes(q));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Group runs into THREADS, because a run on its own is not the unit anyone reasons about.
|
|
334
|
+
*
|
|
335
|
+
* The activity log listed 1,205 independent rows. But nobody asks "what did run 847 do" —
|
|
336
|
+
* they ask what happened in a conversation, a meeting, a note. And those are not one run
|
|
337
|
+
* each: a meeting holds its live monitors and its summaries, a note holds every pass over
|
|
338
|
+
* it including a swarm of agents, a chat holds every message. Flattening that loses the only
|
|
339
|
+
* structure the data has.
|
|
340
|
+
*
|
|
341
|
+
* Three levels, matching what the runs already record:
|
|
342
|
+
* thread (surface + sourceId) → turn (one run) → entries (user, context, tools, assistant)
|
|
343
|
+
*
|
|
344
|
+
* A run with no sourceId is its OWN thread rather than being pooled with other orphans:
|
|
345
|
+
* without an id there is no evidence two runs are related, and inventing a shared parent
|
|
346
|
+
* would group unrelated work under one heading — the opposite of the problem being fixed.
|
|
347
|
+
*/
|
|
348
|
+
export function threadsOf(runs = []) {
|
|
349
|
+
const byKey = new Map();
|
|
350
|
+
for (const run of runs) {
|
|
351
|
+
if (!run) continue;
|
|
352
|
+
// `kind` is the fallback that makes this work on runs recorded before surface existed —
|
|
353
|
+
// and on an export of 1,215 turns, 1,203 had no surface while every one had a kind. A
|
|
354
|
+
// grouping that only works on data recorded after the fix groups nothing anyone has.
|
|
355
|
+
const surface = run.surface || run.turn?.surface || run.turn?.kind || run.kind || 'other';
|
|
356
|
+
const sourceId = run.sourceId || run.turn?.sourceId || null;
|
|
357
|
+
// THE PARENT ID IS THE KEY, ON ITS OWN. Keying on surface+id split a conversation from
|
|
358
|
+
// the autocomplete done inside it — same thread, different surface — which is the exact
|
|
359
|
+
// grouping this exists to produce. Ids are generated and unique, so the surface adds no
|
|
360
|
+
// identity, only a way to break one thread into several.
|
|
361
|
+
const key = sourceId ? `src:${sourceId}` : `run:${run.turnId || run.id}`;
|
|
362
|
+
if (!byKey.has(key)) {
|
|
363
|
+
byKey.set(key, { key, surface, sourceId, runs: [], startedAt: Infinity, endedAt: -Infinity, ms: 0, tokensIn: 0, tokensOut: 0, calls: 0, errors: 0 });
|
|
364
|
+
}
|
|
365
|
+
const t = byKey.get(key);
|
|
366
|
+
t.runs.push(run);
|
|
367
|
+
const at = run.at ?? run.turn?.startedAt ?? 0;
|
|
368
|
+
t.startedAt = Math.min(t.startedAt, at);
|
|
369
|
+
t.endedAt = Math.max(t.endedAt, run.turn?.endedAt ?? at);
|
|
370
|
+
t.ms += run.turn?.ms || 0;
|
|
371
|
+
t.tokensIn += run.turn?.tokensIn || 0;
|
|
372
|
+
t.tokensOut += run.turn?.tokensOut || 0;
|
|
373
|
+
t.calls += run.calls?.length || 0;
|
|
374
|
+
if (run.turn?.reason && run.turn.reason !== 'ok') t.errors += 1;
|
|
375
|
+
}
|
|
376
|
+
const threads = [...byKey.values()].map((t) => ({
|
|
377
|
+
...t,
|
|
378
|
+
// A thread is named after the work it exists FOR, not after the background jobs attached
|
|
379
|
+
// to it: a conversation with six autocomplete turns is still a conversation.
|
|
380
|
+
surface: (t.runs.find((r) => !r.background)?.surface) || t.surface,
|
|
381
|
+
startedAt: Number.isFinite(t.startedAt) ? t.startedAt : 0,
|
|
382
|
+
endedAt: Number.isFinite(t.endedAt) ? t.endedAt : 0,
|
|
383
|
+
// Chronological WITHIN the thread — a conversation read bottom-up is not a conversation.
|
|
384
|
+
runs: [...t.runs].sort((a, b) => (a.at ?? 0) - (b.at ?? 0)),
|
|
385
|
+
turns: t.runs.length,
|
|
386
|
+
}));
|
|
387
|
+
// Most recently active first, which is the one question a log answers on open.
|
|
388
|
+
threads.sort((a, b) => b.endedAt - a.endedAt);
|
|
389
|
+
return threads;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** A readable heading for a thread, from whatever it recorded. */
|
|
393
|
+
export function threadTitle(thread, titleFor = () => null) {
|
|
394
|
+
const named = thread.sourceId ? titleFor(thread.surface, thread.sourceId) : null;
|
|
395
|
+
if (named) return named;
|
|
396
|
+
const first = thread.runs?.[0];
|
|
397
|
+
// Falling back to the surface plus a short id beats "Untitled": it still distinguishes two
|
|
398
|
+
// threads from each other, which is the minimum a heading has to do.
|
|
399
|
+
const short = thread.sourceId ? String(thread.sourceId).slice(-6) : String(first?.turnId || '').slice(-6);
|
|
400
|
+
const label = { chat: 'Chat', note: 'Note', meeting: 'Meeting' }[thread.surface] || (thread.surface || 'Run');
|
|
401
|
+
return short ? `${label} · ${short}` : label;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Split a recorded prompt into the rows a reader needs to tell things apart.
|
|
406
|
+
*
|
|
407
|
+
* The prompt was one row called "Prompt". But "why did it answer that" is nearly always a
|
|
408
|
+
* question about WHICH input said something — the person, the page attached to their
|
|
409
|
+
* message, or the instructions we added — and one row cannot answer it. So the same four
|
|
410
|
+
* things that are recorded separately are shown separately:
|
|
411
|
+
*
|
|
412
|
+
* SYSTEM ours, with the tool preamble as its own row: it is the largest single thing we
|
|
413
|
+
* inject and it was hiding inside a total nobody could attribute
|
|
414
|
+
* USER what the person typed, and nothing else
|
|
415
|
+
* CONTEXT what was attached, one row each, named rather than pasted — including whether
|
|
416
|
+
* the model was handed it or had to ask
|
|
417
|
+
* ASSISTANT / TOOL rows come from the events, not from here
|
|
418
|
+
*/
|
|
419
|
+
export function promptEntries(prompt, at = 0) {
|
|
420
|
+
const out = [];
|
|
421
|
+
if (!prompt || typeof prompt !== 'object') return out;
|
|
422
|
+
if (prompt.system) {
|
|
423
|
+
out.push({ kind: 'system', at, title: 'System prompt', detail: `${approxChars(prompt.system)} chars`, text: prompt.system });
|
|
424
|
+
}
|
|
425
|
+
if (prompt.toolSystem) {
|
|
426
|
+
out.push({ kind: 'system', at, title: 'Tool instructions', detail: `${approxChars(prompt.toolSystem)} chars`, text: prompt.toolSystem });
|
|
427
|
+
}
|
|
428
|
+
for (const m of prompt.messages || []) {
|
|
429
|
+
if (!m || !String(m.content || '').trim()) continue;
|
|
430
|
+
out.push({
|
|
431
|
+
kind: m.role === 'assistant' ? 'assistant' : 'user',
|
|
432
|
+
at, title: m.role === 'assistant' ? 'Assistant' : 'User',
|
|
433
|
+
detail: '', text: String(m.content),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
for (const c of prompt.context || []) {
|
|
437
|
+
out.push({
|
|
438
|
+
kind: 'context', at,
|
|
439
|
+
title: c.title || c.kind || 'Attached',
|
|
440
|
+
// "read on demand" vs "included" are two very different turns that otherwise look
|
|
441
|
+
// identical in a log, so the distinction is on the row rather than buried in the data.
|
|
442
|
+
detail: [c.url, c.chars ? `${c.chars} chars` : '', c.deferred ? 'read on demand' : 'included'].filter(Boolean).join(' · '),
|
|
443
|
+
data: c,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const approxChars = (s) => String(s || '').length;
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Events → TURNS, each holding everything that happened inside it.
|
|
453
|
+
*
|
|
454
|
+
* The middle level the log was missing. A run was a row with a duration and a token count;
|
|
455
|
+
* what it actually DID — what the person asked, what context came with it, which tools ran,
|
|
456
|
+
* what came back — was spread across events that only shared an id. So "open the turn" had
|
|
457
|
+
* nothing to open.
|
|
458
|
+
*
|
|
459
|
+
* A turn is the unit with a beginning and an end. Its parent is whatever it was done FOR —
|
|
460
|
+
* a conversation, a note, a meeting — carried as `sourceId`, which is the identity the
|
|
461
|
+
* grouping is built on. `kind` is only a label: every chat shares the kind 'chat' and they
|
|
462
|
+
* are emphatically not one thread.
|
|
463
|
+
*/
|
|
464
|
+
export function turnsOf(events = []) {
|
|
465
|
+
const byTurn = new Map();
|
|
466
|
+
for (const e of events) {
|
|
467
|
+
const id = e?.turnId || e?.payload?.turnId;
|
|
468
|
+
if (!id) continue;
|
|
469
|
+
if (!byTurn.has(id)) byTurn.set(id, []);
|
|
470
|
+
byTurn.get(id).push(e);
|
|
471
|
+
}
|
|
472
|
+
const turns = [];
|
|
473
|
+
for (const [turnId, evs] of byTurn) {
|
|
474
|
+
const start = evs.find((e) => e.type === 'turn.started');
|
|
475
|
+
const end = evs.find((e) => e.type === 'turn.ended');
|
|
476
|
+
const sp = start?.payload || {};
|
|
477
|
+
const ep = end?.payload || {};
|
|
478
|
+
const at = start?.at ?? evs[0]?.at ?? 0;
|
|
479
|
+
turns.push({
|
|
480
|
+
turnId,
|
|
481
|
+
at,
|
|
482
|
+
// The parent. Without it a turn cannot be filed anywhere, which is a real defect and
|
|
483
|
+
// not a cosmetic one — 264 of 1,215 turns in a real export were suggestions done for a
|
|
484
|
+
// conversation and recorded under nothing.
|
|
485
|
+
sourceId: sp.sourceId || null,
|
|
486
|
+
surface: sp.surface || sp.kind || null,
|
|
487
|
+
kind: sp.kind || 'chat',
|
|
488
|
+
agentId: sp.agentId || null,
|
|
489
|
+
background: !!sp.background,
|
|
490
|
+
// Everything that happened inside, already typed: user, context, route, tool, result,
|
|
491
|
+
// reasoning, assistant.
|
|
492
|
+
entries: buildTrajectory(evs),
|
|
493
|
+
turn: {
|
|
494
|
+
startedAt: at, endedAt: end?.at ?? null, ms: ep.ms ?? null, reason: ep.reason || (end ? 'ok' : 'open'),
|
|
495
|
+
kind: sp.kind || 'chat', surface: sp.surface || null, sourceId: sp.sourceId || null,
|
|
496
|
+
model: ep.model || null, provider: ep.provider || null,
|
|
497
|
+
tokensIn: ep.tokensIn ?? null, tokensOut: ep.tokensOut ?? null,
|
|
498
|
+
},
|
|
499
|
+
calls: evs.filter((e) => e.type === 'capability.invoked'),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
turns.sort((a, b) => a.at - b.at);
|
|
503
|
+
return turns;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** The whole shape in one call: threads → turns → entries. */
|
|
507
|
+
export function threadTree(events = []) {
|
|
508
|
+
return threadsOf(turnsOf(events));
|
|
509
|
+
}
|
package/upcast.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Schema evolution — an append-only log outlives every version of the code that wrote
|
|
2
|
+
// it, so readers upcast and writers never mutate a stored event.
|
|
3
|
+
//
|
|
4
|
+
// The machinery exists from v1 with an empty chain, deliberately: the cost of adding it
|
|
5
|
+
// later is rewriting every reader, and the cost of having it now is this file.
|
|
6
|
+
//
|
|
7
|
+
// The log format is a Tesla-rule contract — additive only, guarded by a drift check,
|
|
8
|
+
// exactly like the bridge wire protocol.
|
|
9
|
+
|
|
10
|
+
import { CURRENT_VERSION, EventError, validateEvent } from './event.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* v(n) -> v(n+1) pure functions. Empty at v1.
|
|
14
|
+
* Each MUST be total: it may not throw for any event of its input version.
|
|
15
|
+
*/
|
|
16
|
+
export const UPCASTERS = Object.freeze({
|
|
17
|
+
// 1: (e) => ({ ...e, v: 2, payload: { ...e.payload, newField: defaultValue } }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Carry a stored event forward to CURRENT_VERSION. Pure. */
|
|
21
|
+
export function upcast(stored) {
|
|
22
|
+
if (!stored || typeof stored !== 'object') throw new EventError('SHAPE', 'event must be an object');
|
|
23
|
+
let e = stored;
|
|
24
|
+
let guard = 0;
|
|
25
|
+
while (e.v < CURRENT_VERSION) {
|
|
26
|
+
const step = UPCASTERS[e.v];
|
|
27
|
+
if (!step) throw new EventError('UPCAST', `no upcaster from v${e.v}`, e.v);
|
|
28
|
+
e = step(e);
|
|
29
|
+
if (++guard > 64) throw new EventError('UPCAST', 'upcaster chain did not terminate');
|
|
30
|
+
}
|
|
31
|
+
if (e.v > CURRENT_VERSION) {
|
|
32
|
+
throw new EventError('UPCAST', `event is v${e.v}; this reader only knows v${CURRENT_VERSION}`, e.v);
|
|
33
|
+
}
|
|
34
|
+
return validateEvent(e);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function upcastAll(stored) { return stored.map(upcast); }
|