polymath-society 0.2.20 → 0.2.22
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/dist/cli.js +23943 -21630
- package/dist/engine/chat-loops.js +8 -1
- package/dist/engine/exp-allfacets.js +8 -1
- package/dist/engine/exp-person.js +8 -1
- package/dist/engine/exp-pipeline.js +8 -1
- package/dist/engine/ingest-export.js +7 -6
- package/dist/engine/peak-demos.js +8 -1
- package/dist/engine/person-dimension-summary.js +8 -1
- package/dist/engine/person-facet-lines.js +8 -1
- package/dist/engine/person-headline.js +8 -1
- package/dist/engine/person-report.js +8 -1
- package/dist/engine/person-self-image.js +8 -1
- package/dist/engine/public-report.js +15 -7
- package/dist/engine/run-analysis.js +8 -1
- package/dist/engine/run-tagger.js +8 -1
- package/dist/index.js +19647 -17969
- package/dist/pipeline/coding-agglomerate.js +826 -82
- package/dist/pipeline/coding-aggregate.js +307 -23
- package/dist/pipeline/coding-build.js +453 -95
- package/dist/pipeline/coding-coaching.js +479 -88
- package/dist/pipeline/coding-day-digest.js +308 -36
- package/dist/pipeline/coding-delegation.js +376 -52
- package/dist/pipeline/coding-expertise.js +356 -62
- package/dist/pipeline/coding-flow.js +288 -15
- package/dist/pipeline/coding-focus.js +371 -51
- package/dist/pipeline/coding-frontier-detail.js +344 -50
- package/dist/pipeline/coding-frontier.js +7 -6
- package/dist/pipeline/coding-gap-dist.js +288 -15
- package/dist/pipeline/coding-gap.js +507 -115
- package/dist/pipeline/coding-grade.js +342 -45
- package/dist/pipeline/coding-growth.js +17041 -0
- package/dist/pipeline/coding-nutshell.js +15 -7
- package/dist/pipeline/coding-projects.js +7 -6
- package/dist/pipeline/coding-walkthrough.js +317 -37
- package/dist/pipeline/coding-workbrief.js +16387 -0
- package/dist/web/app.js +2095 -1395
- package/dist/web/styles.css +1 -1
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import{createRequire as __cr}from'module';const require=__cr(import.meta.url);
|
|
2
2
|
|
|
3
3
|
// ../../scripts/coding-flow.mts
|
|
4
|
-
import { promises as
|
|
5
|
-
import
|
|
4
|
+
import { promises as fs3 } from "fs";
|
|
5
|
+
import path3 from "path";
|
|
6
6
|
|
|
7
7
|
// ../../lib/agents/shared/cliAdapter.ts
|
|
8
8
|
var CLAUDE_BIN = process.env.CLAUDE_BIN || "claude";
|
|
@@ -50,7 +50,7 @@ var LIBERAL = [
|
|
|
50
50
|
var ALS = new AsyncLocalStorage();
|
|
51
51
|
|
|
52
52
|
// ../coding-core/dist/messages.js
|
|
53
|
-
import { promises as
|
|
53
|
+
import { promises as fs2 } from "fs";
|
|
54
54
|
|
|
55
55
|
// ../coding-core/dist/injected.js
|
|
56
56
|
var SYSTEM_REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
|
|
@@ -176,6 +176,274 @@ function extractCodexEvents(raw) {
|
|
|
176
176
|
return out;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// ../coding-core/dist/cursor.js
|
|
180
|
+
import { promises as fs } from "fs";
|
|
181
|
+
var NODE_SQLITE = "node:sqlite";
|
|
182
|
+
async function openCursorSqlite(dbPath) {
|
|
183
|
+
try {
|
|
184
|
+
await fs.access(dbPath);
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
let mod;
|
|
189
|
+
try {
|
|
190
|
+
mod = await import(NODE_SQLITE);
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const DatabaseSync = mod?.DatabaseSync;
|
|
195
|
+
if (!DatabaseSync)
|
|
196
|
+
return null;
|
|
197
|
+
let db;
|
|
198
|
+
try {
|
|
199
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
200
|
+
} catch {
|
|
201
|
+
try {
|
|
202
|
+
db = new DatabaseSync(dbPath);
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const asText = (v) => v == null ? null : typeof v === "string" ? v : Buffer.from(v).toString("utf-8");
|
|
208
|
+
return {
|
|
209
|
+
prefix(prefix) {
|
|
210
|
+
try {
|
|
211
|
+
const rows = db.prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\'").all(likePrefix(prefix));
|
|
212
|
+
return rows.map((r) => ({ key: r.key, value: asText(r.value) ?? "" }));
|
|
213
|
+
} catch {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
get(key) {
|
|
218
|
+
try {
|
|
219
|
+
const row = db.prepare("SELECT value FROM cursorDiskKV WHERE key = ?").get(key);
|
|
220
|
+
return row ? asText(row.value) : null;
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
close() {
|
|
226
|
+
try {
|
|
227
|
+
db.close();
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function likePrefix(prefix) {
|
|
234
|
+
return prefix.replace(/[\\%_]/g, (c) => "\\" + c) + "%";
|
|
235
|
+
}
|
|
236
|
+
var CURSOR_USER_QUERY_RE = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/;
|
|
237
|
+
function cleanUserText(raw) {
|
|
238
|
+
let t = raw.replace(SYSTEM_REMINDER_RE, "");
|
|
239
|
+
const m = t.match(CURSOR_USER_QUERY_RE);
|
|
240
|
+
if (m)
|
|
241
|
+
t = m[1];
|
|
242
|
+
return t.trim();
|
|
243
|
+
}
|
|
244
|
+
var msFrom = (v) => {
|
|
245
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
246
|
+
return v;
|
|
247
|
+
if (typeof v === "string") {
|
|
248
|
+
const n = Date.parse(v);
|
|
249
|
+
return Number.isFinite(n) ? n : null;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
};
|
|
253
|
+
function readCursorComposer(db, composerId) {
|
|
254
|
+
const cdRaw = db.get(`composerData:${composerId}`);
|
|
255
|
+
if (!cdRaw)
|
|
256
|
+
return null;
|
|
257
|
+
let cd;
|
|
258
|
+
try {
|
|
259
|
+
cd = JSON.parse(cdRaw);
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const headers = Array.isArray(cd.fullConversationHeadersOnly) ? cd.fullConversationHeadersOnly : [];
|
|
264
|
+
if (headers.length === 0)
|
|
265
|
+
return null;
|
|
266
|
+
const bubbles = /* @__PURE__ */ new Map();
|
|
267
|
+
for (const row of db.prefix(`bubbleId:${composerId}:`)) {
|
|
268
|
+
const bid = row.key.slice(`bubbleId:${composerId}:`.length);
|
|
269
|
+
try {
|
|
270
|
+
bubbles.set(bid, JSON.parse(row.value));
|
|
271
|
+
} catch {
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const turns = [];
|
|
275
|
+
let clock = msFrom(cd.createdAt) ?? 0;
|
|
276
|
+
for (const h of headers) {
|
|
277
|
+
const b = bubbles.get(h.bubbleId);
|
|
278
|
+
if (!b)
|
|
279
|
+
continue;
|
|
280
|
+
const type = h.type === 1 ? 1 : 2;
|
|
281
|
+
const tool = type === 2 && b.toolFormerData && typeof b.toolFormerData.name === "string" ? b.toolFormerData.name : null;
|
|
282
|
+
const rawText = typeof b.text === "string" ? b.text : "";
|
|
283
|
+
const text = type === 1 ? cleanUserText(rawText) : rawText.replace(SYSTEM_REMINDER_RE, "").trim();
|
|
284
|
+
if (!text && !tool)
|
|
285
|
+
continue;
|
|
286
|
+
const t = msFrom(b.createdAt);
|
|
287
|
+
clock = Math.max(clock, t ?? clock + 1);
|
|
288
|
+
const model = b.modelInfo && typeof b.modelInfo.modelName === "string" ? b.modelInfo.modelName : null;
|
|
289
|
+
turns.push({ t: clock, type, text, tool, model });
|
|
290
|
+
}
|
|
291
|
+
if (turns.length === 0)
|
|
292
|
+
return null;
|
|
293
|
+
const gitRepo = Array.isArray(cd.trackedGitRepos) && cd.trackedGitRepos[0] && typeof cd.trackedGitRepos[0].repoPath === "string" ? cd.trackedGitRepos[0].repoPath : null;
|
|
294
|
+
return {
|
|
295
|
+
composerId,
|
|
296
|
+
createdAt: msFrom(cd.createdAt),
|
|
297
|
+
updatedAt: msFrom(cd.lastUpdatedAt),
|
|
298
|
+
name: typeof cd.name === "string" && cd.name.trim() ? cd.name.trim() : null,
|
|
299
|
+
gitRepo,
|
|
300
|
+
turns
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function sniffCursorRaw(raw) {
|
|
304
|
+
for (const lineRaw of raw.split("\n")) {
|
|
305
|
+
const line = lineRaw.trim();
|
|
306
|
+
if (!line)
|
|
307
|
+
continue;
|
|
308
|
+
try {
|
|
309
|
+
const e = JSON.parse(line);
|
|
310
|
+
return (e.role === "user" || e.role === "assistant") && typeof e.message === "object" && e.message !== null && "content" in e.message && !("type" in e);
|
|
311
|
+
} catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
function jsonlText(content) {
|
|
318
|
+
const parts = [];
|
|
319
|
+
const tools = [];
|
|
320
|
+
if (Array.isArray(content)) {
|
|
321
|
+
for (const item of content) {
|
|
322
|
+
if (!item || typeof item !== "object")
|
|
323
|
+
continue;
|
|
324
|
+
const it = item;
|
|
325
|
+
if (it.type === "text" && it.text)
|
|
326
|
+
parts.push(it.text);
|
|
327
|
+
else if (it.type === "tool_use" && it.name)
|
|
328
|
+
tools.push(it.name);
|
|
329
|
+
}
|
|
330
|
+
} else if (typeof content === "string")
|
|
331
|
+
parts.push(content);
|
|
332
|
+
return { text: parts.join("\n"), tools };
|
|
333
|
+
}
|
|
334
|
+
function parseCursorJsonl(raw, base) {
|
|
335
|
+
const turns = [];
|
|
336
|
+
let clock = base;
|
|
337
|
+
for (const lineRaw of raw.split("\n")) {
|
|
338
|
+
const line = lineRaw.trim();
|
|
339
|
+
if (!line)
|
|
340
|
+
continue;
|
|
341
|
+
let e;
|
|
342
|
+
try {
|
|
343
|
+
e = JSON.parse(line);
|
|
344
|
+
} catch {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (e.role !== "user" && e.role !== "assistant")
|
|
348
|
+
continue;
|
|
349
|
+
const { text: rawText, tools } = jsonlText(e.message?.content);
|
|
350
|
+
const type = e.role === "user" ? 1 : 2;
|
|
351
|
+
const text = type === 1 ? cleanUserText(rawText) : rawText.replace(SYSTEM_REMINDER_RE, "").trim();
|
|
352
|
+
if (type === 2 && tools.length) {
|
|
353
|
+
for (const tool of tools) {
|
|
354
|
+
clock += 1;
|
|
355
|
+
turns.push({ t: clock, type: 2, text: "", tool, model: null });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (!text)
|
|
359
|
+
continue;
|
|
360
|
+
clock += 1;
|
|
361
|
+
turns.push({ t: clock, type, text, tool: null, model: null });
|
|
362
|
+
}
|
|
363
|
+
if (turns.length === 0)
|
|
364
|
+
return null;
|
|
365
|
+
return { composerId: "", createdAt: base, updatedAt: clock, name: null, gitRepo: null, turns };
|
|
366
|
+
}
|
|
367
|
+
function cursorMessages(c) {
|
|
368
|
+
const out = [];
|
|
369
|
+
let aiStart = 0;
|
|
370
|
+
let aiParts = [];
|
|
371
|
+
const flushAI = () => {
|
|
372
|
+
if (!aiParts.length)
|
|
373
|
+
return;
|
|
374
|
+
out.push({ t: aiStart, role: "ai", text: aiParts.join("\n") });
|
|
375
|
+
aiParts = [];
|
|
376
|
+
};
|
|
377
|
+
for (const turn of c.turns) {
|
|
378
|
+
if (turn.type === 1) {
|
|
379
|
+
flushAI();
|
|
380
|
+
out.push({ t: turn.t, role: "user", text: turn.text });
|
|
381
|
+
} else {
|
|
382
|
+
if (aiParts.length === 0)
|
|
383
|
+
aiStart = turn.t;
|
|
384
|
+
if (turn.text)
|
|
385
|
+
aiParts.push(turn.text);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
flushAI();
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
function composerIdFromFile(file) {
|
|
392
|
+
const m = file.match(/agent-transcripts\/([^/]+)\/[^/]+\.jsonl$/);
|
|
393
|
+
return m ? m[1] : null;
|
|
394
|
+
}
|
|
395
|
+
async function loadCursorComposer(file, raw, dbPath) {
|
|
396
|
+
const cid = composerIdFromFile(file);
|
|
397
|
+
if (cid && dbPath) {
|
|
398
|
+
const db = await openCursorSqlite(dbPath);
|
|
399
|
+
if (db) {
|
|
400
|
+
try {
|
|
401
|
+
const c = readCursorComposer(db, cid);
|
|
402
|
+
if (c)
|
|
403
|
+
return c;
|
|
404
|
+
} finally {
|
|
405
|
+
db.close();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return parseCursorJsonl(raw, 0);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ../coding-core/dist/raw.js
|
|
413
|
+
import path from "path";
|
|
414
|
+
import os2 from "os";
|
|
415
|
+
var SOURCE_ROOT_ENV_VARS = [
|
|
416
|
+
"CLAUDE_PROJECTS_DIR",
|
|
417
|
+
"CODEX_SESSIONS_DIR",
|
|
418
|
+
"CURSOR_WORKSPACE_DIR",
|
|
419
|
+
"CURSOR_PROJECTS_DIR",
|
|
420
|
+
// Cursor's globalStorage dir (holds state.vscdb — the composer bubbles that
|
|
421
|
+
// carry a Cursor session's real per-turn timestamps + content). Same
|
|
422
|
+
// isolation contract: overriding any root disables every unset source.
|
|
423
|
+
"CURSOR_GLOBAL_DIR"
|
|
424
|
+
];
|
|
425
|
+
var anyRootOverridden = () => SOURCE_ROOT_ENV_VARS.some((v) => !!process.env[v]);
|
|
426
|
+
function resolveSourceRoot(envVar, ...defaultSegments) {
|
|
427
|
+
const explicit = process.env[envVar];
|
|
428
|
+
if (explicit)
|
|
429
|
+
return explicit;
|
|
430
|
+
if (anyRootOverridden())
|
|
431
|
+
return null;
|
|
432
|
+
return path.join(os2.homedir(), ...defaultSegments);
|
|
433
|
+
}
|
|
434
|
+
var cursorGlobalRoot = () => resolveSourceRoot("CURSOR_GLOBAL_DIR", "Library", "Application Support", "Cursor", "User", "globalStorage");
|
|
435
|
+
function cursorGlobalDbPath() {
|
|
436
|
+
const root = cursorGlobalRoot();
|
|
437
|
+
return root === null ? null : path.join(root, "state.vscdb");
|
|
438
|
+
}
|
|
439
|
+
var MACHINE_CURSOR_FILE_RE = /^(.*[/\\]machines[/\\][^/\\]+[/\\]\.cursor)[/\\]projects[/\\]/;
|
|
440
|
+
function cursorDbPathForFile(file) {
|
|
441
|
+
const m = file.match(MACHINE_CURSOR_FILE_RE);
|
|
442
|
+
if (m)
|
|
443
|
+
return path.join(m[1], "globalStorage", "state.vscdb");
|
|
444
|
+
return cursorGlobalDbPath();
|
|
445
|
+
}
|
|
446
|
+
|
|
179
447
|
// ../coding-core/dist/messages.js
|
|
180
448
|
function extractText(content) {
|
|
181
449
|
if (typeof content === "string")
|
|
@@ -233,12 +501,16 @@ function extractMessagesCodex(raw) {
|
|
|
233
501
|
async function extractMessages(file) {
|
|
234
502
|
let raw;
|
|
235
503
|
try {
|
|
236
|
-
raw = await
|
|
504
|
+
raw = await fs2.readFile(file, "utf-8");
|
|
237
505
|
} catch {
|
|
238
506
|
return [];
|
|
239
507
|
}
|
|
240
508
|
if (sniffCodexRaw(raw))
|
|
241
509
|
return extractMessagesCodex(raw);
|
|
510
|
+
if (sniffCursorRaw(raw)) {
|
|
511
|
+
const c = await loadCursorComposer(file, raw, cursorDbPathForFile(file));
|
|
512
|
+
return c ? cursorMessages(c) : [];
|
|
513
|
+
}
|
|
242
514
|
const out = [];
|
|
243
515
|
const userTexts = /* @__PURE__ */ new Set();
|
|
244
516
|
const seenUuids = /* @__PURE__ */ new Set();
|
|
@@ -380,7 +652,7 @@ function proseWords(text) {
|
|
|
380
652
|
}
|
|
381
653
|
|
|
382
654
|
// ../../lib/agents/coding/profile.ts
|
|
383
|
-
import
|
|
655
|
+
import path2 from "path";
|
|
384
656
|
|
|
385
657
|
// ../../lib/calibration/fingerprint.ts
|
|
386
658
|
import { createHash } from "node:crypto";
|
|
@@ -530,7 +802,7 @@ var IDLE_CAP_MS = 12 * 6e4;
|
|
|
530
802
|
|
|
531
803
|
// ../../lib/calibration/index.ts
|
|
532
804
|
var DEFAULT_CALIBRATION = {
|
|
533
|
-
version: "2026-07-
|
|
805
|
+
version: "2026-07-21.1",
|
|
534
806
|
// = RUBRIC_FINGERPRINT of the shipped npm rubric (packages/coding-analyzer/
|
|
535
807
|
// src/grade/criteria.ts). A unit test fails loud when the rubric changes
|
|
536
808
|
// without this being updated (and re-pushed to the server).
|
|
@@ -626,13 +898,14 @@ var DEFAULT_CALIBRATION = {
|
|
|
626
898
|
},
|
|
627
899
|
codingBenchmarks: {
|
|
628
900
|
flow: {
|
|
629
|
-
// typical knowledge worker ≈ 35% of an 8h day focused; ~4h/day
|
|
630
|
-
//
|
|
901
|
+
// typical knowledge worker ≈ 35% of an 8h day focused; the ~4h/day
|
|
902
|
+
// deep-work ceiling (Newport) sits nearer ~40% of a real (9-10h) top
|
|
903
|
+
// performer's day — 50% overstated it (owner call 2026-07-21).
|
|
631
904
|
typicalPctOfDay: 0.35,
|
|
632
|
-
topPctOfDay: 0.
|
|
905
|
+
topPctOfDay: 0.4,
|
|
633
906
|
tag: "measured",
|
|
634
|
-
source: "RescueTime 2019 (~2h48m focused/day) \xB7 Cal Newport, Deep Work (~4h/day deep-work ceiling)",
|
|
635
|
-
line: "the best spend ~
|
|
907
|
+
source: "RescueTime 2019 (~2h48m focused/day) \xB7 Cal Newport, Deep Work (~4h/day deep-work ceiling against a 9-10h day)",
|
|
908
|
+
line: "the best spend ~40% of the workday in true deep flow; ~4h/day is the human ceiling"
|
|
636
909
|
},
|
|
637
910
|
longestRun: {
|
|
638
911
|
typicalHours: 0.67,
|
|
@@ -681,8 +954,8 @@ var BEST = DEFAULT_CALIBRATION.codingBenchmarks;
|
|
|
681
954
|
|
|
682
955
|
// ../../lib/agents/coding/profile.ts
|
|
683
956
|
var ROOT = process.cwd();
|
|
684
|
-
var CODING_DIR = process.env.POLYMATH_DATA_DIR ?
|
|
685
|
-
var GRADES =
|
|
957
|
+
var CODING_DIR = process.env.POLYMATH_DATA_DIR ? path2.join(process.env.POLYMATH_DATA_DIR, "coding") : path2.join(ROOT, ".data", "coding");
|
|
958
|
+
var GRADES = path2.join(CODING_DIR, "grades");
|
|
686
959
|
|
|
687
960
|
// ../../scripts/coding-flow.mts
|
|
688
961
|
var WPM = 130;
|
|
@@ -698,7 +971,7 @@ var slotOfDay = (t) => {
|
|
|
698
971
|
};
|
|
699
972
|
async function main() {
|
|
700
973
|
const CODING = CODING_DIR;
|
|
701
|
-
const sess = JSON.parse(await
|
|
974
|
+
const sess = JSON.parse(await fs3.readFile(path3.join(CODING, "sessions.json"), "utf8")).filter((s) => !s.duplicateOf);
|
|
702
975
|
const ev = [];
|
|
703
976
|
for (const s of sess) {
|
|
704
977
|
let ms = [];
|
|
@@ -743,7 +1016,7 @@ async function main() {
|
|
|
743
1016
|
}
|
|
744
1017
|
const fs22 = days.reduce((a, d) => a + d.flowSlots, 0), as = days.reduce((a, d) => a + d.activeSlots, 0);
|
|
745
1018
|
const out = { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), params: { wpm: WPM, flowGapMin: FLOW_GAP, breakMin: BREAK, windowMin: WIN, tz: TZ }, totals: { activeDays: days.length, flowSlots: fs22, activeSlots: as, flowFraction: Math.round(fs22 / as * 100) / 100 }, days };
|
|
746
|
-
await
|
|
1019
|
+
await fs3.writeFile(path3.join(CODING, "flow.json"), JSON.stringify(out, null, 2));
|
|
747
1020
|
console.log(`wrote .data/coding/flow.json \u2014 ${days.length} days \xB7 ${fs22}/${as} slots in flow (${Math.round(100 * fs22 / as)}%)`);
|
|
748
1021
|
console.log(`top flow days:`);
|
|
749
1022
|
[...days].sort((a, b) => b.flowSlots - a.flowSlots).slice(0, 5).forEach((d) => console.log(` ${d.date} ${d.flowSlots} flow slots (${d.flowHours}h) \xB7 ${Math.round(d.flowFraction * 100)}% of active`));
|