@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/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
- // A window that includes today reads a transcript Claude Code is still appending
15
- // to; this line marks the snapshot time on the one day still open, so two runs
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
- // 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.
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
- return `${label(d.date).padEnd(12)}${cells}${String(d.peak ?? "-").padStart(6)}${hm(d.activeMin).padStart(8)}`;
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 would cancel the line's outer dim, so in
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
- // Counts go through formatCount so a very active window (999 999 999 prompts) still
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
- // 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.
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
- // Event columns: left out of the table for a day where every active bucket reads
77
- // zero (a quiet day is mostly zeros, and the eye hunts for the non-zero cell).
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
- // No active bucket: just the full header, plus the snapshot line if this
93
- // quiet day is still open — otherwise a run at 09:00 and one at 18:00 on an
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 because no other column can contain a level word (Calm/Warming/Heating/Fried);
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
- // Lists transcript files worth reading. Subagent transcripts live under a `subagents`
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: it may be a value the user typed, or the
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 at a time, so one unreadable directory or a symlink loop below the
26
- // root costs only that directory. A symlink is not followed into: Claude Code never
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 is a hint, not the truth: a transcript synced from another machine, restored by a
45
- // tool that rewrites times, or written under clock skew can be older by mtime than the
46
- // records inside it. For a file mtime would drop, the last "timestamp" in its final 4 KB
47
- // decides. The tail is matched for that one field and discarded; nothing else is read.
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. 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).
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: 120 } as const;
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
- // 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.
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
- // parts are the same raw weighted points, rounded to one decimal for display only.
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
- // 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.
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 content, as data: today's load reduced to the nine values a
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
- // `hour` is the local hour containing `now`; index, level and streakMin describe
21
- // that hour's bucket, peak and activeMin the whole day. A day the report did not
22
- // mark as open has no asOf, so `now` stands in and the function stays total.
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 bucket = day.buckets[hour];
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: bucket?.score?.index ?? null,
32
- level: bucket?.score?.level ?? null,
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: bucket?.streakMin ?? 0,
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. Shell, not core: it owns the
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
- // Write `line` to ~/.claude/zapara/status.json atomically: an exclusively
10
- // created temporary file in the same directory, then a rename over the target.
11
- // A reader sees the old file or the new one, never a partial line, and never a
12
- // file written through a symlink someone left at the target: `rename` replaces
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 modes below are a request the umask narrows, so the umask becomes ours
19
- // before anything is created: zapara is a short-lived CLI and the status file
20
- // is the only thing it writes from here on. Setting it, rather than widening
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
- // The name is unique to this run: two detached runs may write at once, each
28
- // renames its own complete file and the last rename wins. So this run opens
29
- // its temp file with `wx` (an existing file or symlink at the name is an
30
- // error, never followed) and never opens, reuses or deletes one it did not
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 a directory that already exists exactly as it was, so a
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 HourBucket = Metrics & { hour: number; score: Score | null };
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 is set only on the day that is still open when the report runs (the day
16
- // containing `now`), ISO 8601 UTC.
17
- export type Day = { date: string; peak: number | null; mean: number | null; activeMin: number; totals: Totals; buckets: HourBucket[]; asOf?: string };
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 };