@nredd/pi-session-kit 0.0.0-stage → 0.3.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/LICENSE +18 -0
- package/README.md +167 -2
- package/dist/pi-stats-export.mjs +657 -0
- package/docs/stats-snapshots.md +122 -0
- package/package.json +45 -4
- package/src/child.ts +79 -0
- package/src/config.ts +64 -0
- package/src/diff/component.ts +214 -0
- package/src/diff/git.ts +289 -0
- package/src/diff/index.ts +65 -0
- package/src/diff/parse.ts +180 -0
- package/src/diff/view.ts +98 -0
- package/src/goal/index.ts +343 -0
- package/src/goal/state.ts +270 -0
- package/src/goal/subagents.ts +77 -0
- package/src/index.ts +31 -0
- package/src/loop/index.ts +151 -0
- package/src/loop/state.ts +107 -0
- package/src/monitor/README.md +12 -0
- package/src/monitor/coalescer.ts +75 -0
- package/src/monitor/index.ts +213 -0
- package/src/monitor/manager.ts +322 -0
- package/src/monitor/matcher.ts +113 -0
- package/src/monitor/messages.ts +93 -0
- package/src/monitor/output.ts +41 -0
- package/src/monitor/pidfiles.ts +410 -0
- package/src/monitor/process.ts +124 -0
- package/src/monitor/ring.ts +52 -0
- package/src/monitor/splitter.ts +57 -0
- package/src/monitor/types.ts +35 -0
- package/src/monitor/validate.ts +59 -0
- package/src/monitor/widget.ts +75 -0
- package/src/output.ts +25 -0
- package/src/rules/index.ts +94 -0
- package/src/rules/logic.ts +96 -0
- package/src/shared.ts +177 -0
- package/src/stats/aggregate.ts +287 -0
- package/src/stats/chart.ts +247 -0
- package/src/stats/cli.ts +50 -0
- package/src/stats/component.ts +496 -0
- package/src/stats/days.ts +46 -0
- package/src/stats/format.ts +49 -0
- package/src/stats/heatmap.ts +98 -0
- package/src/stats/index.ts +81 -0
- package/src/stats/library.ts +4 -0
- package/src/stats/load.ts +512 -0
- package/src/stats/palette.ts +22 -0
- package/src/stats/snapshot.ts +497 -0
- package/src/titles/aliases.ts +20 -0
- package/src/titles/index.ts +82 -0
- package/src/titles/logic.ts +160 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure aggregation for `/stats`: date ranges, provider/model filters, per-day series and the
|
|
3
|
+
* Overview numbers. "Tokens" is all throughput, input + output + cache read + cache write,
|
|
4
|
+
* everywhere. Anthropic reports almost every prompt token as a cache read or write (its `input` is
|
|
5
|
+
* single digits per call) while Codex reports them as `input`, so anything less badly undercounts
|
|
6
|
+
* agentic usage, and cache reads are what rate limits see.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { addDays, dayKey, dayRange, daysBetween } from "./days.ts";
|
|
10
|
+
import type { Call, Session } from "./load.ts";
|
|
11
|
+
|
|
12
|
+
export type Range = "all" | "7d" | "30d" | "5h" | "24h";
|
|
13
|
+
export const RANGES: readonly Range[] = ["all", "7d", "30d", "5h", "24h"];
|
|
14
|
+
export const RANGE_LABEL: Record<Range, string> = { all: "All", "7d": "7d", "30d": "30d", "5h": "5h", "24h": "24h" };
|
|
15
|
+
export const HOUR_MS = 3600000;
|
|
16
|
+
export const isHourly = (range: Range): range is "5h" | "24h" => range === "5h" || range === "24h";
|
|
17
|
+
export const hourlyStart = (range: "5h" | "24h", now: number): number => now - (range === "5h" ? 5 : 24) * HOUR_MS;
|
|
18
|
+
|
|
19
|
+
/** `{ kind: "all" }`, one provider, or one `provider/model`. */
|
|
20
|
+
export type Filter = { kind: "all" } | { kind: "provider"; provider: string } | { kind: "model"; name: string };
|
|
21
|
+
|
|
22
|
+
export interface ModelTotals {
|
|
23
|
+
/** `provider/model` */
|
|
24
|
+
name: string;
|
|
25
|
+
provider: string;
|
|
26
|
+
model: string;
|
|
27
|
+
input: number;
|
|
28
|
+
output: number;
|
|
29
|
+
cacheRead: number;
|
|
30
|
+
cacheWrite: number;
|
|
31
|
+
cost: number;
|
|
32
|
+
calls: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DayPoint {
|
|
36
|
+
day: string;
|
|
37
|
+
/** Tokens per series key (provider, or `provider/model`). */
|
|
38
|
+
byKey: Map<string, number>;
|
|
39
|
+
total: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Overview {
|
|
43
|
+
totalTokens: number;
|
|
44
|
+
cacheRead: number;
|
|
45
|
+
cacheWrite: number;
|
|
46
|
+
cost: number;
|
|
47
|
+
favorite?: string;
|
|
48
|
+
sessions: number;
|
|
49
|
+
longestSessionMs: number;
|
|
50
|
+
activeDays: number;
|
|
51
|
+
rangeDays: number;
|
|
52
|
+
mostActiveDay?: string;
|
|
53
|
+
longestStreak: number;
|
|
54
|
+
currentStreak: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const tokensOf = (c: Pick<Call, "input" | "output" | "cacheRead" | "cacheWrite">): number =>
|
|
58
|
+
c.input + c.output + c.cacheRead + c.cacheWrite;
|
|
59
|
+
/** What `tokensOf` counts, shown next to every token total. */
|
|
60
|
+
export const TOKENS_NOTE = "(in + out + cache)";
|
|
61
|
+
export const modelName = (c: Pick<Call, "provider" | "model">): string => `${c.provider}/${c.model}`;
|
|
62
|
+
|
|
63
|
+
export function matches(filter: Filter, c: Pick<Call, "provider" | "model">): boolean {
|
|
64
|
+
if (filter.kind === "all") return true;
|
|
65
|
+
if (filter.kind === "provider") return c.provider === filter.provider;
|
|
66
|
+
return modelName(c) === filter.name;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function filterLabel(filter: Filter): string {
|
|
70
|
+
return filter.kind === "all" ? "All models" : filter.kind === "provider" ? filter.provider : filter.name;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** First day of the range; `undefined` for all time. */
|
|
74
|
+
export function rangeStart(range: Range, today: string): string | undefined {
|
|
75
|
+
if (range === "7d") return addDays(today, -6);
|
|
76
|
+
if (range === "30d") return addDays(today, -29);
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether `ms` falls in the range ending today. Days after today (clock skew) are in no range. */
|
|
81
|
+
export function inRange(range: Range, today: string, ms: number, now = Date.now()): boolean {
|
|
82
|
+
if (ms > now) return false;
|
|
83
|
+
if (isHourly(range)) return ms >= hourlyStart(range, now);
|
|
84
|
+
const day = dayKey(ms);
|
|
85
|
+
const start = rangeStart(range, today);
|
|
86
|
+
return day <= today && (start === undefined || day >= start);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** All, then providers by tokens, then every model by tokens. */
|
|
90
|
+
export function filterCycle(calls: readonly Call[]): Filter[] {
|
|
91
|
+
const byProvider = new Map<string, number>();
|
|
92
|
+
const byModel = new Map<string, number>();
|
|
93
|
+
for (const c of calls) {
|
|
94
|
+
byProvider.set(c.provider, (byProvider.get(c.provider) ?? 0) + tokensOf(c));
|
|
95
|
+
byModel.set(modelName(c), (byModel.get(modelName(c)) ?? 0) + tokensOf(c));
|
|
96
|
+
}
|
|
97
|
+
const desc = (m: Map<string, number>) =>
|
|
98
|
+
[...m].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([k]) => k);
|
|
99
|
+
return [
|
|
100
|
+
{ kind: "all" },
|
|
101
|
+
...desc(byProvider).map((provider): Filter => ({ kind: "provider", provider })),
|
|
102
|
+
...desc(byModel).map((name): Filter => ({ kind: "model", name })),
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Per-model totals, by tokens descending. */
|
|
107
|
+
export function modelTotals(calls: readonly Call[]): ModelTotals[] {
|
|
108
|
+
const map = new Map<string, ModelTotals>();
|
|
109
|
+
for (const c of calls) {
|
|
110
|
+
const name = modelName(c);
|
|
111
|
+
let t = map.get(name);
|
|
112
|
+
if (!t) {
|
|
113
|
+
t = {
|
|
114
|
+
name,
|
|
115
|
+
provider: c.provider,
|
|
116
|
+
model: c.model,
|
|
117
|
+
input: 0,
|
|
118
|
+
output: 0,
|
|
119
|
+
cacheRead: 0,
|
|
120
|
+
cacheWrite: 0,
|
|
121
|
+
cost: 0,
|
|
122
|
+
calls: 0,
|
|
123
|
+
};
|
|
124
|
+
map.set(name, t);
|
|
125
|
+
}
|
|
126
|
+
t.input += c.input;
|
|
127
|
+
t.output += c.output;
|
|
128
|
+
t.cacheRead += c.cacheRead;
|
|
129
|
+
t.cacheWrite += c.cacheWrite;
|
|
130
|
+
t.cost += c.cost;
|
|
131
|
+
t.calls++;
|
|
132
|
+
}
|
|
133
|
+
return [...map.values()].sort((a, b) => tokensOf(b) - tokensOf(a) || a.name.localeCompare(b.name));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One chart line: a provider or a `provider/model`, with its tokens in scope. */
|
|
137
|
+
export interface SeriesKey {
|
|
138
|
+
key: string;
|
|
139
|
+
tokens: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* What the chart splits by: providers under the "all" filter, models otherwise. Largest first,
|
|
144
|
+
* at most `limit`.
|
|
145
|
+
*/
|
|
146
|
+
export function chartSeries(
|
|
147
|
+
filter: Filter,
|
|
148
|
+
calls: readonly Call[],
|
|
149
|
+
limit: number,
|
|
150
|
+
): { by: "provider" | "model"; keys: SeriesKey[] } {
|
|
151
|
+
const by = filter.kind === "all" ? "provider" : "model";
|
|
152
|
+
const keyOf = by === "provider" ? (c: Call) => c.provider : modelName;
|
|
153
|
+
const sums = new Map<string, number>();
|
|
154
|
+
for (const c of calls) sums.set(keyOf(c), (sums.get(keyOf(c)) ?? 0) + tokensOf(c));
|
|
155
|
+
const keys = [...sums]
|
|
156
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
157
|
+
.slice(0, limit)
|
|
158
|
+
.map(([key, tokens]) => ({ key, tokens }));
|
|
159
|
+
return { by, keys };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Tokens per local day. */
|
|
163
|
+
export function dailyTotals(calls: readonly Call[]): Map<string, number> {
|
|
164
|
+
const out = new Map<string, number>();
|
|
165
|
+
for (const c of calls) {
|
|
166
|
+
const day = dayKey(c.ms);
|
|
167
|
+
out.set(day, (out.get(day) ?? 0) + tokensOf(c));
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One point per day from `from` to `to` inclusive, zero-filled, split by `keyOf`. */
|
|
173
|
+
export function dailySeries(
|
|
174
|
+
calls: readonly Call[],
|
|
175
|
+
from: string,
|
|
176
|
+
to: string,
|
|
177
|
+
keyOf: (c: Call) => string = modelName,
|
|
178
|
+
): DayPoint[] {
|
|
179
|
+
const points = dayRange(from, to).map((day): DayPoint => ({ day, byKey: new Map(), total: 0 }));
|
|
180
|
+
const index = new Map(points.map((p, i) => [p.day, i]));
|
|
181
|
+
for (const c of calls) {
|
|
182
|
+
const p = points[index.get(dayKey(c.ms)) ?? -1];
|
|
183
|
+
if (!p) continue;
|
|
184
|
+
const key = keyOf(c);
|
|
185
|
+
p.byKey.set(key, (p.byKey.get(key) ?? 0) + tokensOf(c));
|
|
186
|
+
p.total += tokensOf(c);
|
|
187
|
+
}
|
|
188
|
+
return points;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Viewer-local labels include UTC offset, distinguishing repeated DST hours. */
|
|
192
|
+
export function hourLabel(ms: number): string {
|
|
193
|
+
const date = new Date(ms);
|
|
194
|
+
const offset = -date.getTimezoneOffset();
|
|
195
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
196
|
+
const abs = Math.abs(offset);
|
|
197
|
+
return `${date.getMonth() + 1}/${date.getDate()} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")} UTC${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One-hour UTC buckets; partial first/last buckets, zero-filled, original timestamps only. */
|
|
201
|
+
export function hourlySeries(
|
|
202
|
+
calls: readonly Call[],
|
|
203
|
+
range: "5h" | "24h",
|
|
204
|
+
now: number,
|
|
205
|
+
keyOf: (c: Call) => string = modelName,
|
|
206
|
+
): DayPoint[] {
|
|
207
|
+
const start = hourlyStart(range, now);
|
|
208
|
+
const from = Math.floor(start / HOUR_MS) * HOUR_MS;
|
|
209
|
+
const to = Math.floor(now / HOUR_MS) * HOUR_MS;
|
|
210
|
+
const points: DayPoint[] = [];
|
|
211
|
+
for (let ms = from; ms <= to; ms += HOUR_MS)
|
|
212
|
+
points.push({
|
|
213
|
+
day: `${hourLabel(ms)}${ms < start || ms + HOUR_MS > now ? " (partial)" : ""}`,
|
|
214
|
+
byKey: new Map(),
|
|
215
|
+
total: 0,
|
|
216
|
+
});
|
|
217
|
+
for (const c of calls) {
|
|
218
|
+
if (c.ms < start || c.ms > now) continue;
|
|
219
|
+
const p = points[Math.floor((c.ms - from) / HOUR_MS)];
|
|
220
|
+
if (!p) continue;
|
|
221
|
+
const key = keyOf(c);
|
|
222
|
+
p.byKey.set(key, (p.byKey.get(key) ?? 0) + tokensOf(c));
|
|
223
|
+
p.total += tokensOf(c);
|
|
224
|
+
}
|
|
225
|
+
return points;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Longest run of consecutive active days, and the run ending today or yesterday. Future days are ignored. */
|
|
229
|
+
export function streaks(activeDays: Iterable<string>, today: string): { longest: number; current: number } {
|
|
230
|
+
const days = [...new Set(activeDays)].filter((day) => day <= today).sort();
|
|
231
|
+
let longest = 0;
|
|
232
|
+
let run = 0;
|
|
233
|
+
let prev: string | undefined;
|
|
234
|
+
for (const day of days) {
|
|
235
|
+
run = prev !== undefined && daysBetween(prev, day) === 1 ? run + 1 : 1;
|
|
236
|
+
longest = Math.max(longest, run);
|
|
237
|
+
prev = day;
|
|
238
|
+
}
|
|
239
|
+
const last = days[days.length - 1];
|
|
240
|
+
const current = last !== undefined && daysBetween(last, today) <= 1 ? run : 0;
|
|
241
|
+
return { longest, current };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Overview numbers for calls already filtered by model and range. `Sessions` and the longest session
|
|
246
|
+
* count only sessions owning one of those calls. Streaks come from `allDays` (the all-time active days
|
|
247
|
+
* under the same model filter), like Claude Code: a streak is not a range stat.
|
|
248
|
+
*/
|
|
249
|
+
export function overview(
|
|
250
|
+
calls: readonly Call[],
|
|
251
|
+
sessions: readonly Session[],
|
|
252
|
+
range: Range,
|
|
253
|
+
today: string,
|
|
254
|
+
allDays: Iterable<string>,
|
|
255
|
+
): Overview {
|
|
256
|
+
const totals = modelTotals(calls);
|
|
257
|
+
const daily = dailyTotals(calls);
|
|
258
|
+
let mostActiveDay: string | undefined;
|
|
259
|
+
let best = -1;
|
|
260
|
+
for (const [day, tokens] of daily) {
|
|
261
|
+
if (tokens > best) {
|
|
262
|
+
best = tokens;
|
|
263
|
+
mostActiveDay = day;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const start = rangeStart(range, today);
|
|
267
|
+
const firstDay = calls.length > 0 ? dayKey(calls[0]?.ms ?? 0) : today;
|
|
268
|
+
const rangeDays = start === undefined ? daysBetween(firstDay, today) + 1 : daysBetween(start, today) + 1;
|
|
269
|
+
// A session is in scope when it owns at least one call that survived the model and range filters.
|
|
270
|
+
const owners = new Set(calls.map((c) => c.session));
|
|
271
|
+
const inScope = sessions.filter((s) => owners.has(s.path));
|
|
272
|
+
const { longest, current } = streaks(allDays, today);
|
|
273
|
+
return {
|
|
274
|
+
totalTokens: totals.reduce((n, t) => n + tokensOf(t), 0),
|
|
275
|
+
cacheRead: totals.reduce((n, t) => n + t.cacheRead, 0),
|
|
276
|
+
cacheWrite: totals.reduce((n, t) => n + t.cacheWrite, 0),
|
|
277
|
+
cost: totals.reduce((n, t) => n + t.cost, 0),
|
|
278
|
+
favorite: totals[0]?.name,
|
|
279
|
+
sessions: inScope.length,
|
|
280
|
+
longestSessionMs: inScope.reduce((n, s) => Math.max(n, s.endMs - s.startMs), 0),
|
|
281
|
+
activeDays: daily.size,
|
|
282
|
+
rangeDays: Math.max(1, rangeDays),
|
|
283
|
+
mostActiveDay,
|
|
284
|
+
longestStreak: longest,
|
|
285
|
+
currentStreak: current,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Tokens per day" line chart: an asciichart-style plot (`╭╮╰╯│─`) with nice y-axis ticks and
|
|
3
|
+
* date labels. Pure: colours go through a `paint` callback so tests can render plain text.
|
|
4
|
+
*
|
|
5
|
+
* Days are bucketed (1, 2, 3, 7, 14 or 30 days) so the whole range fits the width, then sampled
|
|
6
|
+
* with linear interpolation across every column so a short range draws a smooth curve rather
|
|
7
|
+
* than a staircase.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { addDays, daysBetween, shortDate } from "./days.ts";
|
|
11
|
+
import { axisLabel } from "./format.ts";
|
|
12
|
+
|
|
13
|
+
export const BUCKET_SIZES = [1, 2, 3, 7, 14, 30] as const;
|
|
14
|
+
const BUCKET_UNIT: Record<number, string> = {
|
|
15
|
+
1: "day",
|
|
16
|
+
2: "2 days",
|
|
17
|
+
3: "3 days",
|
|
18
|
+
7: "week",
|
|
19
|
+
14: "2 weeks",
|
|
20
|
+
30: "30 days",
|
|
21
|
+
};
|
|
22
|
+
/** Fixed y-axis intervals: labels land on every `height / TICKS` rows. */
|
|
23
|
+
export const TICKS = 5;
|
|
24
|
+
|
|
25
|
+
/** Box-drawing ports: a glyph is the set of sides its line leaves through. */
|
|
26
|
+
const L = 1;
|
|
27
|
+
const R = 2;
|
|
28
|
+
const U = 4;
|
|
29
|
+
const D = 8;
|
|
30
|
+
const GLYPH: Record<number, string> = {
|
|
31
|
+
[L | R]: "─",
|
|
32
|
+
[U | D]: "│",
|
|
33
|
+
[R | D]: "╭",
|
|
34
|
+
[L | D]: "╮",
|
|
35
|
+
[U | R]: "╰",
|
|
36
|
+
[L | U]: "╯",
|
|
37
|
+
[L | R | U]: "┴",
|
|
38
|
+
[L | R | D]: "┬",
|
|
39
|
+
[U | D | R]: "├",
|
|
40
|
+
[U | D | L]: "┤",
|
|
41
|
+
[L | R | U | D]: "┼",
|
|
42
|
+
};
|
|
43
|
+
/** Y labels are right-aligned to this many columns, then ` ┤`. */
|
|
44
|
+
export const LABEL_WIDTH = 6;
|
|
45
|
+
export const GUTTER = LABEL_WIDTH + 2;
|
|
46
|
+
const NICE = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10] as const;
|
|
47
|
+
|
|
48
|
+
export interface Bucket {
|
|
49
|
+
/** First day in the bucket. */
|
|
50
|
+
start: string;
|
|
51
|
+
/** Tokens per series. */
|
|
52
|
+
values: number[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Bucketed {
|
|
56
|
+
size: number;
|
|
57
|
+
unit: string;
|
|
58
|
+
buckets: Bucket[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sum per-day values into buckets of the smallest size that yields at most `maxBuckets` (at least 2).
|
|
63
|
+
* Buckets end on `to` so the newest bucket is always complete up to today.
|
|
64
|
+
*/
|
|
65
|
+
export function bucketize(
|
|
66
|
+
from: string,
|
|
67
|
+
to: string,
|
|
68
|
+
perDay: (day: string) => number[],
|
|
69
|
+
seriesCount: number,
|
|
70
|
+
maxBuckets: number,
|
|
71
|
+
): Bucketed {
|
|
72
|
+
const days = Math.max(1, daysBetween(from, to) + 1);
|
|
73
|
+
const most = Math.max(2, maxBuckets);
|
|
74
|
+
// Calendar-friendly sizes first; past 30-day buckets, whatever size makes it fit.
|
|
75
|
+
const size = BUCKET_SIZES.find((s) => Math.ceil(days / s) <= most) ?? Math.ceil(days / most);
|
|
76
|
+
const count = Math.ceil(days / size);
|
|
77
|
+
const buckets: Bucket[] = [];
|
|
78
|
+
for (let b = 0; b < count; b++) {
|
|
79
|
+
const end = addDays(to, -(count - 1 - b) * size);
|
|
80
|
+
const start = addDays(end, -(size - 1));
|
|
81
|
+
const values = new Array<number>(seriesCount).fill(0);
|
|
82
|
+
for (let i = 0; i < size; i++) {
|
|
83
|
+
const day = addDays(start, i);
|
|
84
|
+
if (day < from) continue;
|
|
85
|
+
const v = perDay(day);
|
|
86
|
+
for (let s = 0; s < seriesCount; s++) values[s] = (values[s] ?? 0) + (v[s] ?? 0);
|
|
87
|
+
}
|
|
88
|
+
buckets.push({ start: start < from ? from : start, values });
|
|
89
|
+
}
|
|
90
|
+
return { size, unit: BUCKET_UNIT[size] ?? `${size} days`, buckets };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Smallest of 1, 1.5, 2, 2.5, 3, 4, 5, 6, 8 x 10^k that is >= `x`: round labels, little headroom.
|
|
95
|
+
* Tokens are whole, so the step is a whole number too (never below 1, never 1.5 or 2.5).
|
|
96
|
+
*/
|
|
97
|
+
export function niceStep(x: number): number {
|
|
98
|
+
if (!(x > 1)) return 1;
|
|
99
|
+
const exp = 10 ** Math.floor(Math.log10(x));
|
|
100
|
+
for (const m of NICE) {
|
|
101
|
+
const step = m * exp;
|
|
102
|
+
if (Number.isInteger(step) && step >= x - 1e-9 * exp) return step;
|
|
103
|
+
}
|
|
104
|
+
return 10 * exp;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Stretch `values` over `columns` points: bucket `k` lands exactly on column `round(k * (columns - 1) /
|
|
109
|
+
* (n - 1))` with its true value, and columns between two buckets are linearly interpolated. Every
|
|
110
|
+
* bucket value, so every peak, appears unchanged. Needs `n <= columns`, which `bucketize` guarantees.
|
|
111
|
+
*/
|
|
112
|
+
export function stretch(values: readonly number[], columns: number): number[] {
|
|
113
|
+
const n = values.length;
|
|
114
|
+
if (n === 0) return new Array<number>(columns).fill(0);
|
|
115
|
+
if (n === 1) return new Array<number>(columns).fill(values[0] ?? 0);
|
|
116
|
+
const out = new Array<number>(columns).fill(0);
|
|
117
|
+
const at = (k: number) => Math.round((k * (columns - 1)) / (n - 1));
|
|
118
|
+
for (let k = 0; k < n - 1; k++) {
|
|
119
|
+
const from = at(k);
|
|
120
|
+
const to = at(k + 1);
|
|
121
|
+
const a = values[k] ?? 0;
|
|
122
|
+
const b = values[k + 1] ?? 0;
|
|
123
|
+
for (let c = from; c <= to; c++) out[c] = to === from ? b : a + ((b - a) * (c - from)) / (to - from);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface Series<C extends string | number> {
|
|
129
|
+
values: number[];
|
|
130
|
+
color: C;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface PlotOptions<C extends string | number> {
|
|
134
|
+
/** Plot columns, axis excluded. */
|
|
135
|
+
width: number;
|
|
136
|
+
/** Plot rows above the zero line; a multiple of `TICKS` gives evenly spaced labels. */
|
|
137
|
+
height: number;
|
|
138
|
+
paint: (color: C | "axis", text: string) => string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface Plot {
|
|
142
|
+
lines: string[];
|
|
143
|
+
/** Columns before the plot area (labels + axis), for aligning the x-axis. */
|
|
144
|
+
gutter: number;
|
|
145
|
+
max: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Plot `series` stretched over `width` columns. Overlapping series merge into junction glyphs and
|
|
150
|
+
* the first (largest) series keeps the shared cell's colour.
|
|
151
|
+
*/
|
|
152
|
+
export function plot<C extends string | number>(series: readonly Series<C>[], options: PlotOptions<C>): Plot {
|
|
153
|
+
const width = Math.max(2, Math.floor(options.width));
|
|
154
|
+
const height = Math.max(1, Math.floor(options.height));
|
|
155
|
+
const sampled = series.map((s) => stretch(s.values, width + 1));
|
|
156
|
+
const raw = Math.max(0, ...series.flatMap((s) => s.values));
|
|
157
|
+
const step = niceStep(raw / TICKS || 1);
|
|
158
|
+
const max = step * TICKS;
|
|
159
|
+
const labels = Array.from({ length: TICKS + 1 }, (_, i) => axisLabel(step * i));
|
|
160
|
+
const labelWidth = Math.max(LABEL_WIDTH, ...labels.map((l) => l.length));
|
|
161
|
+
const row = (v: number) => Math.round((v / max) * height);
|
|
162
|
+
|
|
163
|
+
// Cells hold box-drawing ports, not glyphs: where series overlap their ports are OR-ed, so a
|
|
164
|
+
// crossing becomes `┴`/`┬`/`┼` and every line stays connected. The first series keeps the colour.
|
|
165
|
+
const grid: { ports: number; color?: C }[][] = Array.from({ length: height + 1 }, () =>
|
|
166
|
+
Array.from({ length: width }, () => ({ ports: 0 })),
|
|
167
|
+
);
|
|
168
|
+
const put = (y: number, x: number, ports: number, color: C) => {
|
|
169
|
+
const cell = grid[height - y]?.[x];
|
|
170
|
+
if (!cell) return;
|
|
171
|
+
cell.ports |= ports;
|
|
172
|
+
cell.color ??= color;
|
|
173
|
+
};
|
|
174
|
+
for (let s = 0; s < sampled.length; s++) {
|
|
175
|
+
const values = sampled[s] ?? [];
|
|
176
|
+
const color = series[s]?.color as C;
|
|
177
|
+
for (let x = 0; x < width; x++) {
|
|
178
|
+
const y0 = row(values[x] ?? 0);
|
|
179
|
+
const y1 = row(values[x + 1] ?? 0);
|
|
180
|
+
if (y0 === y1) {
|
|
181
|
+
put(y0, x, L | R, color);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// enter from the left at y0, leave to the right at y1, vertical in between
|
|
185
|
+
put(y0, x, L | (y1 > y0 ? U : D), color);
|
|
186
|
+
put(y1, x, R | (y1 > y0 ? D : U), color);
|
|
187
|
+
for (let y = Math.min(y0, y1) + 1; y < Math.max(y0, y1); y++) put(y, x, U | D, color);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const perTick = height / TICKS;
|
|
192
|
+
const lines: string[] = [];
|
|
193
|
+
for (let r = 0; r <= height; r++) {
|
|
194
|
+
const y = height - r;
|
|
195
|
+
const tick = Number.isInteger(y / perTick) ? y / perTick : -1;
|
|
196
|
+
const label = tick >= 0 ? (labels[tick] ?? "") : "";
|
|
197
|
+
const axis = y === 0 ? "┼" : tick >= 0 ? "┤" : "│";
|
|
198
|
+
let body = "";
|
|
199
|
+
let run = "";
|
|
200
|
+
let runColor: C | undefined;
|
|
201
|
+
const flush = () => {
|
|
202
|
+
if (run) body += runColor !== undefined ? options.paint(runColor, run) : run;
|
|
203
|
+
run = "";
|
|
204
|
+
};
|
|
205
|
+
for (const cell of grid[r] ?? []) {
|
|
206
|
+
if (cell.color !== runColor) {
|
|
207
|
+
flush();
|
|
208
|
+
runColor = cell.color;
|
|
209
|
+
}
|
|
210
|
+
run += GLYPH[cell.ports] ?? " ";
|
|
211
|
+
}
|
|
212
|
+
flush();
|
|
213
|
+
lines.push(`${options.paint("axis", `${label.padStart(labelWidth)} ${axis}`)}${body}`);
|
|
214
|
+
}
|
|
215
|
+
return { lines, gutter: labelWidth + 2, max };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Two to five date labels under a plot of `width` columns spanning `buckets`: first and last always,
|
|
220
|
+
* evenly spaced ones between when they fit with two spaces of air. Never past the plot's right edge.
|
|
221
|
+
*/
|
|
222
|
+
export function xAxis(buckets: readonly Bucket[], width: number, gutter: number): string {
|
|
223
|
+
if (buckets.length === 0 || width <= 0) return "";
|
|
224
|
+
const last = buckets.length - 1;
|
|
225
|
+
const at = (col: number) => buckets[Math.round((col * last) / Math.max(1, width))]?.start ?? "";
|
|
226
|
+
const line = new Array<string>(width).fill(" ");
|
|
227
|
+
const place = (col: number, text: string): boolean => {
|
|
228
|
+
const start = Math.max(0, Math.min(width - text.length, col));
|
|
229
|
+
for (let i = Math.max(0, start - 2); i < Math.min(width, start + text.length + 2); i++) {
|
|
230
|
+
if (line[i] !== " ") return false;
|
|
231
|
+
}
|
|
232
|
+
for (let i = 0; i < text.length && start + i < width; i++) line[start + i] = text[i] ?? " ";
|
|
233
|
+
return true;
|
|
234
|
+
};
|
|
235
|
+
const first = shortDate(buckets[0]?.start ?? "");
|
|
236
|
+
const end = shortDate(buckets[last]?.start ?? "");
|
|
237
|
+
// The newest date first: on a plot too narrow for both, today's end of the range wins.
|
|
238
|
+
if (last > 0) place(width - end.length, end);
|
|
239
|
+
place(0, first);
|
|
240
|
+
if (last === 0) return `${" ".repeat(gutter)}${line.join("").trimEnd()}`;
|
|
241
|
+
const slots = Math.max(1, Math.min(4, Math.floor(width / 14)));
|
|
242
|
+
for (let i = 1; i < slots; i++) {
|
|
243
|
+
const col = Math.round((i * width) / slots);
|
|
244
|
+
place(col - 3, shortDate(at(col)));
|
|
245
|
+
}
|
|
246
|
+
return `${" ".repeat(gutter)}${line.join("").trimEnd()}`;
|
|
247
|
+
}
|
package/src/stats/cli.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Standalone data-only CLI. Bundles the parser; never imports Pi or the private extension entrypoint. */
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { agentDir } from "./load.ts";
|
|
4
|
+
import { exportStats, importStats } from "./snapshot.ts";
|
|
5
|
+
|
|
6
|
+
async function main(): Promise<void> {
|
|
7
|
+
const [command, ...args] = process.argv.slice(2);
|
|
8
|
+
const options = new Map<string, string>();
|
|
9
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
10
|
+
const flag = args[i];
|
|
11
|
+
const value = args[i + 1];
|
|
12
|
+
if (
|
|
13
|
+
!flag ||
|
|
14
|
+
!value ||
|
|
15
|
+
value.startsWith("--") ||
|
|
16
|
+
options.has(flag) ||
|
|
17
|
+
!["--agent-dir", "--output", "--input", "--sessions-dir"].includes(flag)
|
|
18
|
+
)
|
|
19
|
+
throw new Error(
|
|
20
|
+
"usage: export --agent-dir DIR --output OUT [--sessions-dir DIR] | import --agent-dir DIR --input REPO",
|
|
21
|
+
);
|
|
22
|
+
options.set(flag, value);
|
|
23
|
+
}
|
|
24
|
+
const dir = resolve(options.get("--agent-dir") ?? agentDir());
|
|
25
|
+
if (command === "export" && options.has("--output") && !options.has("--input")) {
|
|
26
|
+
const root = options.get("--sessions-dir");
|
|
27
|
+
console.log(
|
|
28
|
+
JSON.stringify(await exportStats(dir, resolve(options.get("--output") ?? ""), root ? resolve(root) : undefined)),
|
|
29
|
+
);
|
|
30
|
+
} else if (
|
|
31
|
+
command === "import" &&
|
|
32
|
+
options.has("--input") &&
|
|
33
|
+
!options.has("--output") &&
|
|
34
|
+
!options.has("--sessions-dir")
|
|
35
|
+
) {
|
|
36
|
+
const result = await importStats(dir, resolve(options.get("--input") ?? ""));
|
|
37
|
+
console.log(JSON.stringify(result));
|
|
38
|
+
if (result.problems.length) {
|
|
39
|
+
console.error(result.problems.join("\n"));
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
}
|
|
42
|
+
} else
|
|
43
|
+
throw new Error(
|
|
44
|
+
"usage: export --agent-dir DIR --output OUT [--sessions-dir DIR] | import --agent-dir DIR --input REPO",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
main().catch((error: unknown) => {
|
|
48
|
+
console.error(error instanceof Error ? error.message : "Stats operation failed");
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
});
|