savemytokens 0.2.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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +164 -0
  3. package/dist/adapters/claude-code/index.js +130 -0
  4. package/dist/adapters/claude-code/merge.js +90 -0
  5. package/dist/adapters/claude-code/parse.js +642 -0
  6. package/dist/adapters/claude-code/provider.js +105 -0
  7. package/dist/adapters/codex/index.js +74 -0
  8. package/dist/adapters/codex/parse.js +389 -0
  9. package/dist/adapters/codex/provider.js +171 -0
  10. package/dist/adapters/index.js +10 -0
  11. package/dist/adapters/pending.js +29 -0
  12. package/dist/adapters/types.js +1 -0
  13. package/dist/analyze/aggregate.js +180 -0
  14. package/dist/analyze/combine.js +11 -0
  15. package/dist/analyze/detectors.js +244 -0
  16. package/dist/analyze/index.js +29 -0
  17. package/dist/analyze/score.js +20 -0
  18. package/dist/cli-options.js +149 -0
  19. package/dist/cli.js +141 -0
  20. package/dist/collect.js +62 -0
  21. package/dist/commands/audit.js +74 -0
  22. package/dist/commands/control.js +654 -0
  23. package/dist/commands/hud.js +71 -0
  24. package/dist/commands/install.js +369 -0
  25. package/dist/commands/policy.js +93 -0
  26. package/dist/commands/privacy.js +28 -0
  27. package/dist/commands/set.js +83 -0
  28. package/dist/commands/theme.js +136 -0
  29. package/dist/commands/watch.js +135 -0
  30. package/dist/core/cost.js +24 -0
  31. package/dist/core/hash.js +0 -0
  32. package/dist/core/pricing.js +63 -0
  33. package/dist/core/resource.js +1 -0
  34. package/dist/core/tokens.js +32 -0
  35. package/dist/core/types.js +1 -0
  36. package/dist/hooks/nudge.js +111 -0
  37. package/dist/hooks/rules.js +14 -0
  38. package/dist/privacy/payload.js +22 -0
  39. package/dist/report/graph.js +162 -0
  40. package/dist/report/graphs.js +61 -0
  41. package/dist/report/render.js +183 -0
  42. package/dist/report/schedule.js +143 -0
  43. package/dist/report/settings.js +237 -0
  44. package/dist/report/views.js +418 -0
  45. package/dist/runtime/hook.mjs +234 -0
  46. package/dist/runtime/kernel.mjs +1472 -0
  47. package/dist/runtime/statusline.mjs +243 -0
  48. package/dist/scheduler/keys.js +112 -0
  49. package/dist/scheduler/plan.js +287 -0
  50. package/dist/storage/cache.js +38 -0
  51. package/dist/storage/paths.js +29 -0
  52. package/dist/storage/store.js +48 -0
  53. package/dist/util/ansi.js +35 -0
  54. package/dist/util/fmt.js +76 -0
  55. package/package.json +51 -0
