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,20 @@
1
+ const MAX_WASTE_PENALTY = 55;
2
+ const MAX_ERROR_PENALTY = 8;
3
+ const MAX_INTERRUPT_PENALTY = 5;
4
+ export function scoreAudit(agg, wasteRatio) {
5
+ const breakdown = [{ id: "base", label: "Base", points: 100 }];
6
+ const wastePenalty = Math.min(MAX_WASTE_PENALTY, Math.round(wasteRatio * 120));
7
+ if (wastePenalty > 0)
8
+ breakdown.push({ id: "waste", label: "Avoidable token spend", points: -wastePenalty });
9
+ const turns = Math.max(1, agg.totals.turns);
10
+ const errorRate = (agg.apiErrors + agg.toolErrors) / turns;
11
+ const errorPenalty = Math.min(MAX_ERROR_PENALTY, Math.round(errorRate * 40));
12
+ if (errorPenalty > 0)
13
+ breakdown.push({ id: "errors", label: "Failed calls and retries", points: -errorPenalty });
14
+ const tasks = Math.max(1, agg.totals.tasks);
15
+ const interruptPenalty = Math.min(MAX_INTERRUPT_PENALTY, Math.round((agg.outcomes.interrupted / tasks) * 20));
16
+ if (interruptPenalty > 0)
17
+ breakdown.push({ id: "interrupted", label: "Interrupted tasks", points: -interruptPenalty });
18
+ const total = breakdown.reduce((sum, c) => sum + c.points, 0);
19
+ return { score: Math.max(0, Math.min(100, Math.round(total))), breakdown };
20
+ }
@@ -0,0 +1,149 @@
1
+ const COMMANDS = new Set([
2
+ "control",
3
+ "status",
4
+ "share",
5
+ "priority",
6
+ "release",
7
+ "pin",
8
+ "park",
9
+ "policy",
10
+ "defer",
11
+ "hud",
12
+ "audit",
13
+ "install",
14
+ "uninstall",
15
+ "watch",
16
+ "history",
17
+ "privacy",
18
+ "theme",
19
+ "help",
20
+ ]);
21
+ export function parseArgs(argv) {
22
+ const options = {
23
+ command: "control",
24
+ args: [],
25
+ days: 7,
26
+ project: null,
27
+ projectExplicit: false,
28
+ json: false,
29
+ verbose: false,
30
+ save: true,
31
+ interval: 60,
32
+ dryRun: false,
33
+ force: false,
34
+ window: "five_hour",
35
+ adapter: "claude-code",
36
+ rules: false,
37
+ purge: false,
38
+ help: false,
39
+ version: false,
40
+ };
41
+ let commandSeen = false;
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const arg = argv[i];
44
+ if (arg === undefined)
45
+ continue;
46
+ if (!arg.startsWith("-")) {
47
+ if (!commandSeen && COMMANDS.has(arg)) {
48
+ options.command = arg;
49
+ commandSeen = true;
50
+ }
51
+ else if (!commandSeen && options.args.length === 0) {
52
+ options.command = "unknown";
53
+ options.args.push(arg);
54
+ commandSeen = true;
55
+ }
56
+ else {
57
+ options.args.push(arg);
58
+ }
59
+ continue;
60
+ }
61
+ switch (arg) {
62
+ case "--days":
63
+ case "-d": {
64
+ const value = Number(argv[++i]);
65
+ if (Number.isFinite(value) && value > 0)
66
+ options.days = Math.min(365, Math.round(value));
67
+ break;
68
+ }
69
+ case "--here":
70
+ options.project = process.cwd();
71
+ options.projectExplicit = true;
72
+ break;
73
+ case "--project": {
74
+ const value = argv[++i];
75
+ if (value) {
76
+ options.project = value;
77
+ options.projectExplicit = true;
78
+ }
79
+ break;
80
+ }
81
+ case "--all":
82
+ options.project = null;
83
+ options.projectExplicit = true;
84
+ break;
85
+ case "--json":
86
+ options.json = true;
87
+ break;
88
+ case "--verbose":
89
+ case "-v":
90
+ options.verbose = true;
91
+ break;
92
+ case "--no-save":
93
+ options.save = false;
94
+ break;
95
+ case "--dry-run":
96
+ options.dryRun = true;
97
+ break;
98
+ case "--force":
99
+ options.force = true;
100
+ break;
101
+ case "--window": {
102
+ const value = String(argv[++i] ?? "");
103
+ if (/^(7d|week|seven|seven_day)$/i.test(value))
104
+ options.window = "seven_day";
105
+ else
106
+ options.window = "five_hour";
107
+ break;
108
+ }
109
+ case "--7d":
110
+ options.window = "seven_day";
111
+ break;
112
+ case "--adapter": {
113
+ const value = argv[++i];
114
+ if (value)
115
+ options.adapter = value;
116
+ break;
117
+ }
118
+ case "--codex":
119
+ options.adapter = "codex";
120
+ break;
121
+ case "--claude":
122
+ case "--claude-code":
123
+ options.adapter = "claude-code";
124
+ break;
125
+ case "--rules":
126
+ options.rules = true;
127
+ break;
128
+ case "--purge":
129
+ options.purge = true;
130
+ break;
131
+ case "--interval": {
132
+ const value = Number(argv[++i]);
133
+ if (Number.isFinite(value) && value >= 5)
134
+ options.interval = Math.round(value);
135
+ break;
136
+ }
137
+ case "--help":
138
+ case "-h":
139
+ options.help = true;
140
+ break;
141
+ case "--version":
142
+ options.version = true;
143
+ break;
144
+ default:
145
+ break;
146
+ }
147
+ }
148
+ return options;
149
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import { parseArgs } from "./cli-options.js";
4
+ import { runAudit } from "./commands/audit.js";
5
+ import { runControl } from "./commands/control.js";
6
+ import { runInstall, runUninstall } from "./commands/install.js";
7
+ import { runHud } from "./commands/hud.js";
8
+ import { runDefer, runPolicy } from "./commands/policy.js";
9
+ import { runPrivacy } from "./commands/privacy.js";
10
+ import { runSet } from "./commands/set.js";
11
+ import { runTheme } from "./commands/theme.js";
12
+ import { runWatch } from "./commands/watch.js";
13
+ import { renderHistory } from "./report/render.js";
14
+ import { loadRuns } from "./storage/store.js";
15
+ import { bold, dim } from "./util/ansi.js";
16
+ function version() {
17
+ try {
18
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
19
+ return String(pkg.version ?? "0.0.0");
20
+ }
21
+ catch {
22
+ return "0.0.0";
23
+ }
24
+ }
25
+ function help() {
26
+ return `
27
+ ${bold("savemytokens")}: give every Claude Code session a target share of your Claude window.
28
+
29
+ npx savemytokens the control centre
30
+ npx savemytokens install hooks + status line, so it works while the TUI is closed
31
+ npx savemytokens uninstall remove them
32
+ npx savemytokens status one plain-text snapshot
33
+ npx savemytokens share <project> <percent|auto>
34
+ npx savemytokens priority <project> <high|normal|low>
35
+ npx savemytokens release <project> hand its unused share back
36
+ npx savemytokens pin|park <project> keep it visible, or drop it from the set
37
+ npx savemytokens policy what Claude does as the window fills
38
+ npx savemytokens defer work pushed to the next session
39
+ npx savemytokens hud status line layouts, previewed on your numbers
40
+ npx savemytokens theme themes for the TUI and the status line
41
+ npx savemytokens audit the token-waste report
42
+ npx savemytokens watch keep watching, and report drift as it happens
43
+ npx savemytokens history past audit scores
44
+ npx savemytokens privacy every file it reads and writes
45
+
46
+ -d, --days <n> audit window (default 7)
47
+ --json machine-readable
48
+ --force install: wrap an existing status line instead of leaving it alone
49
+ --rules install: also write the token-discipline block into ~/.claude/CLAUDE.md
50
+ --purge uninstall: also delete ~/.savemytokens
51
+ --7d allocate against the weekly window instead of the 5-hour one
52
+ --codex Codex instead of Claude Code (visibility only)
53
+
54
+ ${dim("Everything runs locally. No account, no daemon, no upload. See what it stores: savemytokens privacy")}
55
+ ${dim("Questions, bugs and ideas: hello@offbeatport.com")}
56
+ `;
57
+ }
58
+ const KNOWN_COMMANDS = [
59
+ "audit",
60
+ "defer",
61
+ "help",
62
+ "history",
63
+ "hud",
64
+ "install",
65
+ "park",
66
+ "pin",
67
+ "policy",
68
+ "priority",
69
+ "privacy",
70
+ "release",
71
+ "share",
72
+ "status",
73
+ "theme",
74
+ "uninstall",
75
+ "watch",
76
+ ];
77
+ async function main() {
78
+ const options = parseArgs(process.argv.slice(2));
79
+ if (options.version) {
80
+ process.stdout.write(`${version()}\n`);
81
+ return;
82
+ }
83
+ if (options.help || options.command === "help") {
84
+ process.stdout.write(help());
85
+ return;
86
+ }
87
+ switch (options.command) {
88
+ case "install":
89
+ runInstall({ dryRun: options.dryRun, force: options.force, rules: options.rules });
90
+ return;
91
+ case "uninstall":
92
+ runUninstall(options.purge);
93
+ return;
94
+ case "theme":
95
+ runTheme(options.args);
96
+ return;
97
+ case "hud":
98
+ runHud(options);
99
+ return;
100
+ case "share":
101
+ case "priority":
102
+ case "release":
103
+ case "pin":
104
+ case "park":
105
+ runSet(options);
106
+ return;
107
+ case "policy":
108
+ runPolicy(options);
109
+ return;
110
+ case "defer":
111
+ runDefer(options);
112
+ return;
113
+ case "audit":
114
+ await runAudit(options);
115
+ return;
116
+ case "watch":
117
+ await runWatch(options);
118
+ return;
119
+ case "history":
120
+ process.stdout.write(renderHistory(loadRuns()));
121
+ return;
122
+ case "privacy":
123
+ runPrivacy();
124
+ return;
125
+ case "unknown": {
126
+ const name = options.args[0] ?? "";
127
+ const known = [...KNOWN_COMMANDS].filter((command) => command.startsWith(name[0] ?? "")).slice(0, 4);
128
+ process.stderr.write(`savemytokens: no command called ${name}.\n` +
129
+ (known.length > 0 ? `Did you mean: ${known.join(", ")}?\n` : "") +
130
+ "Run savemytokens --help for the list.\n");
131
+ process.exitCode = 1;
132
+ return;
133
+ }
134
+ default:
135
+ await runControl(options);
136
+ }
137
+ }
138
+ main().catch((error) => {
139
+ process.stderr.write(`savemytokens: ${error.message}\n`);
140
+ process.exitCode = 1;
141
+ });
@@ -0,0 +1,62 @@
1
+ import { activeAdapters } from "./adapters/index.js";
2
+ import { EvidenceCache } from "./storage/cache.js";
3
+ async function pool(items, limit, worker) {
4
+ let cursor = 0;
5
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
6
+ for (;;) {
7
+ const index = cursor++;
8
+ if (index >= items.length)
9
+ return;
10
+ const item = items[index];
11
+ if (item === undefined)
12
+ return;
13
+ await worker(item, index);
14
+ }
15
+ });
16
+ await Promise.all(runners);
17
+ }
18
+ export async function collect(options) {
19
+ const now = Date.now();
20
+ const since = now - options.days * 24 * 60 * 60 * 1000;
21
+ const sessions = [];
22
+ const adapters = activeAdapters();
23
+ const refs = [];
24
+ for (const adapter of adapters) {
25
+ const cache = new EvidenceCache(adapter.id);
26
+ for (const ref of adapter.discover({ since, project: options.project })) {
27
+ refs.push({ ref, cache });
28
+ }
29
+ }
30
+ let done = 0;
31
+ await pool(refs, options.concurrency ?? 8, async ({ ref, cache }) => {
32
+ const cached = cache.get(ref.file, ref.size, ref.mtimeMs);
33
+ if (cached) {
34
+ sessions.push(cached);
35
+ }
36
+ else {
37
+ const adapter = adapters.find((a) => a.id === ref.adapter);
38
+ const evidence = adapter ? await adapter.parse(ref) : null;
39
+ if (evidence) {
40
+ cache.set(evidence);
41
+ sessions.push(evidence);
42
+ }
43
+ }
44
+ done++;
45
+ options.onProgress?.(done, refs.length);
46
+ });
47
+ const caches = new Set(refs.map((r) => r.cache));
48
+ for (const cache of caches)
49
+ cache.flush();
50
+ sessions.sort((a, b) => a.startedAt - b.startedAt);
51
+ return {
52
+ scope: {
53
+ adapters: adapters.map((a) => a.id),
54
+ days: options.days,
55
+ project: options.project,
56
+ sessions: sessions.length,
57
+ from: since,
58
+ to: now,
59
+ },
60
+ sessions,
61
+ };
62
+ }
@@ -0,0 +1,74 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { activeAdapters, pendingDetected } from "../adapters/index.js";
4
+ import { encodeProject } from "../adapters/claude-code/index.js";
5
+ import { analyze } from "../analyze/index.js";
6
+ import { collect } from "../collect.js";
7
+ import { renderAudit } from "../report/render.js";
8
+ import { loadRuns, previousRun, saveRun } from "../storage/store.js";
9
+ import { dim } from "../util/ansi.js";
10
+ import { hookInstalled, nudgeStats } from "./install.js";
11
+ function progress(done, total) {
12
+ if (!process.stderr.isTTY || total < 12)
13
+ return;
14
+ const pct = Math.round((done / total) * 100);
15
+ process.stderr.write(`\r${dim(`reading sessions ${pct}%`)}`);
16
+ if (done === total)
17
+ process.stderr.write("\r" + " ".repeat(24) + "\r");
18
+ }
19
+ function hasLocalData(cwd) {
20
+ for (const adapter of activeAdapters()) {
21
+ if (adapter.id !== "claude-code")
22
+ continue;
23
+ try {
24
+ if (fs.existsSync(path.join(adapter.dataDir, encodeProject(cwd))))
25
+ return true;
26
+ }
27
+ catch {
28
+ continue;
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+ export function defaultProject(options) {
34
+ if (options.projectExplicit)
35
+ return options.project;
36
+ const cwd = process.cwd();
37
+ return hasLocalData(cwd) ? cwd : null;
38
+ }
39
+ export async function runAudit(options) {
40
+ if (activeAdapters().length === 0) {
41
+ process.stdout.write("\nNo Claude Code or Codex data found.\nRun one of them once, then try again.\n\n");
42
+ return null;
43
+ }
44
+ const project = defaultProject(options);
45
+ const corpus = await collect({
46
+ days: options.days,
47
+ project,
48
+ onProgress: options.json ? undefined : progress,
49
+ });
50
+ const audit = analyze(corpus);
51
+ const runs = loadRuns();
52
+ const previous = previousRun(audit, runs);
53
+ if (options.json) {
54
+ process.stdout.write(JSON.stringify({ audit, previous }, null, 2) + "\n");
55
+ }
56
+ else {
57
+ process.stdout.write(renderAudit({
58
+ audit,
59
+ previous,
60
+ history: runs,
61
+ nudges: nudgeStats(),
62
+ installed: hookInstalled(),
63
+ verbose: options.verbose,
64
+ }));
65
+ if (options.verbose) {
66
+ for (const pending of pendingDetected()) {
67
+ process.stdout.write(dim(`${pending.label} found, but skipped: ${pending.reason}.\n`));
68
+ }
69
+ }
70
+ }
71
+ if (options.save)
72
+ saveRun(audit);
73
+ return audit;
74
+ }