cut-at-k 0.1.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 +21 -0
- package/README.md +127 -0
- package/package.json +37 -0
- package/results/record.mjs +144 -0
- package/results/run-ag-ui.mjs +84 -0
- package/results/sever-results.json +4482 -0
- package/src/bytes.js +131 -0
- package/src/index.js +3 -0
- package/src/report.js +117 -0
- package/src/sever.js +101 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Redouane (ROSH Company Labs)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# cut-at-k
|
|
2
|
+
|
|
3
|
+
Cut a stream at every point and check what the consumer is left with.
|
|
4
|
+
|
|
5
|
+
A stream that stops early is not a rare case. A connection drops, a proxy times out,
|
|
6
|
+
a producer dies, someone presses stop. So take the same stream, run it through your
|
|
7
|
+
client once whole and once for every prefix of it, and ask two questions of each cut:
|
|
8
|
+
|
|
9
|
+
- **Did the head change?** Truncation may lose the tail. It must never alter what came
|
|
10
|
+
before the cut.
|
|
11
|
+
- **Does the consumer say so?** A run that was cut short must not report what the same
|
|
12
|
+
run reports when it finishes.
|
|
13
|
+
|
|
14
|
+
The second one is where things are usually wrong, and it is quiet when it is wrong,
|
|
15
|
+
which is why a loop is better at finding it than reading the code.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
git clone https://github.com/roshcompanylabs/cut-at-k
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Not on npm yet. Zero dependencies, so a clone is the whole install.
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import { severAtEveryPoint, summarise, format } from './src/index.js';
|
|
25
|
+
|
|
26
|
+
const report = await severAtEveryPoint({
|
|
27
|
+
events, // the whole stream, in order
|
|
28
|
+
label: 'my-stream',
|
|
29
|
+
replay: async (prefix) => { // run this prefix through your client
|
|
30
|
+
const seen = [];
|
|
31
|
+
const settled = await drive(prefix, seen);
|
|
32
|
+
return { delivered: seen, settled, text: render(seen) };
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
console.log(format(summarise([report], {
|
|
37
|
+
lostContent: (cut, whole) => cut.text !== whole.text,
|
|
38
|
+
})));
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Nothing here knows about any protocol. You supply the events and a function that
|
|
42
|
+
replays them; this supplies the loop and the comparison.
|
|
43
|
+
|
|
44
|
+
## It will not call something a defect on your behalf
|
|
45
|
+
|
|
46
|
+
Three things look identical in the raw numbers and only one of them is a finding:
|
|
47
|
+
|
|
48
|
+
- A cut that **lost nothing**. Cutting after the last content event and before some
|
|
49
|
+
trailing bookkeeping removes no information, so of course the consumer reports the
|
|
50
|
+
same thing. Correct behaviour.
|
|
51
|
+
- A stream that is **invalid on purpose**. Cutting it before its deliberate error
|
|
52
|
+
removes the error, so the whole stream delivers *less* than the prefix and every
|
|
53
|
+
comparison against it is noise. Conformance corpora are full of these.
|
|
54
|
+
- A cut that **lost content and was reported as though it had not**. The finding.
|
|
55
|
+
|
|
56
|
+
So `summarise` reports nothing as a defect unless you pass `lostContent`, and it
|
|
57
|
+
reports excluded streams as a count rather than dropping them silently. The first
|
|
58
|
+
version of `summarise` did not make these distinctions and called 154 cuts a
|
|
59
|
+
problem on a corpus where 54 were. A tool that cries wolf is worse than no tool.
|
|
60
|
+
|
|
61
|
+
## Worked example: AG-UI's own conformance corpus
|
|
62
|
+
|
|
63
|
+
[AG-UI](https://github.com/ag-ui-protocol/ag-ui) ships 68 conformance fixtures — real
|
|
64
|
+
event streams, written by the protocol's authors, with the outcome each one requires.
|
|
65
|
+
Two are a single event long and have no cut point, so 66 were replayed: each through a
|
|
66
|
+
real `HttpAgent` over real HTTP/SSE, whole and then cut at every event boundary. Both
|
|
67
|
+
steps are in `results/` and the recording is committed, so the block below is one
|
|
68
|
+
command away:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
node results/run-ag-ui.mjs results/sever-results.json
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
48 streams, 227 cuts, 18 streams excluded as invalid on purpose
|
|
77
|
+
4 cuts threw during replay
|
|
78
|
+
|
|
79
|
+
reported the same as the whole run : 154
|
|
80
|
+
of which something was actually lost : 54
|
|
81
|
+
of those, no terminal event either : 52
|
|
82
|
+
spread across : 34 streams
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A cut here is the specification's own definition of a truncated run: the stream ends
|
|
86
|
+
after k events with no further frames and no terminal event. From
|
|
87
|
+
[`/spec/1.0/basic/transports#truncation`](https://github.com/ag-ui-protocol/ag-ui):
|
|
88
|
+
|
|
89
|
+
> A consumer whose stream ends without a terminal event has a truncated run … A
|
|
90
|
+
> consumer MUST NOT synthesize a `RUN_FINISHED` for it and MUST NOT report it as
|
|
91
|
+
> having succeeded.
|
|
92
|
+
|
|
93
|
+
The smallest case, from their `conformant-run-is-quiet` fixture cut after its fourth
|
|
94
|
+
event:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
truncated settled {"result":null,"newMessages":1} text "Hello, "
|
|
98
|
+
events RUN_STARTED STEP_STARTED TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT
|
|
99
|
+
completed settled {"result":null,"newMessages":1} text "Hello, world."
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The awaited call reports the same value either way. A subscriber sees no terminal
|
|
103
|
+
event at all. Neither channel says the run was cut.
|
|
104
|
+
|
|
105
|
+
`results/run-ag-ui.mjs` reproduces the table from the recorded observations, through
|
|
106
|
+
this library's own summary rather than separate arithmetic, so the numbers above and
|
|
107
|
+
the numbers the tool prints cannot drift apart.
|
|
108
|
+
|
|
109
|
+
### What this is not
|
|
110
|
+
|
|
111
|
+
It is one protocol, one client, at one commit, cut at event boundaries. Cutting inside
|
|
112
|
+
a frame — mid-UTF-8 sequence, mid-SSE frame, mid tool-call JSON — is not covered yet
|
|
113
|
+
and is where the more interesting failures probably live. Other frameworks have no
|
|
114
|
+
validator of their own to use as an oracle, so for those the harness would have to
|
|
115
|
+
assert its own expectation, which is weaker evidence.
|
|
116
|
+
|
|
117
|
+
Findings belong in the tracker of the project that carries them, not in a scoreboard
|
|
118
|
+
kept outside it.
|
|
119
|
+
|
|
120
|
+
## Status
|
|
121
|
+
|
|
122
|
+
Early. The core loop, the byte-level severing and the summary are tested
|
|
123
|
+
(`npm test`, 21 tests, no dependencies). The API will change.
|
|
124
|
+
|
|
125
|
+
## Licence
|
|
126
|
+
|
|
127
|
+
MIT © Redouane (ROSH Company Labs)
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cut-at-k",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cut a stream at every point and check what the consumer is left with",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"results",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"streaming",
|
|
20
|
+
"truncation",
|
|
21
|
+
"conformance",
|
|
22
|
+
"sse",
|
|
23
|
+
"testing",
|
|
24
|
+
"agent"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "Redouane - ROSH Company Labs"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/roshcompanylabs/cut-at-k.git"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recording step for the AG-UI worked example.
|
|
3
|
+
*
|
|
4
|
+
* Replays every AG-UI conformance fixture through a real HttpAgent over real
|
|
5
|
+
* HTTP/SSE — once whole, then once for every prefix — and writes the raw
|
|
6
|
+
* observations as the JSON that results/run-ag-ui.mjs consumes.
|
|
7
|
+
*
|
|
8
|
+
* node results/record.mjs <ag-ui>/spec/1.0/conformance/streams results/sever-results.json
|
|
9
|
+
* node results/run-ag-ui.mjs results/sever-results.json
|
|
10
|
+
*
|
|
11
|
+
* Only this file needs @ag-ui/client. Its output is committed beside it, so the
|
|
12
|
+
* numbers in the README can be re-derived without installing their client at all.
|
|
13
|
+
*/
|
|
14
|
+
import { createServer } from 'node:http';
|
|
15
|
+
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join, basename } from 'node:path';
|
|
17
|
+
import { HttpAgent } from '@ag-ui/client';
|
|
18
|
+
|
|
19
|
+
const CORPUS = process.argv[2];
|
|
20
|
+
const OUT = process.argv[3];
|
|
21
|
+
if (!CORPUS || !OUT) {
|
|
22
|
+
console.error('usage: node record.mjs <corpus-dir> <out.json>');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const fixtures = readdirSync(CORPUS)
|
|
27
|
+
.filter((f) => f.endsWith('.json'))
|
|
28
|
+
.sort()
|
|
29
|
+
.map((f) => {
|
|
30
|
+
const j = JSON.parse(readFileSync(join(CORPUS, f), 'utf8'));
|
|
31
|
+
return { name: j.name ?? basename(f, '.json'), file: f, stream: j.stream ?? [] };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const byName = new Map(fixtures.map((f) => [f.name, f]));
|
|
35
|
+
|
|
36
|
+
// A real HTTP server speaking real SSE. Truncation = the response simply ends
|
|
37
|
+
// after k events: no further frames, no terminal event, clean close.
|
|
38
|
+
const server = createServer((req, res) => {
|
|
39
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
40
|
+
const fx = byName.get(u.searchParams.get('fixture'));
|
|
41
|
+
const k = Number(u.searchParams.get('k'));
|
|
42
|
+
if (!fx) {
|
|
43
|
+
res.writeHead(404).end();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Drain the request body before answering.
|
|
47
|
+
req.on('data', () => {});
|
|
48
|
+
req.on('end', () => {
|
|
49
|
+
res.writeHead(200, {
|
|
50
|
+
'content-type': 'text/event-stream',
|
|
51
|
+
'cache-control': 'no-cache',
|
|
52
|
+
connection: 'close',
|
|
53
|
+
});
|
|
54
|
+
const prefix = fx.stream.slice(0, k);
|
|
55
|
+
for (const ev of prefix) res.write(`data: ${JSON.stringify(ev)}\n\n`);
|
|
56
|
+
res.end();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
61
|
+
const port = server.address().port;
|
|
62
|
+
|
|
63
|
+
function textOf(messages) {
|
|
64
|
+
return messages
|
|
65
|
+
.map((m) => {
|
|
66
|
+
const c = typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? null);
|
|
67
|
+
const tc = (m.toolCalls ?? []).map((t) => `${t.function?.name}(${t.function?.arguments})`).join(',');
|
|
68
|
+
return `${m.role}:${c}${tc ? `|${tc}` : ''}`;
|
|
69
|
+
})
|
|
70
|
+
.join('\u0001');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function contentOnly(messages) {
|
|
74
|
+
return messages
|
|
75
|
+
.map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? null)))
|
|
76
|
+
.join('\u0001');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function replay(fixture, k) {
|
|
80
|
+
const delivered = [];
|
|
81
|
+
const agent = new HttpAgent({
|
|
82
|
+
url: `http://127.0.0.1:${port}/?fixture=${encodeURIComponent(fixture)}&k=${k}`,
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
const settled = await agent.runAgent({}, { onEvent: ({ event }) => delivered.push(event.type) });
|
|
86
|
+
const msgs = settled.newMessages ?? [];
|
|
87
|
+
return {
|
|
88
|
+
delivered: delivered.join(' '),
|
|
89
|
+
settled: JSON.stringify({ result: settled.result ?? null, newMessages: msgs.length }),
|
|
90
|
+
messagesCount: msgs.length,
|
|
91
|
+
messagesText: textOf(msgs),
|
|
92
|
+
messagesContentOnly: contentOnly(msgs),
|
|
93
|
+
messagesJson: JSON.stringify(msgs),
|
|
94
|
+
};
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
97
|
+
return {
|
|
98
|
+
threw: m,
|
|
99
|
+
delivered: delivered.join(' '),
|
|
100
|
+
// Alternative recording discipline: an errored run recorded as a settled
|
|
101
|
+
// value rather than a throw, which is what their table's zero throws implies.
|
|
102
|
+
errSettled: JSON.stringify({ error: m }),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rows = [];
|
|
108
|
+
for (const fx of fixtures) {
|
|
109
|
+
const of = fx.stream.length;
|
|
110
|
+
if (of < 2) continue; // no cut exists: k runs 1..of-1
|
|
111
|
+
const whole = await replay(fx.name, of);
|
|
112
|
+
for (let k = 1; k < of; k++) {
|
|
113
|
+
const cut = await replay(fx.name, k);
|
|
114
|
+
rows.push({
|
|
115
|
+
fixture: fx.name,
|
|
116
|
+
k,
|
|
117
|
+
of,
|
|
118
|
+
threw: cut.threw,
|
|
119
|
+
cutDelivered: cut.delivered,
|
|
120
|
+
cutSettled: cut.settled,
|
|
121
|
+
cutMessages: cut.messagesText,
|
|
122
|
+
cutMessagesCount: cut.messagesCount,
|
|
123
|
+
cutMessagesContentOnly: cut.messagesContentOnly,
|
|
124
|
+
cutMessagesJson: cut.messagesJson,
|
|
125
|
+
cutErrSettled: cut.errSettled,
|
|
126
|
+
wholeDelivered: whole.delivered,
|
|
127
|
+
wholeSettled: whole.settled,
|
|
128
|
+
wholeMessages: whole.messagesText,
|
|
129
|
+
wholeMessagesCount: whole.messagesCount,
|
|
130
|
+
wholeMessagesContentOnly: whole.messagesContentOnly,
|
|
131
|
+
wholeMessagesJson: whole.messagesJson,
|
|
132
|
+
wholeErrSettled: whole.errSettled,
|
|
133
|
+
wholeThrew: whole.threw,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
process.stderr.write('.');
|
|
137
|
+
}
|
|
138
|
+
process.stderr.write('\n');
|
|
139
|
+
|
|
140
|
+
writeFileSync(OUT, JSON.stringify({ rows }, null, 1));
|
|
141
|
+
console.log(`fixtures seen: ${fixtures.length}`);
|
|
142
|
+
console.log(`fixtures replayed (>=2 events): ${new Set(rows.map((r) => r.fixture)).size}`);
|
|
143
|
+
console.log(`rows (cuts): ${rows.length}`);
|
|
144
|
+
await new Promise((r) => server.close(r));
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worked example: AG-UI's own conformance corpus, cut at every event.
|
|
3
|
+
*
|
|
4
|
+
* This reads the raw observations recorded by replaying each fixture through a
|
|
5
|
+
* real HttpAgent over real HTTP/SSE, and runs them through this library's own
|
|
6
|
+
* summary so the numbers in the README come from the tool rather than from a
|
|
7
|
+
* separate piece of arithmetic.
|
|
8
|
+
*
|
|
9
|
+
* node results/run-ag-ui.mjs <path-to-sever-results.json>
|
|
10
|
+
*
|
|
11
|
+
* The recording step lives in the AG-UI checkout because it needs their client;
|
|
12
|
+
* the file it writes is the input here.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { severAtEveryPoint } from '../src/sever.js';
|
|
16
|
+
import { summarise, format } from '../src/report.js';
|
|
17
|
+
|
|
18
|
+
const input = process.argv[2];
|
|
19
|
+
if (!input) {
|
|
20
|
+
console.error('usage: node results/run-ag-ui.mjs <sever-results.json>');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const raw = JSON.parse(readFileSync(input, 'utf8'));
|
|
25
|
+
|
|
26
|
+
// Rebuild one report per fixture from the recorded observations. Replaying is
|
|
27
|
+
// already done; this replays from the recording so the analysis is the
|
|
28
|
+
// library's, not a bespoke script's.
|
|
29
|
+
const byFixture = new Map();
|
|
30
|
+
for (const row of raw.rows) {
|
|
31
|
+
if (row.k < 0) continue;
|
|
32
|
+
if (!byFixture.has(row.fixture)) byFixture.set(row.fixture, []);
|
|
33
|
+
byFixture.get(row.fixture).push(row);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const reports = [];
|
|
37
|
+
for (const [fixture, rows] of byFixture) {
|
|
38
|
+
rows.sort((a, b) => a.k - b.k);
|
|
39
|
+
const of = rows[0].of;
|
|
40
|
+
const whole = {
|
|
41
|
+
delivered: String(rows[0].wholeDelivered).split(' ').filter(Boolean),
|
|
42
|
+
settled: rows[0].wholeSettled,
|
|
43
|
+
messages: rows[0].wholeMessages,
|
|
44
|
+
};
|
|
45
|
+
const recorded = new Map(rows.map((r) => [r.k, r]));
|
|
46
|
+
|
|
47
|
+
reports.push(
|
|
48
|
+
await severAtEveryPoint({
|
|
49
|
+
events: Array.from({ length: of }, (_, i) => i),
|
|
50
|
+
label: fixture,
|
|
51
|
+
replay: async (prefix) => {
|
|
52
|
+
if (prefix.length === of) return whole;
|
|
53
|
+
const r = recorded.get(prefix.length);
|
|
54
|
+
if (!r) throw new Error(`no recording for k=${prefix.length}`);
|
|
55
|
+
if (r.threw) throw new Error(r.threw);
|
|
56
|
+
return {
|
|
57
|
+
delivered: String(r.cutDelivered).split(' ').filter(Boolean),
|
|
58
|
+
settled: r.cutSettled,
|
|
59
|
+
messages: r.cutMessages,
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Fixtures whose whole stream is invalid on purpose. Cutting one before its
|
|
67
|
+
// deliberate error removes the error, so every comparison against it is noise.
|
|
68
|
+
const summary = summarise(reports, {
|
|
69
|
+
ignore: (label) => label.includes('fatal'),
|
|
70
|
+
// Did the cut actually remove something the application would have seen?
|
|
71
|
+
// Cutting after the last content event and before some trailing bookkeeping
|
|
72
|
+
// removes nothing, and a consumer reporting the same for those is right.
|
|
73
|
+
lostContent: (cut, whole) => cut.messages !== whole.messages,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
console.log(format(summary));
|
|
77
|
+
console.log('\nstreams with at least one silent cut:');
|
|
78
|
+
for (const [label, n] of Object.entries(summary.byStream).sort((a, b) => b[1] - a[1]).slice(0, 10)) {
|
|
79
|
+
console.log(` ${String(n).padStart(3)} ${label}`);
|
|
80
|
+
}
|
|
81
|
+
console.log('\nlast event delivered on a silent cut:');
|
|
82
|
+
for (const [ev, n] of Object.entries(summary.byTerminal).sort((a, b) => b[1] - a[1])) {
|
|
83
|
+
console.log(` ${String(n).padStart(3)} ${ev}`);
|
|
84
|
+
}
|