@@ -0,0 +1,162 @@
1
+ import { paint, pressureRole } from "../runtime/kernel.mjs";
2
+ import { padStartVisible } from "../util/ansi.js";
3
+ const BLOCKS = ["", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
4
+ const HEAT = [" ", "░", "▒", "▓", "█"];
5
+ export function columns(series, width, until = Infinity) {
6
+ const out = new Array(width).fill(null);
7
+ const span = Math.max(1, series.to - series.from);
8
+ const lastColumn = Number.isFinite(until)
9
+ ? Math.min(width - 1, Math.floor(((until - series.from) / span) * width))
10
+ : width - 1;
11
+ for (const point of series.points) {
12
+ if (point.at < series.from || point.at > series.to)
13
+ continue;
14
+ const index = Math.min(width - 1, Math.floor(((point.at - series.from) / span) * width));
15
+ out[index] = point.value;
16
+ }
17
+ let carried = null;
18
+ for (let index = 0; index <= lastColumn; index++) {
19
+ const value = out[index];
20
+ if (value === null || value === undefined)
21
+ out[index] = carried;
22
+ else
23
+ carried = value;
24
+ }
25
+ return out;
26
+ }
27
+ export function projection(series, now) {
28
+ const points = series.points.filter((point) => point.at >= series.from && point.at <= now);
29
+ if (points.length < 2)
30
+ return null;
31
+ const last = points[points.length - 1];
32
+ const window = 45 * 60 * 1000;
33
+ const earlier = points.find((point) => point.at >= (last?.at ?? now) - window) ?? points[0];
34
+ if (!last || !earlier || last.at <= earlier.at)
35
+ return null;
36
+ const rate = ((last.value - earlier.value) / (last.at - earlier.at)) * 60 * 60 * 1000;
37
+ const hoursLeft = Math.max(0, (series.to - now) / (60 * 60 * 1000));
38
+ return { rate, atReset: last.value + rate * hoursLeft };
39
+ }
40
+ function clockAt(ms) {
41
+ const at = new Date(ms);
42
+ return `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
43
+ }
44
+ export function burnChart(series, now, width, height, theme, color) {
45
+ const plotWidth = Math.max(12, width - 6);
46
+ const values = columns(series, plotWidth, now);
47
+ const forecast = projection(series, now);
48
+ const nowIndex = Math.min(plotWidth - 1, Math.floor(((now - series.from) / Math.max(1, series.to - series.from)) * plotWidth));
49
+ const last = [...values].reverse().find((value) => value !== null) ?? 0;
50
+ const rows = [];
51
+ for (let row = height - 1; row >= 0; row--) {
52
+ const top = ((row + 1) / height) * 100;
53
+ const bottom = (row / height) * 100;
54
+ const label = row === height - 1 ? "100%" : row === 0 ? " 0%" : row === Math.floor(height / 2) ? " 50%" : " ";
55
+ let line = "";
56
+ for (let index = 0; index < plotWidth; index++) {
57
+ const value = values[index];
58
+ if (value === null || value === undefined) {
59
+ if (index > nowIndex && forecast) {
60
+ const share = index / Math.max(1, plotWidth - 1);
61
+ const projected = last + (forecast.atReset - last) * ((share * plotWidth - nowIndex) / Math.max(1, plotWidth - nowIndex));
62
+ line += projected >= bottom && projected < top + (100 / height) ? paint(theme, "dim", "┈", color) : " ";
63
+ }
64
+ else {
65
+ line += " ";
66
+ }
67
+ continue;
68
+ }
69
+ if (value >= top) {
70
+ line += paint(theme, pressureRole(value / 100), "█", color);
71
+ }
72
+ else if (value > bottom) {
73
+ const fraction = (value - bottom) / (top - bottom);
74
+ const glyph = BLOCKS[Math.max(1, Math.round(fraction * 8))] ?? "▁";
75
+ line += paint(theme, pressureRole(value / 100), glyph, color);
76
+ }
77
+ else {
78
+ line += " ";
79
+ }
80
+ }
81
+ rows.push(`${paint(theme, "dim", label, color)} ${line}`);
82
+ }
83
+ const axis = ` ${paint(theme, "dim", clockAt(series.from).padEnd(Math.max(1, plotWidth - 10)) + `reset ${clockAt(series.to)}`, color)}`;
84
+ rows.push(axis);
85
+ return rows;
86
+ }
87
+ export function verdict(series, now, theme, color) {
88
+ const forecast = projection(series, now);
89
+ if (!forecast)
90
+ return paint(theme, "dim", "not enough readings yet — the line fills in as you work", color);
91
+ const atReset = Math.round(forecast.atReset);
92
+ const rate = forecast.rate;
93
+ if (rate <= 0.5) {
94
+ return paint(theme, "ok", `flat — barely burning; on this rate the window ends near ${Math.max(0, atReset)}%`, color);
95
+ }
96
+ if (forecast.atReset >= 100) {
97
+ const last = series.points[series.points.length - 1];
98
+ const remaining = last ? ((100 - last.value) / rate) * 60 : 0;
99
+ const out = new Date(now + remaining * 60 * 1000);
100
+ return paint(theme, "danger", `${Math.round(rate)}%/h — at this rate you run out at ${clockAt(out.getTime())}, before the reset`, color);
101
+ }
102
+ return paint(theme, atReset > 85 ? "warn" : "ok", `${Math.round(rate)}%/h — at this rate the window ends at ${atReset}%`, color);
103
+ }
104
+ export function dualBar(target, used, width, theme, color) {
105
+ const cells = Math.max(6, width);
106
+ const usedCells = Math.round(Math.max(0, Math.min(1, used)) * cells);
107
+ const targetCell = Math.round(Math.max(0, Math.min(1, target)) * cells);
108
+ let out = "";
109
+ for (let index = 0; index < cells; index++) {
110
+ if (index === targetCell - 1 && targetCell > 0) {
111
+ out += paint(theme, "accent", index < usedCells ? "┃" : "╵", color);
112
+ }
113
+ else if (index < usedCells) {
114
+ out += paint(theme, pressureRole(target > 0 ? used / target : 0), "█", color);
115
+ }
116
+ else {
117
+ out += paint(theme, "track", "░", color);
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ export function heatStrip(buckets, from, to, width, theme, color) {
123
+ const slots = new Array(width).fill(0);
124
+ const span = Math.max(1, to - from);
125
+ let peak = 0;
126
+ for (const row of buckets) {
127
+ const at = row[0] ?? 0;
128
+ if (at < from || at > to)
129
+ continue;
130
+ const weighted = (row[1] ?? 0) + (row[2] ?? 0) * 5 + (row[3] ?? 0) * 1.25 + (row[4] ?? 0) * 0.1;
131
+ const index = Math.min(width - 1, Math.floor(((at - from) / span) * width));
132
+ slots[index] += weighted;
133
+ if (slots[index] > peak)
134
+ peak = slots[index];
135
+ }
136
+ return slots
137
+ .map((value) => {
138
+ if (peak <= 0 || value <= 0)
139
+ return paint(theme, "track", HEAT[0] ?? " ", color);
140
+ const level = Math.max(1, Math.min(HEAT.length - 1, Math.round((value / peak) * (HEAT.length - 1))));
141
+ return paint(theme, pressureRole(value / peak), HEAT[level] ?? "█", color);
142
+ })
143
+ .join("");
144
+ }
145
+ export function miniSpark(buckets, from, to, width) {
146
+ const slots = new Array(width).fill(0);
147
+ const span = Math.max(1, to - from);
148
+ for (const row of buckets) {
149
+ const at = row[0] ?? 0;
150
+ if (at < from || at > to)
151
+ continue;
152
+ const weighted = (row[1] ?? 0) + (row[2] ?? 0) * 5 + (row[3] ?? 0) * 1.25 + (row[4] ?? 0) * 0.1;
153
+ slots[Math.min(width - 1, Math.floor(((at - from) / span) * width))] += weighted;
154
+ }
155
+ const peak = Math.max(...slots, 0);
156
+ if (peak <= 0)
157
+ return " ".repeat(width);
158
+ return slots.map((value) => BLOCKS[Math.max(0, Math.min(8, Math.round((value / peak) * 8)))] ?? " ").join("");
159
+ }
160
+ export function percentLabel(value, width = 4) {
161
+ return padStartVisible(`${Math.round(value)}%`, width);
162
+ }
@@ -0,0 +1,61 @@
1
+ import { paint, pressureRole } from "../runtime/kernel.mjs";
2
+ import { padStartVisible } from "../util/ansi.js";
3
+ const BLOCKS = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
4
+ const HEAT = [" ", "░", "▒", "▓", "█"];
5
+ export function percentLabel(value, width = 4) {
6
+ if (!Number.isFinite(value))
7
+ return padStartVisible("-", width);
8
+ const rounded = Math.round(value);
9
+ return padStartVisible(rounded > 999 ? ">999%" : `${rounded}%`, width);
10
+ }
11
+ export function weighted(row) {
12
+ return (row[1] ?? 0) + (row[2] ?? 0) * 5 + (row[3] ?? 0) * 1.25 + (row[4] ?? 0) * 0.1;
13
+ }
14
+ export function smallBar(ratio, width, theme, color, role) {
15
+ const cells = Math.max(4, width);
16
+ const glyphs = theme.tui ?? {};
17
+ const fill = glyphs.fill ?? "|";
18
+ const empty = glyphs.empty ?? ".";
19
+ const open = glyphs.open ? paint(theme, "dim", glyphs.open, color) : "";
20
+ const close = glyphs.close ? paint(theme, "dim", glyphs.close, color) : "";
21
+ if (ratio > 1)
22
+ return `${open}${paint(theme, "danger", fill.repeat(cells - 1) + (glyphs.over ?? "»"), color)}${close}`;
23
+ const filled = Math.max(0, Math.min(cells, Math.round(Math.max(0, ratio) * cells)));
24
+ return `${open}${paint(theme, role, fill.repeat(filled), color)}${paint(theme, "track", empty.repeat(cells - filled), color)}${close}`;
25
+ }
26
+ export function emptyBar(width, theme, color) {
27
+ const glyphs = theme.tui ?? {};
28
+ const open = glyphs.open ?? "";
29
+ const close = glyphs.close ?? "";
30
+ return paint(theme, "dim", `${open}${(glyphs.empty ?? ".").repeat(Math.max(4, width))}${close}`, color);
31
+ }
32
+ function slotsFor(buckets, from, to, width) {
33
+ const slots = new Array(width).fill(0);
34
+ const span = Math.max(1, to - from);
35
+ for (const row of buckets) {
36
+ const at = row[0] ?? 0;
37
+ if (at < from || at > to)
38
+ continue;
39
+ slots[Math.min(width - 1, Math.floor(((at - from) / span) * width))] += weighted(row);
40
+ }
41
+ return slots;
42
+ }
43
+ export function heatStrip(buckets, from, to, width, theme, color) {
44
+ const slots = slotsFor(buckets, from, to, width);
45
+ const peak = Math.max(...slots, 0);
46
+ return slots
47
+ .map((value) => {
48
+ if (peak <= 0 || value <= 0)
49
+ return paint(theme, "track", "·", color);
50
+ const level = Math.max(1, Math.min(HEAT.length - 1, Math.round((value / peak) * (HEAT.length - 1))));
51
+ return paint(theme, pressureRole(value / peak), HEAT[level] ?? "█", color);
52
+ })
53
+ .join("");
54
+ }
55
+ export function miniSpark(buckets, from, to, width) {
56
+ const slots = slotsFor(buckets, from, to, width);
57
+ const peak = Math.max(...slots, 0);
58
+ if (peak <= 0)
59
+ return " ".repeat(width);
60
+ return slots.map((value) => BLOCKS[Math.max(0, Math.min(8, Math.round((value / peak) * 8)))] ?? " ").join("");
61
+ }
@@ -0,0 +1,183 @@
1
+ import { bold, clip, dim, green, padEndVisible, padStartVisible, red, visibleWidth } from "../util/ansi.js";
2
+ import { ago, bar, compactNumber, money, percent, plural, shortDate, sparkline } from "../util/fmt.js";
3
+ import { displayHome } from "../storage/paths.js";
4
+ import { pricingNote } from "../core/pricing.js";
5
+ const INDENT = " ";
6
+ const BAR_WIDTH = 8;
7
+ function width() {
8
+ const columns = process.stdout.columns ?? 80;
9
+ return Math.max(48, Math.min(columns - 2, 96));
10
+ }
11
+ export function wrap(text, indent, max = width()) {
12
+ const limit = Math.max(24, max - indent.length);
13
+ const lines = [];
14
+ let line = "";
15
+ for (const word of text.split(/\s+/)) {
16
+ if (!word)
17
+ continue;
18
+ if (line.length === 0)
19
+ line = word;
20
+ else if (line.length + 1 + word.length <= limit)
21
+ line += ` ${word}`;
22
+ else {
23
+ lines.push(indent + line);
24
+ line = word;
25
+ }
26
+ }
27
+ if (line)
28
+ lines.push(indent + line);
29
+ return lines;
30
+ }
31
+ function hanging(text, indent, marker, columns) {
32
+ const lines = wrap(text, "", columns - indent.length - visibleWidth(marker));
33
+ return lines.map((line, index) => index === 0 ? `${indent}${marker}${line}` : `${indent}${" ".repeat(visibleWidth(marker))}${line}`);
34
+ }
35
+ function taskRows(tasks, columns, limit) {
36
+ const top = tasks.slice(0, limit);
37
+ if (top.length === 0)
38
+ return [];
39
+ const max = Math.max(...top.map((t) => t.usd));
40
+ const amounts = top.map((t) => money(t.usd));
41
+ const projects = top.map((t) => (t.project || "").split("/").pop() || "unknown");
42
+ const moneyWidth = Math.max(...amounts.map((a) => a.length));
43
+ const projectWidth = Math.min(14, Math.max(...projects.map((p) => p.length)));
44
+ return top.map((task, index) => {
45
+ const amount = padStartVisible(amounts[index] ?? "", moneyWidth);
46
+ const meter = padEndVisible(dim(bar(task.usd, max, BAR_WIDTH)), BAR_WIDTH);
47
+ const project = padEndVisible((projects[index] ?? "").slice(0, projectWidth), projectWidth);
48
+ const room = Math.max(20, columns - moneyWidth - BAR_WIDTH - projectWidth - INDENT.length - 6);
49
+ return `${INDENT}${amount} ${meter} ${project} ${clip(task.prompt, room)}`;
50
+ });
51
+ }
52
+ function footer(audit, previous, history, verbose) {
53
+ const scores = history
54
+ .filter((run) => run.scope.days === audit.scope.days && (run.scope.project ?? null) === (audit.scope.project ?? null))
55
+ .slice(-16)
56
+ .map((run) => run.score);
57
+ const spark = sparkline([...scores, audit.score]);
58
+ const parts = [];
59
+ if (previous) {
60
+ const delta = audit.wasteRatio - previous.wasteRatio;
61
+ const direction = delta < -0.005
62
+ ? `${green("less waste")} than`
63
+ : delta > 0.005
64
+ ? `${red("more waste")} than`
65
+ : "about the same as";
66
+ parts.push(`${direction} ${ago(previous.ranAt)}`);
67
+ }
68
+ if (spark && verbose)
69
+ parts.push(spark);
70
+ return parts.length > 0 ? [dim(parts.join(" · "))] : [];
71
+ }
72
+ export function renderAudit(input) {
73
+ const { audit, previous, history = [], nudges = null, installed = false, verbose = false } = input;
74
+ const columns = width();
75
+ const out = [""];
76
+ if (audit.totals.sessions === 0) {
77
+ out.push(`No agent sessions in the last ${audit.scope.days} days.`);
78
+ out.push(dim("Wider window: npx savemytokens --days 30"));
79
+ out.push("");
80
+ return out.join("\n");
81
+ }
82
+ const wasted = Math.min(audit.findings.reduce((sum, f) => sum + f.wastedUsd, 0), audit.totals.usd);
83
+ const scope = audit.scope.project ? (audit.scope.project.split("/").pop() ?? "") : "all projects";
84
+ out.push(`${bold(money(audit.totals.usd))} in ${audit.scope.days} ${plural(audit.scope.days, "day")} · ${bold(money(wasted))} wasted ${dim(`${scope} · ${audit.totals.tasks} tasks`)}`);
85
+ if (audit.rateLimitHits > 0) {
86
+ out.push(dim(`hit your usage limit ${audit.rateLimitHits} ${plural(audit.rateLimitHits, "time")}`));
87
+ }
88
+ out.push("");
89
+ const rows = taskRows(audit.topTasks, columns, verbose ? 5 : 3);
90
+ if (rows.length > 0) {
91
+ out.push(...rows);
92
+ out.push("");
93
+ }
94
+ const yours = audit.findings.filter((f) => f.actor === "you");
95
+ const claudes = audit.findings.filter((f) => f.actor === "claude");
96
+ const shown = verbose ? yours.slice(0, 3) : yours.slice(0, 1);
97
+ for (const finding of shown) {
98
+ out.push(...wrap(`${bold(finding.title)} ${bold(money(finding.wastedUsd))}`, ""));
99
+ for (const line of finding.measured.slice(0, verbose ? 3 : 2)) {
100
+ out.push(...hanging(line, INDENT, dim("· "), columns));
101
+ }
102
+ if (finding.receipts?.length) {
103
+ out.push("");
104
+ for (const line of finding.receipts.slice(0, verbose ? 3 : 2)) {
105
+ out.push(`${INDENT}${dim(clip(line, columns - INDENT.length))}`);
106
+ }
107
+ }
108
+ out.push("");
109
+ out.push(...hanging(`${bold("Do this:")} ${finding.fix}`, INDENT, "", columns));
110
+ if (verbose && finding.detail) {
111
+ out.push("");
112
+ for (const detail of finding.detail)
113
+ out.push(`${INDENT}${dim(detail)}`);
114
+ }
115
+ out.push("");
116
+ }
117
+ const rest = yours.length - shown.length;
118
+ if (rest > 0 && !verbose) {
119
+ const restUsd = yours.slice(shown.length).reduce((sum, f) => sum + f.wastedUsd, 0);
120
+ out.push(dim(`${rest} smaller ${plural(rest, "finding")} worth ${money(restUsd)} · npx savemytokens -v`));
121
+ out.push("");
122
+ }
123
+ const claudeUsd = claudes.reduce((sum, f) => sum + f.wastedUsd, 0);
124
+ if (claudes.length > 0 && !installed) {
125
+ out.push(...wrap(dim(`${money(claudeUsd)} more is how Claude works, not how you work: ${claudes.map((f) => f.title.toLowerCase()).join(", ")}. Nothing for you to do by hand; install writes the rules that fix them.`), ""));
126
+ out.push("");
127
+ }
128
+ if (nudges && installed) {
129
+ out.push(`${green("Since you installed:")} ${nudges.fired} ${plural(nudges.fired, "warning")}, ${money(nudges.usdAtStake)} at stake ${dim(`(${ago(nudges.installedAt)})`)}`);
130
+ }
131
+ else if (!installed) {
132
+ out.push(`${green("Do this:")} ${bold("npx savemytokens install")} ${dim("→ warns you before the next one, and gives each session a target share")}`);
133
+ }
134
+ out.push(...footer(audit, previous, history, verbose));
135
+ if (verbose) {
136
+ out.push("");
137
+ out.push(dim(`Efficiency ${audit.score}/100`));
138
+ for (const component of audit.scoreBreakdown) {
139
+ const points = `${component.points > 0 ? "+" : ""}${component.points}`;
140
+ out.push(dim(`${INDENT}${points.padStart(5)} ${component.label}`));
141
+ }
142
+ out.push("");
143
+ out.push(dim("Spend by project"));
144
+ const maxProject = Math.max(...audit.projects.map((p) => p.usd), 0);
145
+ for (const project of audit.projects.slice(0, 8)) {
146
+ const amount = padStartVisible(money(project.usd), 7);
147
+ const meter = padEndVisible(bar(project.usd, maxProject, BAR_WIDTH), BAR_WIDTH);
148
+ out.push(dim(`${INDENT}${amount} ${meter} ${project.name} · ${project.tasks} ${plural(project.tasks, "task")}`));
149
+ }
150
+ out.push("");
151
+ out.push(dim("Models"));
152
+ for (const model of audit.models.slice(0, 6)) {
153
+ const context = model.usage.input + model.usage.cacheWrite + model.usage.cacheRead;
154
+ out.push(dim(`${INDENT}${model.model} · ${model.turns} ${plural(model.turns, "turn")} · in ${compactNumber(context)} · out ${compactNumber(model.usage.output)}`));
155
+ }
156
+ out.push("");
157
+ out.push(dim(pricingNote()));
158
+ out.push(dim(`Saved to ${displayHome()} · nothing left this machine`));
159
+ }
160
+ out.push("");
161
+ return out.join("\n");
162
+ }
163
+ export function renderHistory(runs) {
164
+ if (runs.length === 0)
165
+ return `\n${bold("SaveMyTokens")}\n\nNo runs recorded yet. Run: npx savemytokens\n`;
166
+ const recent = runs.slice(-20);
167
+ const out = ["", bold("SaveMyTokens"), "", dim("date score waste scope"), ""];
168
+ for (const run of recent) {
169
+ const scope = `${run.scope.days}d${run.scope.project ? " · " + (run.scope.project.split("/").pop() ?? "") : ""}`;
170
+ out.push(`${shortDate(run.ranAt).padEnd(18)} ${String(run.score).padStart(3)}/100 ${percent(run.wasteRatio).padStart(5)} ${dim(scope)}`);
171
+ }
172
+ const spark = sparkline(recent.map((run) => run.score));
173
+ const first = recent[0];
174
+ const last = recent[recent.length - 1];
175
+ if (spark && first && last) {
176
+ const delta = last.score - first.score;
177
+ const marker = delta > 0 ? green(`+${delta}`) : delta < 0 ? red(String(delta)) : dim("0");
178
+ out.push("");
179
+ out.push(`${dim(spark)} ${marker} ${dim("points since the first run shown")}`);
180
+ }
181
+ out.push("");
182
+ return out.join("\n");
183
+ }
@@ -0,0 +1,143 @@
1
+ import { activeViews } from "../scheduler/plan.js";
2
+ import { formatReset, meterBar, paint, pressureRole } from "../runtime/kernel.mjs";
3
+ import { padEndVisible, padStartVisible, visibleWidth } from "../util/ansi.js";
4
+ import { ago, compactNumber } from "../util/fmt.js";
5
+ const BAR_WIDTH = 10;
6
+ const STATE_MARK = { active: "•", "needs-more": "+", done: "✓", blocked: "!" };
7
+ function percent(value) {
8
+ return `${Math.round(value)}%`;
9
+ }
10
+ function clip(text, max) {
11
+ if (max <= 1)
12
+ return "";
13
+ return visibleWidth(text) <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`;
14
+ }
15
+ function capacityLines(control, options, now) {
16
+ const { theme, color = true } = options;
17
+ const out = [];
18
+ const published = control.resources.filter((resource) => resource.usedPercent !== null);
19
+ const label = `${control.provider.label} capacity`;
20
+ if (published.length === 0) {
21
+ out.push(` ${paint(theme, "dim", label, color)} ${paint(theme, "warn", "not published to this machine yet", color)}`);
22
+ out.push(` ${paint(theme, "dim", control.provider.id === "claude-code" ? "Anthropic publishes your 5h and 7d usage to the status line only. Run: npx savemytokens install" : "No session has written a rate limit to disk in this window.", color)}`);
23
+ return out;
24
+ }
25
+ const asOf = control.schedule.quota?.at ?? 0;
26
+ out.push(` ${paint(theme, "dim", label, color)} ${paint(theme, "dim", `published · read ${ago(asOf, now)}`, color)}`);
27
+ for (const resource of published) {
28
+ const used = resource.usedPercent ?? 0;
29
+ const key = resource.id.split(":")[1] ?? "";
30
+ const label = key === "five_hour" ? "5h" : key === "seven_day" ? "7d" : "spend";
31
+ const reset = resource.window.resetsAt ? `resets ${formatReset(resource.window.resetsAt, now)}` : "";
32
+ out.push(` ${padEndVisible(label, 5)} ${meterBar(theme, used / 100, BAR_WIDTH, pressureRole(used / 100), color)} ${padStartVisible(percent(used), 4)} ${paint(theme, "dim", `used · ${reset}`, color)}`);
33
+ }
34
+ return out;
35
+ }
36
+ function rowFor(view, index, widths, options, attributed) {
37
+ const { theme, selected, color = true } = options;
38
+ const chosen = selected === index;
39
+ const cursor = chosen ? paint(theme, "accent", "❯ ", color) : " ";
40
+ const mark = paint(theme, view.state === "blocked" ? "danger" : view.state === "done" ? "dim" : "ok", STATE_MARK[view.state] ?? "•", color);
41
+ const label = padEndVisible(clip(widths.name, widths.label), widths.label);
42
+ const pinned = view.allocation.pinned ? paint(theme, "dim", "*", color) : " ";
43
+ const running = view.state === "active" || view.state === "needs-more";
44
+ const role = running ? pressureRole(view.pressure.value) : "dim";
45
+ const target = padStartVisible(percent(view.allocation.target * 100), 6);
46
+ const used = attributed
47
+ ? padStartVisible(paint(theme, role, percent(view.attributedPercent ?? 0), color), 6)
48
+ : "";
49
+ const share = padStartVisible(paint(theme, attributed ? "dim" : role, percent(view.observed * 100), color), 6);
50
+ const priority = padEndVisible(paint(theme, view.claimant.priority === "high" ? "accent" : "dim", view.claimant.priority.toUpperCase(), color), 8);
51
+ const prompt = paint(theme, "dim", clip(view.claimant.prompt || "—", widths.prompt), color);
52
+ return `${cursor}${mark} ${label} ${target}${pinned}${used} ${share} ${priority} ${prompt}`;
53
+ }
54
+ export function renderSchedule(control, options) {
55
+ const { theme, color = true, interactive = false } = options;
56
+ const now = control.schedule.now;
57
+ const columns = Math.max(60, Math.min(options.columns ?? 100, 120));
58
+ const views = activeViews(control.schedule);
59
+ const out = [""];
60
+ const windowLabel = control.schedule.key === "seven_day" ? "7-day window" : "5-hour window";
61
+ out.push(` ${paint(theme, "accent", "SaveMyTokens", color)} ${paint(theme, "dim", `· ${control.provider.label} · ${windowLabel}`, color)}`);
62
+ out.push("");
63
+ out.push(...capacityLines(control, options, now));
64
+ out.push("");
65
+ const attributed = control.schedule.live !== null;
66
+ const seen = new Map();
67
+ for (const view of views)
68
+ seen.set(view.claimant.label, (seen.get(view.claimant.label) ?? 0) + 1);
69
+ const labels = new Map();
70
+ for (const view of views) {
71
+ const base = view.claimant.label || view.claimant.id.slice(0, 8);
72
+ const started = new Date(view.claimant.startedAt);
73
+ const stamp = `${String(started.getHours()).padStart(2, "0")}:${String(started.getMinutes()).padStart(2, "0")}`;
74
+ labels.set(view.claimant.id, (seen.get(view.claimant.label) ?? 0) > 1 ? `${base} ${stamp}` : base);
75
+ }
76
+ const stamped = new Map();
77
+ for (const label of labels.values())
78
+ stamped.set(label, (stamped.get(label) ?? 0) + 1);
79
+ for (const [id, label] of labels) {
80
+ if ((stamped.get(label) ?? 0) > 1)
81
+ labels.set(id, `${label}·${id.slice(0, 4)}`);
82
+ }
83
+ const labelWidth = Math.min(22, Math.max(9, ...[...labels.values()].map((label) => label.length)));
84
+ const promptWidth = Math.max(16, columns - labelWidth - (attributed ? 38 : 30));
85
+ out.push(paint(theme, "dim", ` ${padEndVisible("session", labelWidth)} ${padStartVisible("target", 6)}${attributed ? ` ${padStartVisible("used", 6)}` : ""} ${padStartVisible("share", 6)} ${padEndVisible("priority", 8)} last prompt`, color));
86
+ if (views.length === 0) {
87
+ out.push("");
88
+ out.push(` ${paint(theme, "dim", `No ${control.provider.label} sessions in this window.`, color)}`);
89
+ }
90
+ for (const [index, view] of views.entries()) {
91
+ out.push(rowFor(view, index, { label: labelWidth, prompt: promptWidth, name: labels.get(view.claimant.id) ?? view.claimant.label }, options, attributed));
92
+ }
93
+ out.push("");
94
+ out.push(` ${paint(theme, "dim", "spare target capacity", color)} ${paint(theme, control.schedule.unusedPool > 0.01 ? "warn" : "dim", percent(control.schedule.unusedPool * 100), color)}`);
95
+ const basis = control.schedule.live
96
+ ? `target and used are percentages of the 5h window Anthropic publishes; share is this session's part of ${compactNumber(control.schedule.totalWeighted)} weighted tokens measured on disk, and used is ${percent(control.schedule.live.usedPercent)} × that share`
97
+ : `no published window on this machine, so target and share are portions of the ${compactNumber(control.schedule.totalWeighted)} weighted tokens measured on disk`;
98
+ for (const line of basis.match(/.{1,92}(\s|$)/g) ?? [basis]) {
99
+ out.push(` ${paint(theme, "dim", line.trim(), color)}`);
100
+ }
101
+ const enforcement = control.enforcement.length > 0
102
+ ? `enforcement: ${control.enforcement.join(", ")} only — a hook injects text, nothing here can hold a session to a number`
103
+ : `${control.provider.label} has no hook to inject through, so this view is visibility only`;
104
+ out.push(` ${paint(theme, "dim", enforcement, color)}`);
105
+ const policy = control.config.policyFor?.[process.cwd()] ?? control.config.policy;
106
+ out.push(` ${paint(theme, "dim", `when a session passes its target: policy ${policy} · npx savemytokens policy`, color)}`);
107
+ if (control.deferred.length > 0) {
108
+ out.push("");
109
+ out.push(` ${paint(theme, "warn", "deferred to the next session", color)}`);
110
+ for (const group of control.deferred.slice(0, 3)) {
111
+ const name = group.project.split("/").pop() || group.project;
112
+ for (const item of group.items.slice(-2)) {
113
+ out.push(` ${paint(theme, "dim", `${name} · ${clip(item.text, columns - 20)}`, color)}`);
114
+ }
115
+ }
116
+ }
117
+ for (const other of control.others) {
118
+ const published = other.resources.filter((resource) => resource.usedPercent !== null);
119
+ if (published.length === 0)
120
+ continue;
121
+ const parts = published.map((resource) => {
122
+ const key = resource.id.split(":")[1] ?? "";
123
+ return `${key === "seven_day" ? "7d" : "5h"} ${percent(resource.usedPercent ?? 0)}`;
124
+ });
125
+ out.push("");
126
+ out.push(` ${paint(theme, "dim", `${other.label}: ${parts.join(" · ")} — npx savemytokens --adapter ${other.id}`, color)}`);
127
+ }
128
+ if (control.unattributed !== null) {
129
+ out.push(` ${paint(theme, "warn", `${percent(control.unattributed)} of the window moved while no local session was running`, color)}`);
130
+ }
131
+ if (control.schedule.lockouts.length > 0) {
132
+ const last = control.schedule.lockouts[control.schedule.lockouts.length - 1] ?? 0;
133
+ out.push(` ${paint(theme, "danger", `hit the limit ${control.schedule.lockouts.length}× this window, last ${ago(last, now)}`, color)}`);
134
+ }
135
+ out.push("");
136
+ if (interactive) {
137
+ const preserve = control.config.preserveFor[process.cwd()] ?? control.config.preserveFor.default;
138
+ out.push(paint(theme, "dim", ` preserving ${preserve && preserve.length > 0 ? preserve.join(", ") : "testing and finalisation (default)"} · P to change`, color));
139
+ out.push(paint(theme, "dim", " ↑↓ select ←→ target p priority e equalize d done b blocked a active q quit", color));
140
+ out.push("");
141
+ }
142
+ return out.join("\n");
143
+ }