@gotcos/glasses-server 6.98.1 → 6.99.16

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +170 -11
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/cos-thread.ts +582 -0
  8. package/server/lib/display-bus.ts +3 -0
  9. package/server/lib/drop-attachments.ts +55 -0
  10. package/server/lib/drop-destinations.ts +134 -0
  11. package/server/lib/drop-history-search.ts +97 -0
  12. package/server/lib/drop-p2-boot.ts +60 -0
  13. package/server/lib/drop-p2-runtime.ts +237 -0
  14. package/server/lib/drop-p2-settings.ts +174 -0
  15. package/server/lib/drop-resolution.ts +168 -0
  16. package/server/lib/drop-router.ts +325 -0
  17. package/server/lib/drop-service.ts +830 -0
  18. package/server/lib/drop-settings.ts +56 -0
  19. package/server/lib/drop-store.ts +450 -0
  20. package/server/lib/drop-when.ts +265 -0
  21. package/server/lib/glasses-pairing.ts +15 -0
  22. package/server/lib/learning-needs-you.ts +111 -0
  23. package/server/lib/live-cues-v2-settings.ts +2 -1
  24. package/server/lib/live-cues-v2.ts +6 -4
  25. package/server/lib/media-store.ts +80 -0
  26. package/server/lib/meeting-batch-transcribe.ts +15 -2
  27. package/server/lib/meeting-consolidation-run.ts +169 -0
  28. package/server/lib/meeting-store.ts +61 -0
  29. package/server/lib/meeting-title-steer.ts +200 -0
  30. package/server/lib/network-policy.ts +84 -6
  31. package/server/lib/permission-broker.ts +13 -0
  32. package/server/lib/phone-recording.ts +1 -1
  33. package/server/lib/ping-budget.ts +149 -0
  34. package/server/lib/ping-engine.ts +245 -0
  35. package/server/lib/ping-feed-sink.ts +33 -0
  36. package/server/lib/ping-sources.ts +192 -0
  37. package/server/lib/query-job-coordinator.ts +5 -2
  38. package/server/lib/query-job-runtime.ts +45 -4
  39. package/server/lib/query-job-types.ts +10 -1
  40. package/server/lib/reminder-scheduler.ts +153 -0
  41. package/server/lib/tailnet-self-name.ts +103 -0
  42. package/server/lib/tailscale-nudge.ts +40 -2
  43. package/server/lib/task-store.ts +32 -0
  44. package/server/lib/web-ask-admission.ts +56 -0
  45. package/server/lib/web-live-chunks.ts +273 -0
  46. package/server/lib/web-memories-bridge.ts +323 -0
  47. package/server/lib/web-session-auth.ts +492 -0
  48. package/server/lib/web-usage-bridge.ts +154 -0
  49. package/server/lib/work-search.ts +14 -1
  50. package/server/lib/your-move-count.ts +58 -0
  51. package/server/routes/cos-thread.ts +79 -0
  52. package/server/routes/display.ts +11 -1
  53. package/server/routes/drops.ts +262 -0
  54. package/server/routes/health.ts +16 -0
  55. package/server/routes/live-cues.ts +5 -4
  56. package/server/routes/meeting.ts +61 -5
  57. package/server/routes/permission-broker.ts +23 -3
  58. package/server/routes/transcribe-stream.ts +43 -4
  59. package/server/routes/web-control.ts +569 -0
  60. package/server/routes/welcome-context.ts +2 -1
  61. package/server/scripts/web-live-cls/cdp.mjs +57 -0
  62. package/server/scripts/web-live-cls/measure.mjs +80 -0
  63. package/server/scripts/web-live-cls/mock.mjs +97 -0
  64. package/server/scripts/web-live-cls/render.mjs +26 -0
  65. package/server/web-control/app.css +519 -0
  66. package/server/web-control/app.js +3643 -0
  67. package/server/web-control/control.css +325 -0
  68. package/server/web-control/fonts/DMSans.ttf +0 -0
  69. package/server/web-control/fonts/Fraunces.ttf +0 -0
  70. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  71. package/server/web-control/index.html +275 -0
  72. package/server/web-control/mark.svg +1 -0
  73. package/server/web-control/memories/d3.min.js +2 -0
  74. package/server/web-control/memories/graph-explorer.css +200 -0
  75. package/server/web-control/memories/graph-explorer.js +748 -0
  76. package/server/web-control/memories/memories-app.js +1561 -0
  77. package/server/web-control/memories/memories-theme.css +73 -0
  78. package/server/web-control/memories/memories.html +72 -0
  79. package/server/web-control/memories/memory-stewardship.js +63 -0
  80. package/server/web-control/memories/memory-workspace.js +216 -0
  81. package/server/web-control/panes/cos-chat.css +174 -0
  82. package/server/web-control/panes/cos-chat.js +1118 -0
  83. package/server/web-control/panes/dictation.js +173 -0
  84. package/server/web-control/panes/meetings.css +314 -0
  85. package/server/web-control/panes/meetings.js +2568 -0
  86. package/server/web-control/panes/messages.js +718 -0
  87. package/server/web-control/panes/questions.css +32 -0
  88. package/server/web-control/panes/questions.js +275 -0
  89. package/server/web-control/panes/sessions.css +452 -0
  90. package/server/web-control/panes/settings.js +292 -0
  91. package/server/web-control/panes/speakers.css +269 -0
  92. package/server/web-control/panes/speakers.js +2755 -0
  93. package/server/web-control/panes/threads.js +200 -0
  94. package/server/web-control/panes/work.css +261 -0
  95. package/server/web-control/panes/work.js +1667 -0
  96. package/server/web-control/shell.js +294 -0
  97. package/server/web-control/theme.js +25 -0
  98. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,80 @@
