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,71 @@
1
+ import { HUD_LAYOUTS, builtinThemes, loadConfig, loadTheme, renderHud, saveConfig, userThemes, windowBounds, } from "../runtime/kernel.mjs";
2
+ import { buildPlan } from "../scheduler/plan.js";
3
+ import { bold, colorEnabled, dim, green, padEndVisible } from "../util/ansi.js";
4
+ function sampleView(options) {
5
+ const control = buildPlan(Date.now(), true, options.window, options.adapter);
6
+ const now = control.schedule.now;
7
+ const bounds = windowBounds(control.schedule.quota, "five_hour", now);
8
+ const live = control.schedule.claimants.find((view) => view.state === "active") ?? control.schedule.claimants[0];
9
+ const quota = {};
10
+ for (const key of ["five_hour", "seven_day", "spend_limit"]) {
11
+ const window = control.schedule.quota?.windows?.[key];
12
+ if (window && (typeof window.resetsAt !== "number" || window.resetsAt * 1000 > now))
13
+ quota[key] = window;
14
+ }
15
+ const history = (control.schedule.quota?.history ?? [])
16
+ .filter((point) => typeof point.five_hour === "number" && point.at >= bounds.from)
17
+ .map((point) => point.five_hour);
18
+ return {
19
+ label: live?.claimant.label || "session",
20
+ target: live?.allocation.target ?? 1,
21
+ observed: live?.observed ?? 0,
22
+ used: live?.attributedPercent ?? null,
23
+ pressure: live?.pressure.value ?? 0,
24
+ priority: live?.claimant.priority ?? "normal",
25
+ quota,
26
+ history,
27
+ rate: null,
28
+ from: bounds.from,
29
+ to: bounds.to,
30
+ now,
31
+ };
32
+ }
33
+ export function runHud(options) {
34
+ const config = loadConfig();
35
+ const [first, second] = options.args;
36
+ if (first && HUD_LAYOUTS.includes(first)) {
37
+ config.layout.hud = first;
38
+ if (second)
39
+ config.theme.hud = second;
40
+ saveConfig(config);
41
+ process.stdout.write(`\n${green("Status line")} ${bold(first)}${second ? ` · theme ${bold(second)}` : ""}\n\n`);
42
+ return;
43
+ }
44
+ if (first) {
45
+ process.stdout.write(`\nNo layout called ${bold(first)}. Known: ${HUD_LAYOUTS.join(", ")}\n\n`);
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ const view = sampleView(options);
50
+ const themeName = config.theme.hud;
51
+ const theme = loadTheme(themeName);
52
+ const out = ["", bold("Status line layouts"), dim(" what Claude Code shows you, on your own numbers"), ""];
53
+ for (const layout of HUD_LAYOUTS) {
54
+ const marker = layout === config.layout.hud ? green("→") : " ";
55
+ out.push(` ${marker} ${dim(padEndVisible(layout, 11))} ${renderHud(layout, view, theme, colorEnabled)}`);
56
+ }
57
+ out.push("");
58
+ out.push(bold("The same layout in every theme"));
59
+ out.push("");
60
+ const themes = [...new Set([...builtinThemes(), ...userThemes()])];
61
+ for (const name of themes) {
62
+ const marker = name === themeName ? green("→") : " ";
63
+ out.push(` ${marker} ${dim(padEndVisible(name, 11))} ${renderHud(config.layout.hud, view, loadTheme(name), colorEnabled)}`);
64
+ }
65
+ out.push("");
66
+ out.push(dim(" npx savemytokens hud blocks set the layout"));
67
+ out.push(dim(" npx savemytokens hud blocks nord layout and theme together"));
68
+ out.push(dim(" npx savemytokens theme hud dracula theme only"));
69
+ out.push("");
70
+ process.stdout.write(out.join("\n") + "\n");
71
+ }
@@ -0,0 +1,369 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { RULES_BLOCK, RULES_END, RULES_START } from "../hooks/rules.js";
6
+ import { HOME, HOOKS_DIR, loadConfig, saveConfig } from "../runtime/kernel.mjs";
7
+ import { bold, dim, green, yellow } from "../util/ansi.js";
8
+ const CLAUDE_HOME = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
9
+ const SETTINGS = process.env.SAVEMYTOKENS_SETTINGS || path.join(CLAUDE_HOME, "settings.json");
10
+ const MEMORY = process.env.SAVEMYTOKENS_MEMORY || path.join(CLAUDE_HOME, "CLAUDE.md");
11
+ const RUNTIME_FILES = ["kernel.mjs", "hook.mjs", "statusline.mjs"];
12
+ export const HOOK_EVENTS = [
13
+ ["SessionStart", "session-start"],
14
+ ["UserPromptSubmit", "prompt"],
15
+ ["Stop", "stop"],
16
+ ["SessionEnd", "session-end"],
17
+ ];
18
+ function quoted(value) {
19
+ return /[\s"']/.test(value) ? `"${value}"` : value;
20
+ }
21
+ export function installPlan() {
22
+ const short = (full) => full.replace(os.homedir(), "~");
23
+ const rel = (name) => path.relative(os.homedir(), hookPath(name)).replace(/^\.savemytokens\//, "");
24
+ return [
25
+ {
26
+ file: short(SETTINGS),
27
+ lines: [
28
+ `+ statusLine ${rel("statusline.mjs")}`,
29
+ ...HOOK_EVENTS.map(([event, action]) => `+ ${event.padEnd(17)} ${rel("hook.mjs")} ${action}`),
30
+ `copied first to ${short(path.join(HOME, "settings.backup.json"))}`,
31
+ ],
32
+ },
33
+ {
34
+ file: `${short(HOOKS_DIR)}`,
35
+ lines: ["+ kernel.mjs, hook.mjs, statusline.mjs"],
36
+ },
37
+ ];
38
+ }
39
+ export function hookPath(name) {
40
+ return path.join(HOOKS_DIR, name);
41
+ }
42
+ function hookCommand(event) {
43
+ return `${quoted(process.execPath)} ${quoted(hookPath("hook.mjs"))} ${event}`;
44
+ }
45
+ function statusLineCommand() {
46
+ return `${quoted(process.execPath)} ${quoted(hookPath("statusline.mjs"))}`;
47
+ }
48
+ function ourCommand(command) {
49
+ if (typeof command !== "string")
50
+ return false;
51
+ return command.includes(HOOKS_DIR) || command.includes("savemytokens") || command.includes("nudge.cjs");
52
+ }
53
+ function isOurs(entry) {
54
+ if (ourCommand(entry.command))
55
+ return true;
56
+ return Array.isArray(entry.hooks) && entry.hooks.some(isOurs);
57
+ }
58
+ function readSettings() {
59
+ let raw;
60
+ try {
61
+ raw = fs.readFileSync(SETTINGS, "utf8");
62
+ }
63
+ catch {
64
+ return {};
65
+ }
66
+ if (raw.trim().length === 0)
67
+ return {};
68
+ try {
69
+ const parsed = JSON.parse(raw);
70
+ return parsed && typeof parsed === "object" ? parsed : null;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ function unreadableSettings() {
77
+ process.stdout.write(`\n${bold("SaveMyTokens")}\n\n${SETTINGS} is not valid JSON, so it could not be read.\nRefusing to touch it. Fix the file and try again. Nothing was changed.\n\n`);
78
+ process.exitCode = 1;
79
+ }
80
+ function writeSettings(settings) {
81
+ fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
82
+ fs.writeFileSync(SETTINGS, JSON.stringify(settings, null, 2) + "\n");
83
+ }
84
+ function readMemory() {
85
+ try {
86
+ return fs.readFileSync(MEMORY, "utf8");
87
+ }
88
+ catch {
89
+ return "";
90
+ }
91
+ }
92
+ function stripRules(text) {
93
+ const start = text.indexOf(RULES_START);
94
+ const end = text.indexOf(RULES_END);
95
+ if (start === -1 || end === -1)
96
+ return text;
97
+ return (text.slice(0, start) + text.slice(end + RULES_END.length)).replace(/\n{3,}/g, "\n\n").trimEnd();
98
+ }
99
+ function writeRules() {
100
+ const body = stripRules(readMemory());
101
+ const next = body.length > 0 ? body + "\n\n" + RULES_BLOCK + "\n" : RULES_BLOCK + "\n";
102
+ fs.mkdirSync(path.dirname(MEMORY), { recursive: true });
103
+ fs.writeFileSync(MEMORY, next);
104
+ }
105
+ export function rulesInstalled() {
106
+ return readMemory().includes(RULES_START);
107
+ }
108
+ export function hookInstalled() {
109
+ return fs.existsSync(hookPath("hook.mjs"));
110
+ }
111
+ function copyRuntime() {
112
+ const from = fileURLToPath(new URL("../runtime/", import.meta.url));
113
+ fs.mkdirSync(HOOKS_DIR, { recursive: true });
114
+ for (const name of RUNTIME_FILES) {
115
+ fs.copyFileSync(path.join(from, name), path.join(HOOKS_DIR, name));
116
+ }
117
+ fs.chmodSync(path.join(HOOKS_DIR, "hook.mjs"), 0o755);
118
+ fs.chmodSync(path.join(HOOKS_DIR, "statusline.mjs"), 0o755);
119
+ }
120
+ function addHooks(settings) {
121
+ const hooks = (settings.hooks ??= {});
122
+ let added = 0;
123
+ for (const [event, action] of HOOK_EVENTS) {
124
+ const entries = Array.isArray(hooks[event]) ? hooks[event] : [];
125
+ const kept = entries.filter((entry) => !isOurs(entry));
126
+ kept.push({ hooks: [{ type: "command", command: hookCommand(action), timeout: 10 }] });
127
+ hooks[event] = kept;
128
+ added++;
129
+ }
130
+ return added;
131
+ }
132
+ export function runInstall(options) {
133
+ const parsed = readSettings();
134
+ if (parsed === null) {
135
+ unreadableSettings();
136
+ return;
137
+ }
138
+ if (sandboxMismatch()) {
139
+ process.stdout.write(`\n${bold("SaveMyTokens")}\n\nHalf of this run is sandboxed and half is not: state is ${HOME}\nand settings are ${SETTINGS}. Refusing to touch either. Override\nSAVEMYTOKENS_HOME and SAVEMYTOKENS_SETTINGS together, or neither.\n\n`);
140
+ process.exitCode = 1;
141
+ return;
142
+ }
143
+ const settings = parsed;
144
+ const existingStatusLine = settings.statusLine?.command;
145
+ const ours = ourCommand(existingStatusLine);
146
+ const conflict = typeof existingStatusLine === "string" && !ours;
147
+ const out = ["", bold("SaveMyTokens"), ""];
148
+ out.push("It gives every Claude Code session a target share of your Claude window, and tells");
149
+ out.push("Claude what share it is working within.");
150
+ out.push("");
151
+ out.push(bold("What it does to your machine"));
152
+ out.push("");
153
+ out.push(` writes ${HOOKS_DIR}/{kernel,hook,statusline}.mjs`);
154
+ out.push(` adds ${HOOK_EVENTS.map(([event]) => event).join(", ")} hooks to ${SETTINGS}`);
155
+ out.push(conflict && !options.force
156
+ ? ` keeps your existing status line ${dim("(--force wraps it and appends the SMT segment)")}`
157
+ : conflict
158
+ ? ` wraps your existing status line, then appends the SMT segment`
159
+ : ` sets the status line, the only place Anthropic publishes your 5h and 7d usage`);
160
+ out.push(` backs up the current settings to ${path.join(HOME, "settings.backup.json")}`);
161
+ if (options.rules)
162
+ out.push(` adds a fenced token-discipline block to ${MEMORY}`);
163
+ out.push("");
164
+ out.push(dim(" Hooks read your transcripts, write only to ~/.savemytokens, never block a prompt,"));
165
+ out.push(dim(" never make a network call, and exit 0 on every path. Remove with: npx savemytokens uninstall"));
166
+ out.push("");
167
+ if (conflict && !options.force) {
168
+ out.push(yellow(" Your status line is already set to something else:"));
169
+ out.push(dim(` ${existingStatusLine}`));
170
+ out.push(" Without it SMT cannot read your published 5h/7d usage. Every other part still works.");
171
+ out.push(` Run ${bold("npx savemytokens install --force")} to keep yours and append the SMT segment.`);
172
+ out.push("");
173
+ }
174
+ if (options.quiet && !options.dryRun) {
175
+ fs.mkdirSync(HOME, { recursive: true });
176
+ if (fs.existsSync(SETTINGS))
177
+ fs.copyFileSync(SETTINGS, path.join(HOME, "settings.backup.json"));
178
+ copyRuntime();
179
+ addHooks(settings);
180
+ const quietConfig = loadConfig();
181
+ if (!conflict || options.force) {
182
+ if (conflict && options.force)
183
+ quietConfig.wrappedStatusLine = existingStatusLine;
184
+ settings.statusLine = { type: "command", command: statusLineCommand(), padding: 0, refreshInterval: 10 };
185
+ }
186
+ if (!quietConfig.createdAt)
187
+ quietConfig.createdAt = Date.now();
188
+ saveConfig(quietConfig);
189
+ writeSettings(settings);
190
+ return;
191
+ }
192
+ if (options.dryRun) {
193
+ const preview = {
194
+ statusLine: { type: "command", command: statusLineCommand(), padding: 0, refreshInterval: 10 },
195
+ hooks: Object.fromEntries(HOOK_EVENTS.map(([event, action]) => [event, [{ hooks: [{ type: "command", command: hookCommand(action), timeout: 10 }] }]])),
196
+ };
197
+ out.push(bold("settings.json gains"));
198
+ out.push("");
199
+ for (const line of JSON.stringify(preview, null, 2).split("\n"))
200
+ out.push(` ${line}`);
201
+ out.push("");
202
+ out.push(dim("--dry-run: nothing was written."));
203
+ out.push("");
204
+ process.stdout.write(out.join("\n") + "\n");
205
+ return;
206
+ }
207
+ fs.mkdirSync(HOME, { recursive: true });
208
+ if (fs.existsSync(SETTINGS))
209
+ fs.copyFileSync(SETTINGS, path.join(HOME, "settings.backup.json"));
210
+ copyRuntime();
211
+ if (options.rules)
212
+ writeRules();
213
+ addHooks(settings);
214
+ const config = loadConfig();
215
+ if (!conflict || options.force) {
216
+ if (conflict && options.force)
217
+ config.wrappedStatusLine = existingStatusLine;
218
+ settings.statusLine = { type: "command", command: statusLineCommand(), padding: 0, refreshInterval: 10 };
219
+ }
220
+ if (!config.createdAt)
221
+ config.createdAt = Date.now();
222
+ saveConfig(config);
223
+ writeSettings(settings);
224
+ out.push(`${green("Installed.")} It takes effect in sessions you start from now on.`);
225
+ out.push(dim(" Open the control centre with: npx savemytokens"));
226
+ out.push("");
227
+ if (!options.quiet)
228
+ process.stdout.write(out.join("\n") + "\n");
229
+ }
230
+ function sandboxMismatch() {
231
+ const homeOverridden = Boolean(process.env.SAVEMYTOKENS_HOME);
232
+ const settingsChosen = Boolean(process.env.SAVEMYTOKENS_SETTINGS || process.env.CLAUDE_CONFIG_DIR);
233
+ return homeOverridden !== settingsChosen;
234
+ }
235
+ function pointedAtTheRealHome() {
236
+ if (!process.env.SAVEMYTOKENS_HOME)
237
+ return false;
238
+ return path.resolve(HOME) === path.resolve(os.homedir(), ".savemytokens");
239
+ }
240
+ function refusePointingHere() {
241
+ process.stdout.write(`\n${bold("SaveMyTokens")}\n\nSAVEMYTOKENS_HOME is set to ${HOME}, which is the real one.\nRefusing to delete anything. Leave the variable unset to work on your own\nstate, or point it somewhere else.\n\n`);
242
+ process.exitCode = 1;
243
+ }
244
+ export function runUninstall(purge) {
245
+ const parsed = readSettings();
246
+ if (parsed === null) {
247
+ unreadableSettings();
248
+ return;
249
+ }
250
+ if (sandboxMismatch()) {
251
+ process.stdout.write(`\n${bold("SaveMyTokens")}\n\nHalf of this run is sandboxed and half is not: state is ${HOME}\nand settings are ${SETTINGS}. Refusing to touch either. Override\nSAVEMYTOKENS_HOME and SAVEMYTOKENS_SETTINGS together, or neither.\n\n`);
252
+ process.exitCode = 1;
253
+ return;
254
+ }
255
+ if (pointedAtTheRealHome()) {
256
+ refusePointingHere();
257
+ return;
258
+ }
259
+ const settings = parsed;
260
+ let removed = 0;
261
+ for (const [event] of HOOK_EVENTS) {
262
+ const entries = Array.isArray(settings.hooks?.[event]) ? settings.hooks[event] : [];
263
+ const kept = entries.filter((entry) => !isOurs(entry));
264
+ removed += entries.length - kept.length;
265
+ if (kept.length > 0)
266
+ settings.hooks[event] = kept;
267
+ else if (settings.hooks)
268
+ delete settings.hooks[event];
269
+ }
270
+ const config = loadConfig();
271
+ let statusLineRestored = false;
272
+ if (ourCommand(settings.statusLine?.command)) {
273
+ if (config.wrappedStatusLine) {
274
+ settings.statusLine = { type: "command", command: config.wrappedStatusLine, padding: 0 };
275
+ statusLineRestored = true;
276
+ }
277
+ else {
278
+ delete settings.statusLine;
279
+ }
280
+ removed++;
281
+ }
282
+ if (removed > 0)
283
+ writeSettings(settings);
284
+ try {
285
+ fs.rmSync(HOOKS_DIR, { recursive: true, force: true });
286
+ }
287
+ catch { }
288
+ const memory = readMemory();
289
+ const strippedRules = memory.includes(RULES_START);
290
+ if (strippedRules)
291
+ fs.writeFileSync(MEMORY, stripRules(memory) + "\n");
292
+ const defaultHome = path.resolve(HOME) === path.resolve(os.homedir(), ".savemytokens");
293
+ let purged = false;
294
+ if (purge && defaultHome && !process.stdin.isTTY) {
295
+ process.stdout.write(`\n${bold("SaveMyTokens")}\n\nRefusing to delete ${HOME} from a non-interactive run.\nRun it from a terminal, or delete the directory yourself.\n\n`);
296
+ process.exitCode = 1;
297
+ return;
298
+ }
299
+ if (purge) {
300
+ try {
301
+ fs.rmSync(HOME, { recursive: true, force: true });
302
+ purged = true;
303
+ }
304
+ catch { }
305
+ }
306
+ const out = ["", bold("SaveMyTokens"), ""];
307
+ out.push(removed > 0 || strippedRules
308
+ ? "Removed the hooks, the status line and the scripts. Nothing else was touched."
309
+ : "Nothing was installed.");
310
+ if (statusLineRestored)
311
+ out.push(dim("Your own status line is back in place."));
312
+ if (purged) {
313
+ out.push(dim(`Deleted ${HOME}.`));
314
+ }
315
+ else {
316
+ const kept = describeState();
317
+ out.push("");
318
+ out.push(`${dim("Kept")} ${HOME}${kept ? dim(` (${kept})`) : ""}`);
319
+ out.push(dim(" Your allocations, priorities and deferred notes live here. Measured"));
320
+ out.push(dim(" usage rebuilds itself from transcripts; those settings do not."));
321
+ out.push("");
322
+ out.push(` ${dim("npx savemytokens uninstall --purge")}`);
323
+ }
324
+ out.push("");
325
+ process.stdout.write(out.join("\n") + "\n");
326
+ }
327
+ function describeState() {
328
+ try {
329
+ const parts = [];
330
+ let bytes = 0;
331
+ const walk = (dir) => {
332
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
333
+ const full = path.join(dir, entry.name);
334
+ if (entry.isDirectory())
335
+ walk(full);
336
+ else
337
+ bytes += fs.statSync(full).size;
338
+ }
339
+ };
340
+ walk(HOME);
341
+ parts.push(bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`);
342
+ const root = path.join(HOME, "projects");
343
+ let projects = 0;
344
+ for (const adapter of fs.readdirSync(root)) {
345
+ projects += fs.readdirSync(path.join(root, adapter)).filter((name) => name.endsWith(".json")).length;
346
+ }
347
+ if (projects > 0)
348
+ parts.push(`${projects} ${projects === 1 ? "project" : "projects"}`);
349
+ return parts.join(" · ");
350
+ }
351
+ catch {
352
+ return "";
353
+ }
354
+ }
355
+ export function nudgeStats() {
356
+ try {
357
+ const parsed = JSON.parse(fs.readFileSync(path.join(HOME, "nudges.json"), "utf8"));
358
+ if (!Array.isArray(parsed.events) || parsed.events.length === 0)
359
+ return null;
360
+ return {
361
+ installedAt: parsed.installedAt ?? parsed.events[0]?.at ?? Date.now(),
362
+ fired: parsed.events.length,
363
+ usdAtStake: parsed.events.reduce((sum, event) => sum + (event.usd ?? 0), 0),
364
+ };
365
+ }
366
+ catch {
367
+ return null;
368
+ }
369
+ }
@@ -0,0 +1,93 @@
1
+ import { POLICIES, deferredProjects, loadConfig, policyFor, policyNames } from "../runtime/kernel.mjs";
2
+ import { forgetDeferred, savePreference, setPolicy } from "../scheduler/plan.js";
3
+ import { bold, dim, green } from "../util/ansi.js";
4
+ const PRESERVE_KINDS = ["implementation", "tests", "end-to-end checks", "documentation", "exploration"];
5
+ function show(project) {
6
+ const config = loadConfig();
7
+ const active = policyFor(config, project);
8
+ const preserve = config.preserveFor[project] ?? config.preserveFor.default ?? [];
9
+ const out = ["", bold("When the window gets tight"), ""];
10
+ out.push(` policy ${bold(active.name)} ${dim(`· ${active.summary}`)}`);
11
+ out.push(` preserve ${preserve.length > 0 ? preserve.join(", ") : dim("testing and finalisation (default)")}`);
12
+ out.push("");
13
+ for (const name of policyNames()) {
14
+ const policy = POLICIES[name];
15
+ if (!policy)
16
+ continue;
17
+ const marker = name === active.name ? green("→") : " ";
18
+ const stages = policy.stages.length > 0
19
+ ? policy.stages.map((stage) => `${stage.at}% ${stage.actions.join("+")}`).join(" ")
20
+ : "nothing is ever injected";
21
+ out.push(` ${marker} ${bold(name.padEnd(8))} ${dim(stages)}`);
22
+ }
23
+ out.push("");
24
+ out.push(dim(" focus stay on completion, batch tool calls, stop wide reading"));
25
+ out.push(dim(" narrow cut scope to the smallest done version, start nothing new"));
26
+ out.push(dim(" defer write what is dropped as SMT: DEFER, and get it back next session"));
27
+ out.push(dim(" verify finish what is open, run the tests, leave the tree clean"));
28
+ out.push(dim(" handoff say where it stopped, then DONE / NEEDS_MORE / BLOCKED"));
29
+ out.push("");
30
+ out.push(dim(" npx savemytokens policy strict set it everywhere"));
31
+ out.push(dim(" npx savemytokens policy strict --here set it for this project only"));
32
+ out.push(dim(" npx savemytokens policy preserve tests documentation"));
33
+ out.push("");
34
+ process.stdout.write(out.join("\n") + "\n");
35
+ }
36
+ export function runPolicy(options) {
37
+ const project = options.project ?? process.cwd();
38
+ const [first, ...rest] = options.args;
39
+ if (!first) {
40
+ show(project);
41
+ return;
42
+ }
43
+ if (first === "preserve") {
44
+ const kinds = rest
45
+ .flatMap((value) => value.split(","))
46
+ .map((value) => value.trim().toLowerCase())
47
+ .filter(Boolean)
48
+ .map((value) => PRESERVE_KINDS.find((kind) => kind.startsWith(value)) ?? value);
49
+ if (kinds.length === 0) {
50
+ process.stdout.write(`\nWhat should be preserved? ${dim(PRESERVE_KINDS.join(", "))}\n\n`);
51
+ process.exitCode = 1;
52
+ return;
53
+ }
54
+ savePreference(options.projectExplicit ? project : "default", kinds);
55
+ process.stdout.write(`\n${green("Will preserve")} ${bold(kinds.join(", "))}\n\n`);
56
+ return;
57
+ }
58
+ if (!setPolicy(first, options.projectExplicit ? project : null)) {
59
+ process.stdout.write(`\nNo policy called ${bold(first)}. Known: ${policyNames().join(", ")}\n\n`);
60
+ process.exitCode = 1;
61
+ return;
62
+ }
63
+ const scope = options.projectExplicit ? `for ${project.split("/").pop()}` : "everywhere";
64
+ process.stdout.write(`\n${green("Policy")} ${bold(first)} ${dim(scope)}\n\n`);
65
+ }
66
+ export function runDefer(options) {
67
+ const project = options.project ?? process.cwd();
68
+ const [action] = options.args;
69
+ const adapter = options.adapter;
70
+ if (action === "clear") {
71
+ const groups = deferredProjects(adapter);
72
+ const targets = options.args[1] === "all" ? groups.map((group) => group.project) : [project];
73
+ for (const target of targets)
74
+ forgetDeferred(target, adapter);
75
+ process.stdout.write(`\n${green("Cleared")} deferred work for ${bold(targets.map((value) => value.split("/").pop()).join(", "))}\n\n`);
76
+ return;
77
+ }
78
+ const groups = deferredProjects(adapter);
79
+ const out = ["", bold("Deferred work"), ""];
80
+ if (groups.length === 0) {
81
+ out.push(dim(" Nothing yet. Sessions add to this when they report SMT: DEFER <one line>."));
82
+ }
83
+ for (const group of groups) {
84
+ out.push(` ${bold(group.project.split("/").pop() ?? group.project)}`);
85
+ for (const item of group.items)
86
+ out.push(` · ${item.text}`);
87
+ out.push("");
88
+ }
89
+ out.push(dim(" It is injected at the start of the next session in that project."));
90
+ out.push(dim(" npx savemytokens defer clear --here"));
91
+ out.push("");
92
+ process.stdout.write(out.join("\n") + "\n");
93
+ }
@@ -0,0 +1,28 @@
1
+ import { claudeCodeAdapter } from "../adapters/claude-code/index.js";
2
+ import { displayHome } from "../storage/paths.js";
3
+ import { bold, dim } from "../util/ansi.js";
4
+ export function runPrivacy() {
5
+ const out = ["", bold("SaveMyTokens"), "", bold("What it reads"), ""];
6
+ out.push(` ${claudeCodeAdapter.dataDir}${dim(" (Claude Code's own session logs, read-only)")}`);
7
+ out.push("");
8
+ out.push(bold("What it writes"));
9
+ out.push("");
10
+ out.push(` ${displayHome()}/claimants/ ${dim("one file per session: label, target share, priority, state")}`);
11
+ out.push(` ${displayHome()}/meter/ ${dim("token counts in five-minute buckets, and how far each transcript was read")}`);
12
+ out.push(` ${displayHome()}/quota/ ${dim("the 5h and 7d percentages Anthropic publishes to the status line")}`);
13
+ out.push(` ${displayHome()}/hooks/ ${dim("the three scripts install puts there")}`);
14
+ out.push(` ${displayHome()}/cache/ ${dim("per-session audit measurements, so repeat runs are fast")}`);
15
+ out.push(dim(" · includes the first 120 characters of each prompt, so a finding or a row can"));
16
+ out.push(dim(" name the task it came from"));
17
+ out.push(` ${displayHome()}/runs.json ${dim("one line per audit run: score, waste ratios, token totals")}`);
18
+ out.push("");
19
+ out.push(dim(" Nothing outside that directory is created or modified, apart from the hook and"));
20
+ out.push(dim(" status line entries install adds to Claude Code's own settings.json. No project"));
21
+ out.push(dim(" file is touched unless you pass --rules."));
22
+ out.push("");
23
+ out.push(bold("What leaves this machine"));
24
+ out.push("");
25
+ out.push(" Nothing. There is no network call in this tool.");
26
+ out.push("");
27
+ process.stdout.write(out.join("\n") + "\n");
28
+ }
@@ -0,0 +1,83 @@
1
+ import { buildPlan, resolveClaimant, setParked, setPinned, setPriority, setShare, setState } from "../scheduler/plan.js";
2
+ import { bold, dim, green, yellow } from "../util/ansi.js";
3
+ const PRIORITIES = ["high", "normal", "low"];
4
+ const STATES = {
5
+ done: "done",
6
+ blocked: "blocked",
7
+ active: "active",
8
+ "needs-more": "needs-more",
9
+ };
10
+ function fail(message) {
11
+ process.stdout.write(`\n${message}\n\n`);
12
+ process.exitCode = 1;
13
+ }
14
+ function percentOf(value) {
15
+ return `${Math.round(value * 100)}%`;
16
+ }
17
+ export function runSet(options) {
18
+ const [target, value] = options.args;
19
+ const command = options.command;
20
+ if (!target) {
21
+ fail(`Which project? ${dim(`try: npx savemytokens ${command} <project> ${command === "release" ? "" : "<value>"}`)}`);
22
+ return;
23
+ }
24
+ const control = buildPlan(Date.now(), true, options.window, options.adapter);
25
+ const found = resolveClaimant(control.schedule, target);
26
+ if (!found) {
27
+ const known = control.schedule.projects.map((view) => view.label).filter(Boolean);
28
+ fail(`No project matching ${bold(target)}. ${known.length > 0 ? dim(`Known: ${[...new Set(known)].join(", ")}`) : ""}`);
29
+ return;
30
+ }
31
+ const { view, matches } = found;
32
+ const note = matches > 1 ? dim(` (${matches} matched, took the busiest)`) : "";
33
+ if (command === "share") {
34
+ if (!value) {
35
+ fail(`What share? ${dim("try: npx savemytokens share webinvoke 50, or `auto` to unpin it")}`);
36
+ return;
37
+ }
38
+ if (value === "auto" || value === "even") {
39
+ setShare(view.project, null, control.provider.id);
40
+ process.stdout.write(`\n${green("Unpinned")} ${bold(view.label)}, it takes an even split again${note}\n\n`);
41
+ return;
42
+ }
43
+ const percent = Number(String(value).replace("%", ""));
44
+ if (!Number.isFinite(percent) || percent < 0 || percent > 100) {
45
+ fail(`${bold(String(value))} is not a percentage between 0 and 100.`);
46
+ return;
47
+ }
48
+ setShare(view.project, percent / 100, control.provider.id);
49
+ const after = buildPlan(Date.now(), false, options.window, options.adapter);
50
+ const updated = after.schedule.projects.find((row) => row.project === view.project);
51
+ const actual = updated ? percentOf(updated.allocation.target) : `${Math.round(percent)}%`;
52
+ const clamped = updated && Math.abs(updated.allocation.target * 100 - percent) > 0.5;
53
+ process.stdout.write(`\n${green("Set")} ${bold(view.label)} target to ${bold(actual)}${note}\n${clamped ? yellow(` asked for ${Math.round(percent)}%, but the window is already committed elsewhere\n`) : ""}\n`);
54
+ return;
55
+ }
56
+ if (command === "pin" || command === "park") {
57
+ const on = String(value ?? "on").toLowerCase() !== "off";
58
+ if (command === "pin")
59
+ setPinned(view.project, on, control.provider.id);
60
+ else
61
+ setParked(view.project, on, control.provider.id);
62
+ process.stdout.write(`\n${green(on ? (command === "pin" ? "Pinned" : "Parked") : "Cleared")} ${bold(view.label)}${note}\n${dim(command === "pin" ? " it stays visible even when it goes quiet" : " it drops out of the working set until you resume it")}\n\n`);
63
+ return;
64
+ }
65
+ if (command === "priority") {
66
+ const priority = String(value ?? "").toLowerCase();
67
+ if (!PRIORITIES.includes(priority)) {
68
+ fail(`Priority is one of ${PRIORITIES.join(", ")}.`);
69
+ return;
70
+ }
71
+ setPriority(view.project, priority, control.provider.id);
72
+ process.stdout.write(`\n${green("Set")} ${bold(view.label)} to ${bold(priority.toUpperCase())}${note}\n\n`);
73
+ return;
74
+ }
75
+ const state = STATES[String(value ?? "done").toLowerCase()];
76
+ if (!state) {
77
+ fail(`State is one of ${Object.keys(STATES).join(", ")}.`);
78
+ return;
79
+ }
80
+ setState(view.project, state, control.provider.id);
81
+ const released = state === "done" || state === "blocked";
82
+ process.stdout.write(`\n${green("Marked")} ${bold(view.label)} ${bold(state)}${note}\n${released ? dim(" its unused share goes back to the pool\n") : ""}\n`);
83
+ }