@drakulavich/zapara 0.3.1 → 0.5.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 +24 -0
- package/README.md +66 -53
- package/package.json +1 -1
- package/src/analyze.ts +3 -4
- package/src/card.ts +8 -11
- package/src/cardhtml.ts +6 -9
- package/src/derive.ts +117 -67
- package/src/format.ts +2 -6
- package/src/image.ts +18 -14
- package/src/index.ts +15 -31
- package/src/parse.ts +30 -21
- package/src/render.ts +15 -20
- package/src/report.ts +0 -1
- package/src/scan.ts +8 -13
- package/src/score.ts +6 -9
- package/src/status.ts +10 -13
- package/src/statusfile.ts +13 -26
- package/src/types.ts +8 -5
package/src/render.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatCount, plural } from "./format.ts";
|
|
2
|
+
import { levelOf } from "./score.ts";
|
|
2
3
|
import type { Day, HourBucket, Level } from "./types.ts";
|
|
3
4
|
|
|
4
5
|
const GLYPH: Record<Level, string> = { Calm: "░", Warming: "▒", Heating: "▓", Fried: "█" };
|
|
@@ -11,9 +12,8 @@ const paint = (s: string, level: Level, color: boolean) => (color ? `\x1b[${ANSI
|
|
|
11
12
|
const dim = (s: string, color: boolean) => (color ? `\x1b[2m${s}\x1b[0m` : s);
|
|
12
13
|
const hm = (min: number) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, "0")}`;
|
|
13
14
|
const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// minutes apart are explained rather than silently disagreeing.
|
|
15
|
+
// Marks the snapshot time on the open day, so two runs minutes apart are
|
|
16
|
+
// explained rather than silently disagreeing.
|
|
17
17
|
const snapshotLine = (d: Day | undefined, color: boolean): string[] =>
|
|
18
18
|
d?.asOf ? [dim(` as of ${hhmm(new Date(d.asOf))}, this hour is still running`, color)] : [];
|
|
19
19
|
const label = (date: string) => {
|
|
@@ -22,14 +22,15 @@ const label = (date: string) => {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
export function renderWeek(days: Day[], color: boolean): string {
|
|
25
|
-
//
|
|
25
|
+
// 12-char label, 24 cells of 3 chars, peak in 6, active in 8.
|
|
26
26
|
const header = " " + Array.from({ length: 24 }, (_, h) => `${String(h).padStart(2, "0")} `).join("") + " peak active";
|
|
27
27
|
const rows = days.map((d) => {
|
|
28
28
|
const cells = d.buckets.map((b) => ` ${b.score ? paint(GLYPH[b.score.level], b.score.level, color) : "·"} `).join("");
|
|
29
|
-
|
|
29
|
+
// Padding stays outside the paint so the escape codes add no width.
|
|
30
|
+
const peak = d.peak === null ? "-".padStart(6) : " ".repeat(6 - String(d.peak).length) + paint(String(d.peak), levelOf(d.peak), color);
|
|
31
|
+
return `${label(d.date).padEnd(12)}${cells}${peak}${hm(d.activeMin).padStart(8)}`;
|
|
30
32
|
});
|
|
31
|
-
// A painted glyph's own \x1b[0m
|
|
32
|
-
// color mode re-emit \x1b[2m right after it to keep the label dim too.
|
|
33
|
+
// A painted glyph's own \x1b[0m cancels the line's dim; re-emit it after.
|
|
33
34
|
const dimGlyph = (level: Level) => paint(GLYPH[level], level, color) + (color ? "\x1b[2m" : "");
|
|
34
35
|
const legend = dim(" " + LEVELS.map((l) => `${dimGlyph(l)} ${LEVEL_NAME[l]}`).join(" "), color);
|
|
35
36
|
const active = days.reduce((s, d) => s + d.activeMin, 0);
|
|
@@ -37,18 +38,16 @@ export function renderWeek(days: Day[], color: boolean): string {
|
|
|
37
38
|
const reports = days.reduce((s, d) => s + d.totals.reports, 0);
|
|
38
39
|
const decisions = days.reduce((s, d) => s + d.totals.decisions, 0);
|
|
39
40
|
const maxSessions = Math.max(0, ...days.map((d) => d.totals.maxSessions));
|
|
40
|
-
//
|
|
41
|
-
// fits inside the grid's 100 columns; hm(active) has no compact form, so it stays as is.
|
|
41
|
+
// Compact counts keep a very active window inside the grid's 100 columns.
|
|
42
42
|
const totals = dim(` ${hm(active)} active ${plural(prompts, "prompt")} ${plural(reports, "report")} ${plural(decisions, "decision")} ${plural(maxSessions, "session")} at once`, color);
|
|
43
43
|
return [header, ...rows, "", legend, totals, ...snapshotLine(days[days.length - 1], color)].join("\n");
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// Values at or above 1000 are shown as one decimal of a thousand (e.g. "41.2k"); smaller values print as-is.
|
|
47
46
|
const fmtTokens = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
48
47
|
|
|
49
48
|
type Col = [string, number, (b: HourBucket) => string];
|
|
50
49
|
|
|
51
|
-
//
|
|
50
|
+
// Hour left-aligned, level left-aligned after two spaces, the rest right-aligned.
|
|
52
51
|
const COLS: Col[] = [
|
|
53
52
|
["hour", 5, (b) => `${String(b.hour).padStart(2, "0")}:00`],
|
|
54
53
|
["index", 7, (b) => String(b.score?.index ?? "")],
|
|
@@ -73,10 +72,8 @@ const EXPLAIN: Col[] = [
|
|
|
73
72
|
["strk", 6, (b) => String(b.score?.parts.streak ?? "")],
|
|
74
73
|
["late", 6, (b) => String(b.score?.parts.late ?? "")],
|
|
75
74
|
];
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// The skeleton columns (hour, index, level, sess, prompts, streak, out-tok, and
|
|
79
|
-
// the six --explain parts) always show, so two days still line up.
|
|
75
|
+
// Left out of the table for a day where every active bucket reads zero; the
|
|
76
|
+
// other columns always show, so two days still line up.
|
|
80
77
|
const EVENT_COLS = new Set(["rep", "intr", "rej", "quest", "plan", "mode", "ctx-sw"]);
|
|
81
78
|
const FULL_NAME: Record<string, string> = {
|
|
82
79
|
rep: "reports", intr: "interrupts", rej: "rejects", quest: "questions",
|
|
@@ -89,9 +86,8 @@ export function renderDay(day: Day, opts: { explain: boolean; color: boolean }):
|
|
|
89
86
|
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();
|
|
90
87
|
|
|
91
88
|
const active = day.buckets.filter((b) => b.score !== null);
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// empty today would print the identical line.
|
|
89
|
+
// The snapshot line still shows on an empty open day, or a run at 09:00 and
|
|
90
|
+
// one at 18:00 would print the identical line.
|
|
95
91
|
if (active.length === 0) return [line(cols, cols.map(([name]) => name)), ...snapshotLine(day, opts.color)].join("\n");
|
|
96
92
|
|
|
97
93
|
const visible = cols.filter(([name, , f]) => !EVENT_COLS.has(name) || active.some((b) => f(b) !== "0"));
|
|
@@ -101,8 +97,7 @@ export function renderDay(day: Day, opts: { explain: boolean; color: boolean }):
|
|
|
101
97
|
const rows = active.map((b) => {
|
|
102
98
|
const cells = visible.map(([, , f]) => f(b));
|
|
103
99
|
const text = line(visible, cells);
|
|
104
|
-
// Safe only
|
|
105
|
-
// if one ever could, this would need to target the level column's slice, not a string search.
|
|
100
|
+
// Safe only while no other column can contain a level word.
|
|
106
101
|
return opts.color && b.score ? text.replace(b.score.level, paint(b.score.level, b.score.level, true)) : text;
|
|
107
102
|
});
|
|
108
103
|
const lines = [header, ...rows];
|
package/src/report.ts
CHANGED
|
@@ -6,7 +6,6 @@ import type { Day, Transcript } from "./types.ts";
|
|
|
6
6
|
|
|
7
7
|
export type ReportOptions = { projects: string; to: string; days: number; now?: Date };
|
|
8
8
|
|
|
9
|
-
// The shell's seam: lists and reads files, then hands the text to the pure core.
|
|
10
9
|
export async function report(o: ReportOptions): Promise<Day[]> {
|
|
11
10
|
const window = { to: o.to, days: o.days, now: o.now };
|
|
12
11
|
const paths = await scan(o.projects, windowBounds(window).cutoffMs);
|
package/src/scan.ts
CHANGED
|
@@ -2,12 +2,9 @@ import type { Dirent } from "node:fs";
|
|
|
2
2
|
import { open, readdir, stat } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
// directory and are the parent agent's conversation, not the human's; mtime is checked
|
|
7
|
-
// first, and for a file mtime would drop, the last timestamp in its tail decides.
|
|
5
|
+
// A `subagents` directory holds the parent agent's conversation, not the human's.
|
|
8
6
|
export async function scan(projects: string, cutoffMs: number): Promise<string[]> {
|
|
9
|
-
// No path in either message:
|
|
10
|
-
// homedir-derived default, and the CLI must never print a filesystem path.
|
|
7
|
+
// No path in either message: the CLI never prints one.
|
|
11
8
|
let root;
|
|
12
9
|
try {
|
|
13
10
|
if (!(await stat(projects)).isDirectory()) throw new Error("not a directory");
|
|
@@ -22,9 +19,8 @@ export async function scan(projects: string, cutoffMs: number): Promise<string[]
|
|
|
22
19
|
return out.sort();
|
|
23
20
|
}
|
|
24
21
|
|
|
25
|
-
// One directory
|
|
26
|
-
//
|
|
27
|
-
// writes one, and following it is how a loop or a stray link to $HOME would get in.
|
|
22
|
+
// One unreadable directory costs only itself. Symlinks are not followed: Claude
|
|
23
|
+
// Code never writes one, and following one is how a loop or $HOME would get in.
|
|
28
24
|
async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: string[]): Promise<void> {
|
|
29
25
|
for (const entry of entries) {
|
|
30
26
|
const full = join(dir, entry.name);
|
|
@@ -41,11 +37,10 @@ async function collect(dir: string, entries: Dirent[], cutoffMs: number, out: st
|
|
|
41
37
|
}
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
// mtime
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
const TAIL_BYTES = 4096;
|
|
40
|
+
// mtime can be older than the records inside (sync, restore, clock skew), so for
|
|
41
|
+
// a file mtime would drop, the last "timestamp" in its final 64 KB decides. 64 KB
|
|
42
|
+
// because the last record is often a big tool result with its timestamp in front.
|
|
43
|
+
const TAIL_BYTES = 65_536;
|
|
49
44
|
async function lastTimestampMs(path: string): Promise<number> {
|
|
50
45
|
const fh = await open(path, "r");
|
|
51
46
|
try {
|
package/src/score.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { Level, Metrics, Parts, Score } from "./types.ts";
|
|
2
2
|
|
|
3
|
-
// Calibration lives here and nowhere else.
|
|
4
|
-
//
|
|
5
|
-
// Norms are the p90 of two weeks of real data on two machines (see CHANGELOG).
|
|
3
|
+
// Calibration lives here and nowhere else. Integer points of 100, so 0.5 sums
|
|
4
|
+
// stay exact; norms are the p90 of two weeks on two machines (see CHANGELOG).
|
|
6
5
|
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:
|
|
6
|
+
export const NORMS = { parallelSpan: 4, pacePerHour: 20, supervisionPerHour: 45, decisionWeight: 3, readingTokens: 80_000, streakMin: 40 } as const;
|
|
8
7
|
export const LEVELS: readonly { max: number; level: Level }[] = [
|
|
9
8
|
{ max: 29, level: "Calm" },
|
|
10
9
|
{ max: 59, level: "Warming" },
|
|
@@ -23,8 +22,7 @@ export function score(m: Metrics): Score | null {
|
|
|
23
22
|
if (m.sessions === 0) return null;
|
|
24
23
|
const parallel = clamp01((m.sessions - 1) / NORMS.parallelSpan);
|
|
25
24
|
const pace = clamp01(m.prompts / NORMS.pacePerHour);
|
|
26
|
-
//
|
|
27
|
-
// reacting to one agent report or one session hop costs, and the three share a norm.
|
|
25
|
+
// A decision costs `decisionWeight` reports or session hops; the three share a norm.
|
|
28
26
|
const supervision = clamp01(
|
|
29
27
|
(NORMS.decisionWeight * m.decisions + m.reports + m.contextSwitches) / NORMS.supervisionPerHour,
|
|
30
28
|
);
|
|
@@ -40,7 +38,7 @@ export function score(m: Metrics): Score | null {
|
|
|
40
38
|
streak: WEIGHTS.streak * streak,
|
|
41
39
|
late: WEIGHTS.late * late,
|
|
42
40
|
};
|
|
43
|
-
//
|
|
41
|
+
// Rounded for display only.
|
|
44
42
|
const parts: Parts = {
|
|
45
43
|
parallel: Math.round(raw.parallel * 10) / 10,
|
|
46
44
|
pace: Math.round(raw.pace * 10) / 10,
|
|
@@ -49,8 +47,7 @@ export function score(m: Metrics): Score | null {
|
|
|
49
47
|
streak: Math.round(raw.streak * 10) / 10,
|
|
50
48
|
late: Math.round(raw.late * 10) / 10,
|
|
51
49
|
};
|
|
52
|
-
//
|
|
53
|
-
// rounding (parts, above) can never tip it across a boundary raw didn't.
|
|
50
|
+
// Rounded once from the raw points, so parts' rounding can never tip it across a boundary.
|
|
54
51
|
const index = Math.round(
|
|
55
52
|
raw.parallel + raw.pace + raw.supervision + raw.reading + raw.streak + raw.late,
|
|
56
53
|
);
|
package/src/status.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
+
import { GAP_MS } from "./derive.ts";
|
|
1
2
|
import type { Day, Level } from "./types.ts";
|
|
2
3
|
|
|
3
|
-
// The status file's
|
|
4
|
-
// status line needs. Core, not shell — this file never reads the clock, the
|
|
5
|
-
// environment or the file system, and never writes one; `now` arrives as an
|
|
6
|
-
// argument and `src/statusfile.ts` does the writing. The field order below is
|
|
7
|
-
// the file format (see the status-file design spec) and JSON.stringify keeps it.
|
|
4
|
+
// The status file's nine values. The field order is the file format.
|
|
8
5
|
export type Status = {
|
|
9
6
|
schema: 1;
|
|
10
7
|
asOf: string;
|
|
@@ -17,26 +14,26 @@ export type Status = {
|
|
|
17
14
|
streakMin: number;
|
|
18
15
|
};
|
|
19
16
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
17
|
+
// index and level are the day's `live` bucket, not the hour's: an hour's bucket
|
|
18
|
+
// is nearly empty just after the hour turns. streakMin is measured against `now`,
|
|
19
|
+
// not the bucket's: it must not reset on the hour or stop between two actions,
|
|
20
|
+
// and it is over once the last action is more than GAP_MS behind `now`.
|
|
23
21
|
export function statusOf(day: Day, now: Date): Status {
|
|
24
22
|
const hour = now.getHours();
|
|
25
|
-
const
|
|
23
|
+
const live = day.presence !== null && now.getTime() - Date.parse(day.presence.lastAt) <= GAP_MS;
|
|
26
24
|
return {
|
|
27
25
|
schema: 1,
|
|
28
26
|
asOf: day.asOf ?? now.toISOString(),
|
|
29
27
|
date: day.date,
|
|
30
28
|
hour,
|
|
31
|
-
index:
|
|
32
|
-
level:
|
|
29
|
+
index: day.live?.score?.index ?? null,
|
|
30
|
+
level: day.live?.score?.level ?? null,
|
|
33
31
|
peak: day.peak,
|
|
34
32
|
activeMin: day.activeMin,
|
|
35
|
-
streakMin:
|
|
33
|
+
streakMin: live ? Math.round((now.getTime() - Date.parse(day.presence!.streakStartAt)) / 60000) : 0,
|
|
36
34
|
};
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
// One line of JSON, the fields in the order declared above, newline at the end.
|
|
40
37
|
export function renderStatus(s: Status): string {
|
|
41
38
|
return JSON.stringify(s) + "\n";
|
|
42
39
|
}
|
package/src/statusfile.ts
CHANGED
|
@@ -1,41 +1,30 @@
|
|
|
1
|
-
// The only module that writes the status file.
|
|
2
|
-
// path, the directory, the temporary file, the rename and the modes. The line
|
|
3
|
-
// it is handed comes from the pure `src/status.ts`.
|
|
1
|
+
// The only module that writes the status file.
|
|
4
2
|
import { chmod, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
5
3
|
import { join } from "node:path";
|
|
6
4
|
|
|
7
5
|
const FAILED = "cannot write the status file";
|
|
8
6
|
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// the link. HOME comes from the env passed in rather than os.homedir(), so an
|
|
14
|
-
// empty HOME is the error the spec names instead of a silent fallback.
|
|
7
|
+
// Atomic: a temporary file in the same directory, then a rename over the
|
|
8
|
+
// target, so a reader never sees a partial line and a symlink at the target is
|
|
9
|
+
// replaced, not written through. HOME comes from `env`, not os.homedir(), so an
|
|
10
|
+
// empty HOME is an error rather than a silent fallback.
|
|
15
11
|
export async function writeStatus(line: string, env: NodeJS.ProcessEnv): Promise<void> {
|
|
16
12
|
const home = env.HOME;
|
|
17
13
|
if (!home) throw new Error(FAILED);
|
|
18
|
-
// The
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// each path afterwards, is what makes `mkdir` and `open` come out exactly
|
|
22
|
-
// 0700 and 0600 on the first try — and it closes the window where one run has
|
|
23
|
-
// created `~/.claude` too narrow to enter and a second run, seeing a parent
|
|
24
|
-
// that already exists, fails inside it.
|
|
14
|
+
// The umask narrows the modes below; set it first so `mkdir` and `open` come
|
|
15
|
+
// out 0700 and 0600 on the first try, with no window where a second run finds
|
|
16
|
+
// a parent it cannot enter. Nothing else is written from here on.
|
|
25
17
|
process.umask(0o077);
|
|
26
18
|
const dir = join(home, ".claude", "zapara");
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// create — from the outside a crashed run's leftover and a slow run's file
|
|
32
|
-
// in flight look the same, and removing the second would break that promise.
|
|
19
|
+
// Unique per run: concurrent runs each rename their own complete file. Opened
|
|
20
|
+
// with `wx` so a file or symlink already at the name is an error, and never
|
|
21
|
+
// reused or deleted if not ours: a crashed run's leftover and a slow run's
|
|
22
|
+
// file in flight look the same from outside.
|
|
33
23
|
const tmp = join(dir, `status.json.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`);
|
|
34
24
|
let created = false;
|
|
35
25
|
try {
|
|
36
26
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
37
|
-
// `mkdir` leaves
|
|
38
|
-
// status directory someone else created loose is tightened here.
|
|
27
|
+
// `mkdir` leaves an existing directory as it was; tighten it.
|
|
39
28
|
await chmod(dir, 0o700);
|
|
40
29
|
const handle = await open(tmp, "wx", 0o600);
|
|
41
30
|
created = true;
|
|
@@ -43,8 +32,6 @@ export async function writeStatus(line: string, env: NodeJS.ProcessEnv): Promise
|
|
|
43
32
|
await chmod(tmp, 0o600);
|
|
44
33
|
await rename(tmp, join(dir, "status.json"));
|
|
45
34
|
} catch {
|
|
46
|
-
// Only this run's own file, and a failure to remove it changes nothing:
|
|
47
|
-
// the error below is what the caller acts on either way.
|
|
48
35
|
if (created) await unlink(tmp).catch(() => {});
|
|
49
36
|
throw new Error(FAILED);
|
|
50
37
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type EventKind = "prompt" | "report" | "output" | "interrupt" | "reject" | "question" | "plan_review" | "mode_change" | "activity";
|
|
1
|
+
export type EventKind = "prompt" | "report" | "output" | "interrupt" | "reject" | "answer" | "question" | "plan_review" | "mode_change" | "activity";
|
|
2
2
|
export type Event = { ts: number; sessionId: string; kind: EventKind; tokens?: number };
|
|
3
3
|
export type Transcript = { path: string; text: string };
|
|
4
4
|
export type Window = { to: string; days: number; now?: Date }; // to = "YYYY-MM-DD" local
|
|
@@ -10,8 +10,11 @@ export type Metrics = {
|
|
|
10
10
|
export type Level = "Calm" | "Warming" | "Heating" | "Fried";
|
|
11
11
|
export type Parts = { parallel: number; pace: number; supervision: number; reading: number; streak: number; late: number }; // weighted points, sum ≈ index
|
|
12
12
|
export type Score = { index: number; level: Level; parts: Parts };
|
|
13
|
-
export type
|
|
13
|
+
export type LiveBucket = Metrics & { score: Score | null };
|
|
14
|
+
export type HourBucket = LiveBucket & { hour: number };
|
|
14
15
|
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
|
-
// asOf
|
|
16
|
-
//
|
|
17
|
-
|
|
16
|
+
// `asOf` and `live` exist only on the day containing `now`: `live` is the sixty
|
|
17
|
+
// minutes `(asOf − 60 min, asOf]`, by the hour bucket's rule, look-back included.
|
|
18
|
+
// `presence` is the day's last human action and the start of its streak, which
|
|
19
|
+
// may lie on an earlier day; instants are ISO 8601 UTC.
|
|
20
|
+
export type Day = { date: string; peak: number | null; mean: number | null; activeMin: number; presence: { lastAt: string; streakStartAt: string } | null; totals: Totals; buckets: HourBucket[]; asOf?: string; live?: LiveBucket };
|