1
+ // 6.99.14: the harness behind the CHANGELOG numbers. Start node mock.mjs <web-control dir> <port> 150 (HISTORY_DELAY_MS=1500
2
+ // for scenario D), then node measure.mjs <port> <width> <height> 20. CHROME= overrides the headless Chromium path.
3
+ // node measure.mjs <mockPort> <width> <height> [N]
4
+ // Two scenarios on the live meeting view, N appended transcript lines each (one per poll):
5
+ // A following: the reader is at the newest line. Measures how far the page around the transcript moves
6
+ // (the title and the Ask panel, in CSS px, summed over updates) and the layout-shift total.
7
+ // B reading: the reader scrolled up into the middle. Measures the drift of the first visible line (CSS px, summed)
8
+ // and the layout-shift total.
9
+ import { launch, sleep } from './cdp.mjs'
10
+ const [port, width, height, N] = [process.argv[2] || 4517, Number(process.argv[3] || 390), Number(process.argv[4] || 844), Number(process.argv[5] || 20)]
11
+ const SID = 'meeting_1762349349000_g2demo'
12
+ const URL = `http://127.0.0.1:${port}/control/#/meetings/live/${SID}`
13
+ const OBS = `(() => { window.__cls = 0; window.__shifts = 0; try { new PerformanceObserver(l => { for (const e of l.getEntries()) { if (!e.hadRecentInput) { window.__cls += e.value; window.__shifts++ } } }).observe({ type: 'layout-shift', buffered: true }) } catch (e) {} })()`
14
+ const append = n => fetch(`http://127.0.0.1:${port}/__mock/append?n=${n}`, { method: 'POST' })
15
+ const browser = await launch(9300 + Math.floor(Math.random() * 500))
16
+ try {
17
+ await browser.viewport(width, height, 'light')
18
+ await browser.send('Page.addScriptToEvaluateOnNewDocument', { source: OBS })
19
+ const probe = `(() => {
20
+ const t = document.querySelector('.mt-dtitle'), side = document.querySelector('.mt-side-panel');
21
+ const box = document.getElementById('mt-turns');
22
+ const last = box && box.lastElementChild, lr = last && last.getBoundingClientRect();
23
+ return { title: t ? t.getBoundingClientRect().top : null, side: side ? side.getBoundingClientRect().top : null, y: scrollY,
24
+ newestShowing: !!(lr && lr.bottom <= Math.min(innerHeight, (getComputedStyle(box).overflowY !== 'visible' ? box.getBoundingClientRect().bottom : innerHeight)) + 2 && lr.bottom > 0),
25
+ turns: box ? box.children.length : 0, cls: window.__cls, shifts: window.__shifts };
26
+ })()`
27
+ // A: following
28
+ await browser.goto(URL); await sleep(3500)
29
+ await browser.evaluate(`window.__cls = 0; window.__shifts = 0`)
30
+ let prev = await browser.evaluate(probe), moved = 0, sideMoved = 0, scrolled = 0
31
+ const start = prev
32
+ for (let k = 0; k < N; k++) {
33
+ await append(1); await sleep(1700)
34
+ const now = await browser.evaluate(probe)
35
+ moved += Math.abs(now.title - prev.title); sideMoved += Math.abs((now.side ?? 0) - (prev.side ?? 0)); scrolled += Math.abs(now.y - prev.y)
36
+ prev = now
37
+ }
38
+ console.log(JSON.stringify({ scenario: 'A following', width, height, N, turnsBefore: start.turns, turnsAfter: prev.turns, titleDriftPx: Math.round(moved), askPanelDriftPx: Math.round(sideMoved), pageScrollPx: Math.round(scrolled), newestLineShowing: prev.newestShowing, layoutShift: +prev.cls.toFixed(4), shiftEntries: prev.shifts }))
39
+ // B: reading in the middle
40
+ await browser.goto(URL); await sleep(3500)
41
+ const pick = await browser.evaluate(`(() => {
42
+ const box = document.getElementById('mt-turns'), kids = [...box.children], mid = kids[Math.floor(kids.length / 2)];
43
+ const inner = getComputedStyle(box).overflowY !== 'visible' && box.scrollHeight > box.clientHeight + 4;
44
+ if (inner) box.scrollTop = mid.offsetTop - box.offsetTop - 40; else window.scrollTo(0, mid.getBoundingClientRect().top + scrollY - 160);
45
+ return { id: mid.getAttribute('data-turn'), inner };
46
+ })()`)
47
+ await sleep(900)
48
+ await browser.evaluate(`window.__cls = 0; window.__shifts = 0`)
49
+ const line = `(() => { const n = document.querySelector('[data-turn="${pick.id}"]'); return n ? n.getBoundingClientRect().top : null })()`
50
+ let p0 = await browser.evaluate(line), drift = 0, first = p0
51
+ for (let k = 0; k < N; k++) {
52
+ await append(1); await sleep(1700)
53
+ const p1 = await browser.evaluate(line); drift += Math.abs(p1 - p0); p0 = p1
54
+ }
55
+ const tail = await browser.evaluate(probe)
56
+ console.log(JSON.stringify({ scenario: 'B reading', width, height, N, innerScroller: pick.inner, firstVisibleLineDriftPx: Math.round(drift), netDriftPx: Math.round(p0 - first), layoutShift: +tail.cls.toFixed(4), shiftEntries: tail.shifts }))
57
+ // C: turns ABOVE the reader are rewritten (the speaker pass sends a full snapshot with longer text).
58
+ await browser.goto(URL); await sleep(3500)
59
+ const pickC = await browser.evaluate(`(() => {
60
+ const box = document.getElementById('mt-turns'), kids = [...box.children], mid = kids[Math.floor(kids.length * 0.6)];
61
+ const inner = getComputedStyle(box).overflowY !== 'visible' && box.scrollHeight > box.clientHeight + 4;
62
+ if (inner) box.scrollTop = mid.offsetTop - box.offsetTop - 40; else window.scrollTo(0, mid.getBoundingClientRect().top + scrollY - 160);
63
+ return { id: mid.getAttribute('data-turn') };
64
+ })()`)
65
+ await sleep(900)
66
+ await browser.evaluate(`window.__cls = 0; window.__shifts = 0`)
67
+ const lineC = `(() => { const n = document.querySelector('[data-turn="${pickC.id}"]'); return n ? n.getBoundingClientRect().top : null })()`
68
+ const c0 = await browser.evaluate(lineC)
69
+ await fetch(`http://127.0.0.1:${port}/__mock/rewrite?upto=40`, { method: 'POST' }); await sleep(2500)
70
+ const c1 = await browser.evaluate(lineC), tc = await browser.evaluate(probe)
71
+ console.log(JSON.stringify({ scenario: 'C rewrite above reader', width, firstVisibleLineDriftPx: Math.round(c1 - c0), layoutShift: +tc.cls.toFixed(4) }))
72
+ // D: a file is already on the meeting and the Ask history answers late (run the mock with HISTORY_DELAY_MS=1500).
73
+ await fetch(`http://127.0.0.1:${port}/__mock/addfile`, { method: 'POST' })
74
+ await browser.goto(URL); await sleep(500)
75
+ const boxTop = `(() => { const b = document.getElementById('mt-turns'); return b ? b.getBoundingClientRect().top + scrollY : null })()`
76
+ const d0 = await browser.evaluate(boxTop); await sleep(3000)
77
+ const d1 = await browser.evaluate(boxTop), td = await browser.evaluate(probe)
78
+ console.log(JSON.stringify({ scenario: 'D files load late', width, transcriptTopMovedPx: Math.round(d1 - d0), layoutShift: +td.cls.toFixed(4), shiftEntries: td.shifts }))
79
+ const errs = browser.logs.filter(l => /^(error|EXC)/.test(l)); if (errs.length) console.log('console errors:', errs.slice(0, 5))
80
+ } finally { await browser.close() }
@@ -0,0 +1,97 @@
1
+ // A fake COS server for rendering and measuring COS Control Web's live meeting view.
2
+ // node mock.mjs <web-control dir> <port> [initialChunks]
3
+ // Serves /control/* from the given directory and answers /api/* with a scripted live glasses meeting.
4
+ // POST /__mock/append?n=K adds K transcript chunks (and their levels). Never touches a real COS server.
5
+ import http from 'node:http'
6
+ import { readFileSync, existsSync, statSync } from 'node:fs'
7
+ import { join, extname, normalize } from 'node:path'
8
+
9
+ const ROOT = process.argv[2]
10
+ const PORT = Number(process.argv[3] || 4517)
11
+ const INITIAL = Number(process.argv[4] || 150)
12
+ const SID = 'meeting_1762349349000_g2demo'
13
+ const START = Date.now() - 15 * 60 * 1000 - 9000
14
+ let seed = 7
15
+ function rnd() { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648 }
16
+ const SPEAKERS = ['Miles', 'Gina', 'Graham', 'Speaker 3']
17
+ const BITS = [
18
+ 'the Liquor pacing is ahead of plan for the month', 'Jewelry demo volume dipped after the show', 'we should move the webinar to Thursday',
19
+ 'Thrift leads came in heavier than the forecast said', 'the comparison page needs the new pricing table', 'call-in volume is up about a third',
20
+ 'I want the brief before the board prep', 'that number ties to the MTD dashboard', 'can we get the persona pull for Pet by Friday',
21
+ 'the endcap photo shows the spring set', 'Silas asked for the budget variance by region', 'let us keep the test running another week',
22
+ 'opportunities are the stickler layer here', 'the form conversion moved from four to six percent', 'Ryan has the creative ready for review',
23
+ ]
24
+ const chunks = []
25
+ let speaker = 'Miles'
26
+ function addChunk() {
27
+ const i = chunks.length
28
+ if (rnd() < 0.55) speaker = SPEAKERS[Math.floor(rnd() * SPEAKERS.length)]
29
+ const n = 1 + Math.floor(rnd() * 3)
30
+ const words = []
31
+ for (let k = 0; k < n; k++) words.push(BITS[Math.floor(rnd() * BITS.length)])
32
+ const text = words.join(', and ').replace(/^./, c => c.toUpperCase()) + '.'
33
+ const silent = rnd() < 0.15
34
+ chunks.push({ i, elapsedMs: i * 6000, speaker, text: silent ? '' : text, level: silent ? 0.12 : 0.35 + rnd() * 0.6, speechSec: silent ? 0.1 : 1 + rnd() * 4.5, at: Date.now() })
35
+ }
36
+ for (let k = 0; k < INITIAL; k++) addChunk()
37
+ let files = []
38
+ let rev = 0
39
+ const HISTORY_DELAY = Number(process.env.HISTORY_DELAY_MS || 0)
40
+ let mediaSeq = 0
41
+ function stamp() { return String(chunks.length) + '.' + files.length + '.' + rev }
42
+ function read(since, known, knownRev) {
43
+ if (known === stamp() && knownRev === String(rev)) return { sessionId: SID, ended: false, unchanged: true, stamp: stamp() }
44
+ const full = knownRev !== String(rev)
45
+ return {
46
+ sessionId: SID, ended: false, unchanged: false, stamp: stamp(), startTime: START, lastActivityAt: Date.now(),
47
+ maxIndex: chunks.length - 1, settledThrough: chunks.length - 1,
48
+ speakerRevision: rev, fullSnapshot: full,
49
+ turns: chunks.filter(c => (full || c.i > since) && c.text).map(c => ({ i: c.i, elapsedMs: c.elapsedMs, speaker: c.speaker, text: c.text })),
50
+ levels: chunks.slice(-120).map(c => ({ i: c.i, level: Math.round(c.level * 1000) / 1000, speechSec: Math.round(c.speechSec * 100) / 100 })),
51
+ }
52
+ }
53
+ const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.json': 'application/json', '.png': 'image/png' }
54
+ function json(res, code, body) { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)) }
55
+ function body(req) { return new Promise(r => { const parts = []; req.on('data', d => parts.push(d)); req.on('end', () => r(Buffer.concat(parts))) }) }
56
+ const server = http.createServer(async (req, res) => {
57
+ const url = new URL(req.url, 'http://x')
58
+ const p = url.pathname
59
+ if (p === '/__mock/append') { const n = Number(url.searchParams.get('n') || 1); for (let k = 0; k < n; k++) addChunk(); return json(res, 200, { count: chunks.length }) }
60
+ if (p === '/__mock/rewrite') { const upto = Number(url.searchParams.get('upto') || 20); for (const c of chunks.slice(0, upto)) if (c.text) c.text += ' Corrected after the speaker pass, with a longer line than before.'; rev++; return json(res, 200, { rev }) }
61
+ if (p === '/__mock/addfile') { files = files.concat([{ id: 'phone_m_seed' + files.length, revision: 'r1', name: 'Board deck.pdf', status: 'ready', text: 'x', origin: 'phone' }]); return json(res, 200, { files }) }
62
+ if (p === '/__mock/state') return json(res, 200, { count: chunks.length, files })
63
+ if (p.startsWith('/control')) {
64
+ let rel = p.slice('/control'.length) || '/'
65
+ if (rel === '/') rel = '/index.html'
66
+ const full = normalize(join(ROOT, rel))
67
+ if (!full.startsWith(normalize(ROOT)) || !existsSync(full) || statSync(full).isDirectory()) { res.writeHead(404); return res.end() }
68
+ res.writeHead(200, { 'Content-Type': TYPES[extname(full)] || 'application/octet-stream', 'Cache-Control': 'no-store' })
69
+ return res.end(readFileSync(full))
70
+ }
71
+ if (!p.startsWith('/api/')) { res.writeHead(302, { Location: '/control/' }); return res.end() }
72
+ const raw = req.method === 'GET' ? Buffer.alloc(0) : await body(req)
73
+ let b = {}; try { b = raw.length && /json/.test(req.headers['content-type'] || '') ? JSON.parse(raw.toString('utf8')) : {} } catch { b = {} }
74
+ if (p === '/api/web-session/status') return json(res, 200, { authenticated: true, owner: 'Miles', version: '6.99.14' })
75
+ if (p === '/api/health') return json(res, 200, { status: 'ok', version: '6.99.14', capabilities: { liveMeeting: { protocol: 1, chunkLevels: true } } })
76
+ if (p === '/api/meetings/live-chunks') {
77
+ const sessions = [{ sessionId: SID, mtimeMs: Date.now(), stamp: stamp() }]
78
+ const sid = url.searchParams.get('sessionId')
79
+ if (!sid) return json(res, 200, { sessions })
80
+ return json(res, 200, { sessions, read: read(Number(url.searchParams.get('since') ?? -1), url.searchParams.get('stamp'), url.searchParams.get('speakerRevision')) })
81
+ }
82
+ if (p.startsWith('/api/meeting/sessions/') && p.endsWith('/status')) return json(res, 200, { state: 'active' })
83
+ if (p === '/api/meeting-ask/config') return json(res, 200, { protocolVersion: 2, serverInstanceId: 'inst-demo-1', selected: { engine: 'claude', model: 'opus' }, cosSearch: 'meaning', publicTopics: false, capabilities: {}, models: [{ engine: 'claude', model: 'opus', available: true }] })
84
+ if (p === '/api/meeting-ask/history') { if (HISTORY_DELAY) await new Promise(r => setTimeout(r, HISTORY_DELAY)); return json(res, 200, { exchanges: [], nextOffset: null, files }) }
85
+ if (p === '/api/meeting-ask/context') return json(res, 200, { candidates: [] })
86
+ if (p === '/api/live-cues/history') return json(res, 200, { cues: [] })
87
+ if (p === '/api/media/file') { const id = 'm_' + (++mediaSeq).toString(16).padStart(24, '0'); return json(res, 200, { attachment: { id, kind: 'document' } }) }
88
+ if (p === '/api/media') { const id = 'm_' + (++mediaSeq).toString(16).padStart(24, '0'); return json(res, 200, { attachments: [{ id, kind: 'image' }] }) }
89
+ if (p === '/api/meeting-ask/files/media') {
90
+ for (const m of b.media || []) files = files.filter(f => f.id !== 'phone_' + m.id).concat([{ id: 'phone_' + m.id, revision: 'r1', name: m.name || m.id, status: /\.(png|jpe?g|heic)$/i.test(m.name || '') ? 'ready' : 'ready', text: 'x', origin: 'phone', ...(/\.(png|jpe?g)$/i.test(m.name || '') ? { notice: 'Image text is available. Verify small numbers and table columns.' } : {}) }])
91
+ return json(res, 200, { files })
92
+ }
93
+ if (p === '/api/meetings') return json(res, 200, { meetings: [] })
94
+ if (p === '/api/meeting/orphans') return json(res, 200, { items: [], stranded: [], recovering: [] })
95
+ return json(res, 200, {})
96
+ })
97
+ server.listen(PORT, '127.0.0.1', () => console.log('mock on', PORT, 'root', ROOT, 'chunks', chunks.length))
@@ -0,0 +1,26 @@
1
+ // node render.mjs <port> <width> <height> <scheme> <out.png> [addFile]
2
+ import { launch, sleep } from './cdp.mjs'
3
+ const [port, w, h, scheme, out, add] = process.argv.slice(2)
4
+ const SID = 'meeting_1762349349000_g2demo'
5
+ const b = await launch(9800 + Math.floor(Math.random() * 150))
6
+ try {
7
+ await b.viewport(Number(w), Number(h), scheme)
8
+ await b.goto(`http://127.0.0.1:${port}/control/#/meetings/live/${SID}`)
9
+ await sleep(3500)
10
+ if (add === '1') {
11
+ const r = await b.evaluate(`(() => {
12
+ const dt = new DataTransfer();
13
+ dt.items.add(new File(['Q4 price list: Starter is 59 a month.'], 'Q4 pricing.pdf', { type: 'application/pdf' }));
14
+ const ta = document.querySelector('.mt-lmain');
15
+ ta.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: dt }));
16
+ const ev = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt });
17
+ ta.dispatchEvent(ev);
18
+ return ev.defaultPrevented;
19
+ })()`)
20
+ console.log('drop prevented', r)
21
+ await sleep(1500)
22
+ }
23
+ if (add === 'open') { await b.evaluate(`(() => { const h = document.querySelector('[data-mt="files-toggle"]'); if (h && h.getAttribute('aria-expanded') !== 'true') h.click(); })()`); await sleep(400) }
24
+ await b.shot(out)
25
+ const errs = b.logs.filter(l => /^(error|EXC)/.test(l)); if (errs.length) console.log(errs.slice(0, 8).join('\n'))
26
+ } finally { await b.close() }