@drakulavich/zapara 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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/assets/characters.webp +0 -0
- package/assets/fonts/LICENSE-inter.txt +92 -0
- package/assets/fonts/LICENSE-jetbrains-mono.txt +93 -0
- package/assets/fonts/inter-400.woff2 +0 -0
- package/assets/fonts/inter-700.woff2 +0 -0
- package/assets/fonts/inter-800.woff2 +0 -0
- package/assets/fonts/jetbrains-mono-500.woff2 +0 -0
- package/package.json +27 -0
- package/src/analyze.ts +12 -0
- package/src/card.ts +162 -0
- package/src/cardhtml.ts +133 -0
- package/src/derive.ts +129 -0
- package/src/format.ts +20 -0
- package/src/image.ts +70 -0
- package/src/index.ts +141 -0
- package/src/parse.ts +105 -0
- package/src/render.ts +110 -0
- package/src/report.ts +18 -0
- package/src/scan.ts +40 -0
- package/src/score.ts +58 -0
- package/src/types.ts +15 -0
package/src/parse.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Event } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
// Markers compared against message text. The text itself is never kept.
|
|
4
|
+
const INTERRUPT_PREFIX = "[Request interrupted by user";
|
|
5
|
+
const REJECT_PREFIX = "The user doesn't want to proceed with this tool use";
|
|
6
|
+
const QUESTION_TOOL = "AskUserQuestion";
|
|
7
|
+
const PLAN_TOOL = "ExitPlanMode";
|
|
8
|
+
// Inbound messages from subagents, other sessions and background tasks: the human
|
|
9
|
+
// has to read and react to these, but did not type them, so they are `report`
|
|
10
|
+
// events, never `prompt`. An interrupt marker is checked first and always wins.
|
|
11
|
+
const AGENT_MARKERS = [
|
|
12
|
+
"Another Claude session sent a message:",
|
|
13
|
+
"<teammate-message",
|
|
14
|
+
"<cross-session-message",
|
|
15
|
+
"<task-notification>",
|
|
16
|
+
"[Cross-session",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
type Rec = Record<string, unknown>;
|
|
20
|
+
const isObj = (v: unknown): v is Rec => typeof v === "object" && v !== null;
|
|
21
|
+
const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|
22
|
+
|
|
23
|
+
const firstText = (content: unknown): string | null => {
|
|
24
|
+
if (typeof content === "string") return content;
|
|
25
|
+
if (Array.isArray(content) && isObj(content[0]) && content[0].type === "text") return str(content[0].text);
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function parseTranscript(text: string): Event[] {
|
|
30
|
+
const events: Event[] = [];
|
|
31
|
+
let lastTs: number | null = null;
|
|
32
|
+
let lastMode: string | null = null;
|
|
33
|
+
// Dedupes `output` events by requestId within this one file: Claude Code
|
|
34
|
+
// writes one record per content block of a response, repeating the same
|
|
35
|
+
// requestId and usage, so only the first qualifying record counts.
|
|
36
|
+
const seenRequestIds = new Set<string>();
|
|
37
|
+
|
|
38
|
+
for (const line of text.split("\n")) {
|
|
39
|
+
if (line.trim() === "") continue;
|
|
40
|
+
let rec: unknown;
|
|
41
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
42
|
+
if (!isObj(rec)) continue;
|
|
43
|
+
const type = str(rec.type);
|
|
44
|
+
const sessionId = str(rec.sessionId);
|
|
45
|
+
if (type === null || sessionId === null) continue;
|
|
46
|
+
if (rec.isSidechain === true) continue;
|
|
47
|
+
|
|
48
|
+
if (type === "permission-mode") {
|
|
49
|
+
const m = str(rec.permissionMode);
|
|
50
|
+
if (m === null) continue;
|
|
51
|
+
// The baseline mode is tracked as soon as it is seen, even before any
|
|
52
|
+
// timestamp exists, so a later switch away from it can be detected. A
|
|
53
|
+
// switch is only ever emitted as an event once a timestamp is known;
|
|
54
|
+
// a switch that happens before any timestamp is known is dropped.
|
|
55
|
+
if (lastMode !== null && m !== lastMode && lastTs !== null) {
|
|
56
|
+
events.push({ ts: lastTs, sessionId, kind: "mode_change" });
|
|
57
|
+
}
|
|
58
|
+
lastMode = m;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (type !== "user" && type !== "assistant") continue;
|
|
63
|
+
const ts = Date.parse(str(rec.timestamp) ?? "");
|
|
64
|
+
if (Number.isNaN(ts)) continue;
|
|
65
|
+
lastTs = ts;
|
|
66
|
+
events.push({ ts, sessionId, kind: "activity" });
|
|
67
|
+
|
|
68
|
+
const content = isObj(rec.message) ? rec.message.content : undefined;
|
|
69
|
+
if (type === "user") {
|
|
70
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
71
|
+
for (const b of blocks) {
|
|
72
|
+
if (!isObj(b) || b.type !== "tool_result") continue;
|
|
73
|
+
const t = firstText(b.content);
|
|
74
|
+
if (t !== null && t.startsWith(REJECT_PREFIX)) events.push({ ts, sessionId, kind: "reject" });
|
|
75
|
+
}
|
|
76
|
+
const head = firstText(content);
|
|
77
|
+
if (head === null) continue;
|
|
78
|
+
if (head.startsWith(INTERRUPT_PREFIX)) events.push({ ts, sessionId, kind: "interrupt" });
|
|
79
|
+
else if (rec.isMeta !== true) {
|
|
80
|
+
if (AGENT_MARKERS.some((marker) => head.startsWith(marker))) events.push({ ts, sessionId, kind: "report" });
|
|
81
|
+
else events.push({ ts, sessionId, kind: "prompt" });
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
85
|
+
for (const b of blocks) {
|
|
86
|
+
if (!isObj(b) || b.type !== "tool_use") continue;
|
|
87
|
+
if (b.name === QUESTION_TOOL) events.push({ ts, sessionId, kind: "question" });
|
|
88
|
+
else if (b.name === PLAN_TOOL) events.push({ ts, sessionId, kind: "plan_review" });
|
|
89
|
+
}
|
|
90
|
+
// `output`: one event per distinct requestId per file, the first record seen
|
|
91
|
+
// that has a string requestId, numeric usage.output_tokens and at least one
|
|
92
|
+
// text block. A response holding only tool_use blocks is not text the human
|
|
93
|
+
// reads, so it never triggers this, even on its first (and only) record.
|
|
94
|
+
const requestId = str(rec.requestId);
|
|
95
|
+
const usage = isObj(rec.message) ? rec.message.usage : undefined;
|
|
96
|
+
const outputTokens = isObj(usage) ? usage.output_tokens : undefined;
|
|
97
|
+
const hasText = blocks.some((b) => isObj(b) && b.type === "text");
|
|
98
|
+
if (requestId !== null && typeof outputTokens === "number" && hasText && !seenRequestIds.has(requestId)) {
|
|
99
|
+
seenRequestIds.add(requestId);
|
|
100
|
+
events.push({ ts, sessionId, kind: "output", tokens: outputTokens });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return events;
|
|
105
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { formatCount, plural } from "./format.ts";
|
|
2
|
+
import type { Day, HourBucket, Level } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const GLYPH: Record<Level, string> = { Calm: "░", Warming: "▒", Heating: "▓", Fried: "█" };
|
|
5
|
+
const ANSI: Record<Level, string> = { Calm: "32", Warming: "33", Heating: "35", Fried: "31" };
|
|
6
|
+
const LEVEL_NAME: Record<Level, string> = { Calm: "calm", Warming: "warming", Heating: "heating", Fried: "fried" };
|
|
7
|
+
const LEVELS: Level[] = ["Calm", "Warming", "Heating", "Fried"];
|
|
8
|
+
const WEEKDAY = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
9
|
+
|
|
10
|
+
const paint = (s: string, level: Level, color: boolean) => (color ? `\x1b[${ANSI[level]}m${s}\x1b[0m` : s);
|
|
11
|
+
const dim = (s: string, color: boolean) => (color ? `\x1b[2m${s}\x1b[0m` : s);
|
|
12
|
+
const hm = (min: number) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
|
|
13
|
+
const label = (date: string) => {
|
|
14
|
+
const [y, m, d] = date.split("-").map(Number) as [number, number, number];
|
|
15
|
+
return `${WEEKDAY[new Date(y, m - 1, d).getDay()]} ${String(d).padStart(2, "0")}/${String(m).padStart(2, "0")}`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function renderWeek(days: Day[], color: boolean): string {
|
|
19
|
+
// Geometry: 12-char label, 24 cells of 3 chars (glyph in the middle), peak in 6, active in 8. Header hours are `HH ` so they sit over the cells.
|
|
20
|
+
const header = " " + Array.from({ length: 24 }, (_, h) => `${String(h).padStart(2, "0")} `).join("") + " peak active";
|
|
21
|
+
const rows = days.map((d) => {
|
|
22
|
+
const cells = d.buckets.map((b) => ` ${b.score ? paint(GLYPH[b.score.level], b.score.level, color) : "·"} `).join("");
|
|
23
|
+
return `${label(d.date).padEnd(12)}${cells}${String(d.peak ?? "-").padStart(6)}${hm(d.activeMin).padStart(8)}`;
|
|
24
|
+
});
|
|
25
|
+
// A painted glyph's own \x1b[0m would cancel the line's outer dim, so in
|
|
26
|
+
// color mode re-emit \x1b[2m right after it to keep the label dim too.
|
|
27
|
+
const dimGlyph = (level: Level) => paint(GLYPH[level], level, color) + (color ? "\x1b[2m" : "");
|
|
28
|
+
const legend = dim(" " + LEVELS.map((l) => `${dimGlyph(l)} ${LEVEL_NAME[l]}`).join(" "), color);
|
|
29
|
+
const active = days.reduce((s, d) => s + d.activeMin, 0);
|
|
30
|
+
const prompts = days.reduce((s, d) => s + d.totals.prompts, 0);
|
|
31
|
+
const reports = days.reduce((s, d) => s + d.totals.reports, 0);
|
|
32
|
+
const decisions = days.reduce((s, d) => s + d.totals.decisions, 0);
|
|
33
|
+
const maxSessions = Math.max(0, ...days.map((d) => d.totals.maxSessions));
|
|
34
|
+
// Counts go through formatCount so a very active window (999 999 999 prompts) still
|
|
35
|
+
// fits inside the grid's 100 columns; hm(active) has no compact form, so it stays as is.
|
|
36
|
+
const totals = dim(` ${hm(active)} active ${plural(prompts, "prompt")} ${plural(reports, "report")} ${plural(decisions, "decision")} ${plural(maxSessions, "session")} at once`, color);
|
|
37
|
+
return [header, ...rows, "", legend, totals].join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Values at or above 1000 are shown as one decimal of a thousand (e.g. "41.2k"); smaller values print as-is.
|
|
41
|
+
const fmtTokens = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
42
|
+
|
|
43
|
+
type Col = [string, number, (b: HourBucket) => string];
|
|
44
|
+
|
|
45
|
+
// Widths reproduce the header the test pins: hour is left-aligned, level is left-aligned inside a 9-wide cell with two leading spaces, everything else right-aligned.
|
|
46
|
+
const COLS: Col[] = [
|
|
47
|
+
["hour", 5, (b) => `${String(b.hour).padStart(2, "0")}:00`],
|
|
48
|
+
["index", 7, (b) => String(b.score?.index ?? "")],
|
|
49
|
+
["level", 9, (b) => b.score?.level ?? ""],
|
|
50
|
+
["sess", 6, (b) => String(b.sessions)],
|
|
51
|
+
["prompts", 9, (b) => String(b.prompts)],
|
|
52
|
+
["rep", 5, (b) => String(b.reports)],
|
|
53
|
+
["intr", 6, (b) => String(b.interrupts)],
|
|
54
|
+
["rej", 5, (b) => String(b.rejects)],
|
|
55
|
+
["quest", 7, (b) => String(b.questions)],
|
|
56
|
+
["plan", 6, (b) => String(b.plans)],
|
|
57
|
+
["mode", 6, (b) => String(b.modeSwitches)],
|
|
58
|
+
["ctx-sw", 8, (b) => String(b.contextSwitches)],
|
|
59
|
+
["streak", 8, (b) => `${b.streakMin}m`],
|
|
60
|
+
["out-tok", 9, (b) => fmtTokens(b.outputTokens)],
|
|
61
|
+
];
|
|
62
|
+
const EXPLAIN: Col[] = [
|
|
63
|
+
["par", 5, (b) => String(b.score?.parts.parallel ?? "")],
|
|
64
|
+
["pace", 6, (b) => String(b.score?.parts.pace ?? "")],
|
|
65
|
+
["sup", 6, (b) => String(b.score?.parts.supervision ?? "")],
|
|
66
|
+
["read", 6, (b) => String(b.score?.parts.reading ?? "")],
|
|
67
|
+
["strk", 6, (b) => String(b.score?.parts.streak ?? "")],
|
|
68
|
+
["late", 6, (b) => String(b.score?.parts.late ?? "")],
|
|
69
|
+
];
|
|
70
|
+
// Event columns: left out of the table for a day where every active bucket reads
|
|
71
|
+
// zero (a quiet day is mostly zeros, and the eye hunts for the non-zero cell).
|
|
72
|
+
// The skeleton columns (hour, index, level, sess, prompts, streak, out-tok, and
|
|
73
|
+
// the six --explain parts) always show, so two days still line up.
|
|
74
|
+
const EVENT_COLS = new Set(["rep", "intr", "rej", "quest", "plan", "mode", "ctx-sw"]);
|
|
75
|
+
const FULL_NAME: Record<string, string> = {
|
|
76
|
+
rep: "reports", intr: "interrupts", rej: "rejects", quest: "questions",
|
|
77
|
+
plan: "plans", mode: "mode switches", "ctx-sw": "context switches",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function renderDay(day: Day, opts: { explain: boolean; color: boolean }): string {
|
|
81
|
+
const cols = opts.explain ? [...COLS, ...EXPLAIN] : COLS;
|
|
82
|
+
const line = (columns: Col[], cells: string[]) =>
|
|
83
|
+
cells.map((c, i) => { const w = columns[i]![1]; if (i === 0) return c.padEnd(w); if (columns[i]![0] === "level") return ` ${c.padEnd(w - 2)}`; return c.padStart(w); }).join("").trimEnd();
|
|
84
|
+
|
|
85
|
+
const active = day.buckets.filter((b) => b.score !== null);
|
|
86
|
+
// No active bucket: today's plain behavior, the full header and nothing else.
|
|
87
|
+
if (active.length === 0) return line(cols, cols.map(([name]) => name));
|
|
88
|
+
|
|
89
|
+
const visible = cols.filter(([name, , f]) => !EVENT_COLS.has(name) || active.some((b) => f(b) !== "0"));
|
|
90
|
+
const leftOut = cols.filter((col) => EVENT_COLS.has(col[0]) && !visible.includes(col));
|
|
91
|
+
|
|
92
|
+
const header = line(visible, visible.map(([name]) => name));
|
|
93
|
+
const rows = active.map((b) => {
|
|
94
|
+
const cells = visible.map(([, , f]) => f(b));
|
|
95
|
+
const text = line(visible, cells);
|
|
96
|
+
// Safe only because no other column can contain a level word (Calm/Warming/Heating/Fried);
|
|
97
|
+
// if one ever could, this would need to target the level column's slice, not a string search.
|
|
98
|
+
return opts.color && b.score ? text.replace(b.score.level, paint(b.score.level, b.score.level, true)) : text;
|
|
99
|
+
});
|
|
100
|
+
const lines = [header, ...rows];
|
|
101
|
+
if (leftOut.length > 0) {
|
|
102
|
+
const note = ` no ${leftOut.map(([name]) => FULL_NAME[name]).join(", ")} today`;
|
|
103
|
+
lines.push(dim(note, opts.color));
|
|
104
|
+
}
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function renderJson(data: Day[] | Day): string {
|
|
109
|
+
return JSON.stringify(data, null, 2);
|
|
110
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { analyze } from "./analyze.ts";
|
|
3
|
+
import { windowBounds } from "./derive.ts";
|
|
4
|
+
import { scan } from "./scan.ts";
|
|
5
|
+
import type { Day, Transcript } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export type ReportOptions = { projects: string; to: string; days: number };
|
|
8
|
+
|
|
9
|
+
// The shell's seam: lists and reads files, then hands the text to the pure core.
|
|
10
|
+
export async function report(o: ReportOptions): Promise<Day[]> {
|
|
11
|
+
const window = { to: o.to, days: o.days };
|
|
12
|
+
const paths = await scan(o.projects, windowBounds(window).cutoffMs);
|
|
13
|
+
const transcripts: Transcript[] = [];
|
|
14
|
+
for (const path of paths) {
|
|
15
|
+
try { transcripts.push({ path, text: await readFile(path, "utf8") }); } catch { /* vanished or unreadable: skip */ }
|
|
16
|
+
}
|
|
17
|
+
return analyze(transcripts, window);
|
|
18
|
+
}
|
package/src/scan.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Lists transcript files worth reading. Subagent transcripts live under a `subagents`
|
|
5
|
+
// directory and are the parent agent's conversation, not the human's; a file whose mtime
|
|
6
|
+
// is before the cutoff cannot hold events the report needs.
|
|
7
|
+
export async function scan(projects: string, cutoffMs: number): Promise<string[]> {
|
|
8
|
+
try {
|
|
9
|
+
if (!(await stat(projects)).isDirectory()) throw new Error("not a directory");
|
|
10
|
+
} catch {
|
|
11
|
+
// No path in the message: it may be a value the user typed, or the
|
|
12
|
+
// homedir-derived default, and the CLI must never print a filesystem path.
|
|
13
|
+
throw new Error("projects directory not found (pass --projects <dir>)");
|
|
14
|
+
}
|
|
15
|
+
const out: string[] = [];
|
|
16
|
+
await walk(projects, cutoffMs, out);
|
|
17
|
+
return out.sort();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// One directory at a time, so one unreadable directory or a symlink loop costs only
|
|
21
|
+
// that directory. A symlink is not followed into: Claude Code never writes one, and
|
|
22
|
+
// following it is how a loop or a stray link to $HOME would get in.
|
|
23
|
+
async function walk(dir: string, cutoffMs: number, out: string[]): Promise<void> {
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
27
|
+
} catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
const full = join(dir, entry.name);
|
|
32
|
+
if (entry.isDirectory()) {
|
|
33
|
+
if (entry.name !== "subagents") await walk(full, cutoffMs, out);
|
|
34
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
35
|
+
try {
|
|
36
|
+
if ((await stat(full)).mtimeMs >= cutoffMs) out.push(full);
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/score.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Level, Metrics, Parts, Score } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
// Calibration lives here and nowhere else. A change is one diff plus a CHANGELOG line.
|
|
4
|
+
// Points of 100, kept as integers so 0.5 sums stay exact in floating point.
|
|
5
|
+
// Norms are the p90 of two weeks of real data on two machines (see CHANGELOG).
|
|
6
|
+
export const WEIGHTS = { parallel: 25, pace: 15, supervision: 30, reading: 10, streak: 10, late: 10 } as const;
|
|
7
|
+
export const NORMS = { parallelSpan: 4, pacePerHour: 20, supervisionPerHour: 45, decisionWeight: 3, readingTokens: 80_000, streakMin: 120 } as const;
|
|
8
|
+
export const LEVELS: readonly { max: number; level: Level }[] = [
|
|
9
|
+
{ max: 29, level: "Calm" },
|
|
10
|
+
{ max: 59, level: "Warming" },
|
|
11
|
+
{ max: 84, level: "Heating" },
|
|
12
|
+
{ max: 100, level: "Fried" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const clamp01 = (x: number): number => Math.min(1, Math.max(0, x));
|
|
16
|
+
|
|
17
|
+
export function levelOf(index: number): Level {
|
|
18
|
+
for (const l of LEVELS) if (index <= l.max) return l.level;
|
|
19
|
+
return "Fried";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function score(m: Metrics): Score | null {
|
|
23
|
+
if (m.sessions === 0) return null;
|
|
24
|
+
const parallel = clamp01((m.sessions - 1) / NORMS.parallelSpan);
|
|
25
|
+
const pace = clamp01(m.prompts / NORMS.pacePerHour);
|
|
26
|
+
// Supervision is one load: an explicit decision costs `decisionWeight` times what
|
|
27
|
+
// reacting to one agent report or one session hop costs, and the three share a norm.
|
|
28
|
+
const supervision = clamp01(
|
|
29
|
+
(NORMS.decisionWeight * m.decisions + m.reports + m.contextSwitches) / NORMS.supervisionPerHour,
|
|
30
|
+
);
|
|
31
|
+
const reading = clamp01(m.outputTokens / NORMS.readingTokens);
|
|
32
|
+
const streak = clamp01(m.streakMin / NORMS.streakMin);
|
|
33
|
+
const late = m.lateNight ? 1 : 0;
|
|
34
|
+
|
|
35
|
+
const raw = {
|
|
36
|
+
parallel: WEIGHTS.parallel * parallel,
|
|
37
|
+
pace: WEIGHTS.pace * pace,
|
|
38
|
+
supervision: WEIGHTS.supervision * supervision,
|
|
39
|
+
reading: WEIGHTS.reading * reading,
|
|
40
|
+
streak: WEIGHTS.streak * streak,
|
|
41
|
+
late: WEIGHTS.late * late,
|
|
42
|
+
};
|
|
43
|
+
// parts are the same raw weighted points, rounded to one decimal for display only.
|
|
44
|
+
const parts: Parts = {
|
|
45
|
+
parallel: Math.round(raw.parallel * 10) / 10,
|
|
46
|
+
pace: Math.round(raw.pace * 10) / 10,
|
|
47
|
+
supervision: Math.round(raw.supervision * 10) / 10,
|
|
48
|
+
reading: Math.round(raw.reading * 10) / 10,
|
|
49
|
+
streak: Math.round(raw.streak * 10) / 10,
|
|
50
|
+
late: Math.round(raw.late * 10) / 10,
|
|
51
|
+
};
|
|
52
|
+
// index is rounded once, from the unrounded raw points, so per-component
|
|
53
|
+
// rounding (parts, above) can never tip it across a boundary raw didn't.
|
|
54
|
+
const index = Math.round(
|
|
55
|
+
raw.parallel + raw.pace + raw.supervision + raw.reading + raw.streak + raw.late,
|
|
56
|
+
);
|
|
57
|
+
return { index, level: levelOf(index), parts };
|
|
58
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type EventKind = "prompt" | "report" | "output" | "interrupt" | "reject" | "question" | "plan_review" | "mode_change" | "activity";
|
|
2
|
+
export type Event = { ts: number; sessionId: string; kind: EventKind; tokens?: number };
|
|
3
|
+
export type Transcript = { path: string; text: string };
|
|
4
|
+
export type Window = { to: string; days: number }; // to = "YYYY-MM-DD" local
|
|
5
|
+
export type Metrics = {
|
|
6
|
+
sessions: number; prompts: number; reports: number; outputTokens: number; interrupts: number; rejects: number; questions: number;
|
|
7
|
+
plans: number; modeSwitches: number; decisions: number; contextSwitches: number;
|
|
8
|
+
activeMin: number; streakMin: number; lateNight: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type Level = "Calm" | "Warming" | "Heating" | "Fried";
|
|
11
|
+
export type Parts = { parallel: number; pace: number; supervision: number; reading: number; streak: number; late: number }; // weighted points, sum ≈ index
|
|
12
|
+
export type Score = { index: number; level: Level; parts: Parts };
|
|
13
|
+
export type HourBucket = Metrics & { hour: number; score: Score | null };
|
|
14
|
+
export type Totals = { prompts: number; reports: number; outputTokens: number; interrupts: number; rejects: number; questions: number; plans: number; modeSwitches: number; decisions: number; contextSwitches: number; maxSessions: number };
|
|
15
|
+
export type Day = { date: string; peak: number | null; mean: number | null; activeMin: number; totals: Totals; buckets: HourBucket[] };
|