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,136 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { HUD_LAYOUTS, THEME_DIR, builtinThemes, loadConfig, loadTheme, saveConfig, userThemes, writeJson, } from "../runtime/kernel.mjs";
4
+ import { bold, dim, green, red } from "../util/ansi.js";
5
+ const LAYOUTS = HUD_LAYOUTS;
6
+ function scaffold(name, from) {
7
+ if (!name) {
8
+ process.stdout.write(`\nName it: ${bold("npx savemytokens theme new midnight")}\n\n`);
9
+ process.exitCode = 1;
10
+ return;
11
+ }
12
+ const file = path.join(THEME_DIR, `${name}.json`);
13
+ if (fs.existsSync(file)) {
14
+ process.stdout.write(`\n${file} already exists.\n\n`);
15
+ process.exitCode = 1;
16
+ return;
17
+ }
18
+ const base = loadTheme(from);
19
+ writeJson(file, { ...base, name });
20
+ fs.writeFileSync(file, JSON.stringify({ ...base, name }, null, 2) + "\n");
21
+ process.stdout.write(`\n${green("Wrote")} ${file}\n${dim(` edit it, then: npx savemytokens theme check ${name} && npx savemytokens theme tui ${name}`)}\n\n`);
22
+ }
23
+ const TEXT_ROLES = ["fg", "accent", "ok", "warn", "danger"];
24
+ const REQUIRED_TUI = ["cursor", "pin", "active", "done", "blocked", "idle", "fill", "empty", "over", "meter", "track"];
25
+ function channel(value) {
26
+ const v = value / 255;
27
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
28
+ }
29
+ function luminance(hex) {
30
+ if (!/^#[0-9a-f]{6}$/i.test(hex))
31
+ return null;
32
+ const n = Number.parseInt(hex.slice(1), 16);
33
+ return 0.2126 * channel((n >> 16) & 255) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255);
34
+ }
35
+ function contrast(hex, background) {
36
+ const first = luminance(hex);
37
+ const second = luminance(background);
38
+ if (first === null || second === null)
39
+ return null;
40
+ return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
41
+ }
42
+ function check(name, background) {
43
+ const known = new Set([...builtinThemes(), ...userThemes()]);
44
+ if (!known.has(name)) {
45
+ process.stdout.write(`\nNo theme called ${bold(name)}. Known: ${[...known].join(", ")}\n\n`);
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ const theme = loadTheme(name);
50
+ const out = ["", bold(`theme ${name}`), dim(` measured against ${background}`), ""];
51
+ let problems = 0;
52
+ for (const [role, value] of Object.entries(theme.colors)) {
53
+ const ratio = contrast(String(value), background);
54
+ if (ratio === null) {
55
+ out.push(` ${red("✗")} ${role.padEnd(8)} ${value} ${dim("is not a #rrggbb colour")}`);
56
+ problems++;
57
+ continue;
58
+ }
59
+ const floor = TEXT_ROLES.includes(role) ? 4.5 : role === "dim" ? 2.5 : 1.15;
60
+ const ceiling = role === "track" ? 4 : Infinity;
61
+ const ok = ratio >= floor && ratio <= ceiling;
62
+ if (!ok)
63
+ problems++;
64
+ out.push(` ${ok ? green("✓") : red("✗")} ${role.padEnd(8)} ${String(value).padEnd(9)} ${ratio.toFixed(1)}:1 ${dim(ok ? "" : ratio < floor ? `wants at least ${floor}:1` : `wants at most ${ceiling}:1`)}`);
65
+ }
66
+ const missing = REQUIRED_TUI.filter((key) => !(theme.tui ?? {})[key]);
67
+ if (missing.length > 0) {
68
+ out.push("");
69
+ out.push(` ${dim(`inherits ${missing.join(", ")} from the default theme`)}`);
70
+ }
71
+ out.push("");
72
+ out.push(problems === 0 ? green(" Readable everywhere.") : red(` ${problems} ${problems === 1 ? "problem" : "problems"} to fix.`));
73
+ out.push(dim(" --light checks against a white terminal instead"));
74
+ out.push("");
75
+ process.stdout.write(out.join("\n") + "\n");
76
+ if (problems > 0)
77
+ process.exitCode = 1;
78
+ }
79
+ export function runTheme(args) {
80
+ const config = loadConfig();
81
+ const surface = args[0];
82
+ const value = args[1];
83
+ if (surface === "new") {
84
+ scaffold(String(value ?? ""), String(args[2] ?? config.theme.tui));
85
+ return;
86
+ }
87
+ if (surface === "check") {
88
+ const light = args.includes("--light");
89
+ check(String(value ?? config.theme.tui), light ? "#ffffff" : "#1e1e1e");
90
+ return;
91
+ }
92
+ const target = surface === "tui" || surface === "hud" ? surface : null;
93
+ if (surface && !target) {
94
+ process.stderr.write(`\nNo theme surface called ${surface}. Use: theme tui <name>, theme hud <name>, theme check, theme new\n\n`);
95
+ process.exitCode = 1;
96
+ return;
97
+ }
98
+ if (!surface) {
99
+ const out = ["", bold("SaveMyTokens themes"), ""];
100
+ out.push(` tui ${config.theme.tui}`);
101
+ out.push(` hud ${config.theme.hud} ${dim(`· layout ${config.layout.hud}`)}`);
102
+ out.push("");
103
+ out.push(dim(` built in: ${builtinThemes().join(", ")}`));
104
+ const custom = userThemes().filter((name) => !builtinThemes().includes(name));
105
+ if (custom.length > 0)
106
+ out.push(dim(` yours: ${custom.join(", ")}`));
107
+ out.push(dim(` hud layouts: ${LAYOUTS.join(", ")}`));
108
+ out.push("");
109
+ out.push(dim(" npx savemytokens theme tui nord use it in the control centre"));
110
+ out.push(dim(" npx savemytokens theme new mine nord copy one to start from"));
111
+ out.push(dim(" npx savemytokens theme check mine is it readable?"));
112
+ out.push(dim(" they live in ~/.savemytokens/themes/<name>.json"));
113
+ out.push("");
114
+ process.stdout.write(out.join("\n") + "\n");
115
+ return;
116
+ }
117
+ if (!value) {
118
+ process.stdout.write(`\n ${target} theme is ${config.theme[target ?? "tui"]}\n\n`);
119
+ return;
120
+ }
121
+ if (target === "hud" && LAYOUTS.includes(value)) {
122
+ config.layout.hud = value;
123
+ saveConfig(config);
124
+ process.stdout.write(`\n${green("Set")} hud layout to ${bold(value)}\n\n`);
125
+ return;
126
+ }
127
+ const known = new Set([...builtinThemes(), ...userThemes()]);
128
+ if (!known.has(value)) {
129
+ process.stdout.write(`\nNo theme called ${bold(value)}. Known: ${[...known].join(", ")}\n\n`);
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ config.theme[target ?? "tui"] = value;
134
+ saveConfig(config);
135
+ process.stdout.write(`\n${green("Set")} ${target} theme to ${bold(value)}\n\n`);
136
+ }
@@ -0,0 +1,135 @@
1
+ import { activeAdapters } from "../adapters/index.js";
2
+ import { analyze } from "../analyze/index.js";
3
+ import { collect } from "../collect.js";
4
+ import { buildPlan } from "../scheduler/plan.js";
5
+ import { saveRun } from "../storage/store.js";
6
+ import { bold, dim, green, red, yellow } from "../util/ansi.js";
7
+ import { percent, plural } from "../util/fmt.js";
8
+ import { defaultProject } from "./audit.js";
9
+ const SCORE_STEP = 2;
10
+ const QUOTA_STEP = 5;
11
+ function stageOf(pressure) {
12
+ if (pressure >= 1)
13
+ return 100;
14
+ if (pressure >= 0.9)
15
+ return 90;
16
+ if (pressure >= 0.8)
17
+ return 80;
18
+ return 0;
19
+ }
20
+ function scheduleSnapshot(options) {
21
+ const control = buildPlan(Date.now(), true, options.window, options.adapter);
22
+ const snapshot = {
23
+ states: new Map(),
24
+ stages: new Map(),
25
+ labels: new Map(),
26
+ used: control.schedule.live ? control.schedule.live.usedPercent : null,
27
+ };
28
+ for (const view of control.schedule.claimants) {
29
+ snapshot.states.set(view.claimant.id, view.state);
30
+ snapshot.stages.set(view.claimant.id, stageOf(view.pressure.value));
31
+ snapshot.labels.set(view.claimant.id, view.claimant.label || view.claimant.id.slice(0, 8));
32
+ }
33
+ return { snapshot };
34
+ }
35
+ function driftEvents(previous, next) {
36
+ const events = [];
37
+ for (const [id, state] of next.states) {
38
+ const label = next.labels.get(id) ?? id.slice(0, 8);
39
+ const before = previous.states.get(id);
40
+ if (before === undefined) {
41
+ events.push(`${bold(label)} joined the window`);
42
+ continue;
43
+ }
44
+ if (before !== state && (state === "done" || state === "blocked")) {
45
+ events.push(`${bold(label)} ${state} ${dim("· its unused share is back in the pool")}`);
46
+ }
47
+ const stage = next.stages.get(id) ?? 0;
48
+ if (stage > (previous.stages.get(id) ?? 0)) {
49
+ const text = stage >= 100 ? "is over its target share" : `passed ${stage}% of its target share`;
50
+ events.push(`${stage >= 100 ? red(label) : yellow(label)} ${text}`);
51
+ }
52
+ }
53
+ if (previous.used !== null && next.used !== null && next.used - previous.used >= QUOTA_STEP) {
54
+ events.push(dim(`window ${Math.round(previous.used)}% → ${Math.round(next.used)}% used`));
55
+ }
56
+ return events;
57
+ }
58
+ function clock() {
59
+ const d = new Date();
60
+ const pad = (n) => String(n).padStart(2, "0");
61
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
62
+ }
63
+ function line(text) {
64
+ process.stdout.write(`${dim(clock())} ${text}\n`);
65
+ }
66
+ export async function runWatch(options) {
67
+ if (activeAdapters().length === 0) {
68
+ process.stdout.write(`\nSaveMyTokens\n\nNo Claude Code data found at ~/.claude/projects.\n\n`);
69
+ return;
70
+ }
71
+ process.stdout.write(`\n${bold("SaveMyTokens")} ${dim("watch")}\n\n`);
72
+ process.stdout.write(dim("Observing only. Nothing is modified, redirected, or uploaded.\n"));
73
+ process.stdout.write(dim("Reports allocation drift as well as new waste.\n"));
74
+ process.stdout.write(dim(`Window: last ${options.days} days${options.project ? " · this project" : ""} · checking every ${options.interval}s · Ctrl-C to stop\n\n`));
75
+ let baseline = null;
76
+ let stopping = false;
77
+ let schedule = null;
78
+ const tick = async () => {
79
+ const { snapshot } = scheduleSnapshot(options);
80
+ if (schedule)
81
+ for (const event of driftEvents(schedule, snapshot))
82
+ line(event);
83
+ schedule = snapshot;
84
+ const corpus = await collect({ days: options.days, project: defaultProject(options) });
85
+ const audit = analyze(corpus);
86
+ if (!baseline) {
87
+ baseline = audit;
88
+ const top = audit.findings[0];
89
+ line(`baseline ${bold(`${audit.score}/100`)} ${dim(`· ${audit.totals.sessions} ${plural(audit.totals.sessions, "session")} · ${audit.totals.turns} turns`)}${top ? dim(` · top: ${top.title.toLowerCase()}`) : ""}`);
90
+ if (options.save)
91
+ saveRun(audit);
92
+ return;
93
+ }
94
+ const previous = baseline;
95
+ const delta = audit.score - previous.score;
96
+ const newTurns = audit.totals.turns - previous.totals.turns;
97
+ const knownIds = new Set(previous.findings.map((f) => f.id));
98
+ const fresh = audit.findings.filter((f) => !knownIds.has(f.id) && f.wasteRatio >= 0.02);
99
+ for (const finding of fresh) {
100
+ line(`${yellow("new waste")} ${finding.title.toLowerCase()} ${dim(`· ${percent(finding.wasteRatio, 1)} of spend`)}`);
101
+ line(dim(` fix: ${finding.fix}`));
102
+ }
103
+ if (Math.abs(delta) >= SCORE_STEP) {
104
+ const marker = delta > 0 ? green(`+${delta}`) : red(String(delta));
105
+ line(`efficiency ${bold(`${audit.score}/100`)} ${marker} ${dim(`· ${newTurns} new turns`)}`);
106
+ }
107
+ if (fresh.length > 0 || Math.abs(delta) >= SCORE_STEP) {
108
+ baseline = audit;
109
+ if (options.save)
110
+ saveRun(audit);
111
+ }
112
+ };
113
+ const stop = () => {
114
+ if (stopping)
115
+ return;
116
+ stopping = true;
117
+ const audit = baseline;
118
+ process.stdout.write(`\n${dim("stopped")} ${audit ? `· efficiency ${audit.score}/100 · run ${bold("npx savemytokens")} for the full audit` : ""}\n\n`);
119
+ process.exit(0);
120
+ };
121
+ process.on("SIGINT", stop);
122
+ process.on("SIGTERM", stop);
123
+ await tick();
124
+ for (;;) {
125
+ await new Promise((resolve) => setTimeout(resolve, options.interval * 1000));
126
+ if (stopping)
127
+ return;
128
+ try {
129
+ await tick();
130
+ }
131
+ catch (error) {
132
+ line(red(`scan failed: ${error.message}`));
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,24 @@
1
+ import { WEIGHTS } from "./tokens.js";
2
+ export class LifetimeCost {
3
+ tokens = 0;
4
+ segments = new Map();
5
+ add(segment, turn, tokens) {
6
+ this.tokens += tokens;
7
+ let s = this.segments.get(segment);
8
+ if (!s) {
9
+ s = { tokens: 0, tokenTurns: 0 };
10
+ this.segments.set(segment, s);
11
+ }
12
+ s.tokens += tokens;
13
+ s.tokenTurns += tokens * turn;
14
+ }
15
+ resolve(segmentEnds) {
16
+ let total = 0;
17
+ for (const [segment, s] of this.segments) {
18
+ const end = segmentEnds[segment] ?? 0;
19
+ const residentTurns = Math.max(0, end * s.tokens - s.tokenTurns);
20
+ total += WEIGHTS.cacheWrite * s.tokens + WEIGHTS.cacheRead * residentTurns;
21
+ }
22
+ return total;
23
+ }
24
+ }
Binary file
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export const PRICE_SOURCES = [
5
+ { label: "Anthropic", asOf: "2026-06-24", url: "https://docs.claude.com/en/docs/about-claude/pricing" },
6
+ { label: "OpenAI", asOf: "2026-08-31", url: "https://developers.openai.com/api/docs/pricing" },
7
+ ];
8
+ const TABLE = [
9
+ [/fable|mythos/i, { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }],
10
+ [/opus/i, { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }],
11
+ [/sonnet-4-6/i, { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }],
12
+ [/sonnet/i, { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }],
13
+ [/haiku/i, { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }],
14
+ [/gpt-5\.5-pro/i, { input: 30, output: 180, cacheRead: 3, cacheWrite: 30 }],
15
+ [/gpt-5\.6-luna/i, { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.2 }],
16
+ [/gpt-5\.6-terra/i, { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 2 }],
17
+ [/gpt-5\.6/i, { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 5 }],
18
+ [/gpt-5\.5/i, { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 5 }],
19
+ [/gpt-5\.4/i, { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 2.5 }],
20
+ [/gpt-5/i, { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 5 }],
21
+ ];
22
+ const FALLBACK = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 };
23
+ let overrides = null;
24
+ function loadOverrides() {
25
+ if (overrides)
26
+ return overrides;
27
+ overrides = {};
28
+ const home = process.env.SAVEMYTOKENS_HOME || path.join(os.homedir(), ".savemytokens");
29
+ try {
30
+ const parsed = JSON.parse(fs.readFileSync(path.join(home, "pricing.json"), "utf8"));
31
+ if (parsed && typeof parsed === "object")
32
+ overrides = parsed;
33
+ }
34
+ catch {
35
+ overrides = {};
36
+ }
37
+ return overrides;
38
+ }
39
+ export function rateFor(model) {
40
+ const override = loadOverrides()[model];
41
+ if (override)
42
+ return override;
43
+ for (const [pattern, rate] of TABLE)
44
+ if (pattern.test(model))
45
+ return rate;
46
+ return FALLBACK;
47
+ }
48
+ export function isKnownModel(model) {
49
+ if (loadOverrides()[model])
50
+ return true;
51
+ return TABLE.some(([pattern]) => pattern.test(model));
52
+ }
53
+ export function usd(model, usage) {
54
+ const rate = rateFor(model);
55
+ return ((usage.input * rate.input +
56
+ usage.cacheWrite * rate.cacheWrite +
57
+ usage.cacheRead * rate.cacheRead +
58
+ usage.output * rate.output) /
59
+ 1_000_000);
60
+ }
61
+ export function pricingNote() {
62
+ return `Prices: ${PRICE_SOURCES.map((s) => `${s.label} list ${s.asOf}`).join(", ")}. Override in ~/.savemytokens/pricing.json`;
63
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ export const WEIGHTS = {
2
+ input: 1,
3
+ cacheWrite: 1.25,
4
+ cacheRead: 0.1,
5
+ output: 5,
6
+ };
7
+ export const CHARS_PER_TOKEN = 4;
8
+ export function emptyUsage() {
9
+ return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
10
+ }
11
+ export function addUsage(target, add) {
12
+ target.input += add.input;
13
+ target.output += add.output;
14
+ target.cacheWrite += add.cacheWrite;
15
+ target.cacheRead += add.cacheRead;
16
+ return target;
17
+ }
18
+ export function rawTokens(u) {
19
+ return u.input + u.output + u.cacheWrite + u.cacheRead;
20
+ }
21
+ export function weigh(u) {
22
+ return (u.input * WEIGHTS.input +
23
+ u.cacheWrite * WEIGHTS.cacheWrite +
24
+ u.cacheRead * WEIGHTS.cacheRead +
25
+ u.output * WEIGHTS.output);
26
+ }
27
+ export function estimateTokens(chars) {
28
+ return Math.round(chars / CHARS_PER_TOKEN);
29
+ }
30
+ export function contextLifetimeCost(tokens, turnsAfter) {
31
+ return tokens * (WEIGHTS.cacheWrite + WEIGHTS.cacheRead * Math.max(0, turnsAfter));
32
+ }
@@ -0,0 +1 @@
1
+ export const EVIDENCE_SCHEMA = 4;
@@ -0,0 +1,111 @@
1
+ export const HOOK_FILENAME = "nudge.cjs";
2
+ export const NUDGE_CONTEXT_TOKENS = 150_000;
3
+ export const NUDGE_COOLDOWN_MS = 30 * 60 * 1000;
4
+ export const NUDGE_HORIZON_TURNS = 10;
5
+ export const HOOK_SCRIPT = String.raw `#!/usr/bin/env node
6
+ const fs = require("node:fs");
7
+ const os = require("node:os");
8
+ const path = require("node:path");
9
+
10
+ const HOME = process.env.SAVEMYTOKENS_HOME || path.join(os.homedir(), ".savemytokens");
11
+ const STATE = path.join(HOME, "nudges.json");
12
+ const THRESHOLD = 150000;
13
+ const COOLDOWN_MS = 30 * 60 * 1000;
14
+ const TAIL_BYTES = 262144;
15
+ const RATE_PER_TOKEN = 5 / 1000000;
16
+ const CACHE_READ = 0.1;
17
+ const HORIZON_TURNS = 10;
18
+ const ANAPHORIC =
19
+ /^\s*(ok|okay|now|also|and|then|next|again|yes|no|nope|yep|same|do the same|the other|these|those|them|it|that|this|continue|carry on|keep going|go on|more|another|fix (it|that|this)|try again|redo|revert|undo|hmm|wait|great|nice|thanks|perfect|good)\b/i;
20
+
21
+ function selfContained(prompt) {
22
+ const text = String(prompt || "").trim();
23
+ return text.length >= 40 && !ANAPHORIC.test(text);
24
+ }
25
+
26
+ function tailLines(file) {
27
+ const fd = fs.openSync(file, "r");
28
+ try {
29
+ const size = fs.fstatSync(fd).size;
30
+ const start = Math.max(0, size - TAIL_BYTES);
31
+ const buffer = Buffer.alloc(size - start);
32
+ fs.readSync(fd, buffer, 0, buffer.length, start);
33
+ return buffer.toString("utf8").split("\n");
34
+ } finally {
35
+ fs.closeSync(fd);
36
+ }
37
+ }
38
+
39
+ function currentContext(file) {
40
+ const lines = tailLines(file);
41
+ for (let i = lines.length - 1; i >= 0; i--) {
42
+ const line = lines[i];
43
+ if (!line || line.indexOf('"usage"') === -1) continue;
44
+ let record;
45
+ try {
46
+ record = JSON.parse(line);
47
+ } catch {
48
+ continue;
49
+ }
50
+ const usage = record && record.message && record.message.usage;
51
+ if (!usage) continue;
52
+ const total =
53
+ (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
54
+ if (total > 0) return total;
55
+ }
56
+ return 0;
57
+ }
58
+
59
+ function readState() {
60
+ try {
61
+ const parsed = JSON.parse(fs.readFileSync(STATE, "utf8"));
62
+ if (parsed && Array.isArray(parsed.events)) return parsed;
63
+ } catch {}
64
+ return { installedAt: Date.now(), events: [] };
65
+ }
66
+
67
+ function writeState(state) {
68
+ try {
69
+ fs.mkdirSync(HOME, { recursive: true });
70
+ state.events = state.events.slice(-500);
71
+ fs.writeFileSync(STATE, JSON.stringify(state));
72
+ } catch {}
73
+ }
74
+
75
+ function run() {
76
+ const raw = fs.readFileSync(0, "utf8");
77
+ if (!raw) return;
78
+ const input = JSON.parse(raw);
79
+ if (!input || !input.transcript_path || !selfContained(input.prompt)) return;
80
+ if (!fs.existsSync(input.transcript_path)) return;
81
+
82
+ const context = currentContext(input.transcript_path);
83
+ if (context < THRESHOLD) return;
84
+
85
+ const state = readState();
86
+ const now = Date.now();
87
+ const recent = state.events.filter((event) => event.session === input.session_id).pop();
88
+ if (recent && now - recent.at < COOLDOWN_MS) return;
89
+
90
+ const horizon = context * CACHE_READ * RATE_PER_TOKEN * HORIZON_TURNS;
91
+ const thousands = Math.round(context / 1000);
92
+
93
+ state.events.push({ at: now, session: input.session_id, context: context, usd: horizon });
94
+ writeState(state);
95
+
96
+ process.stdout.write(
97
+ "[savemytokens] This reads as a new task and " +
98
+ thousands +
99
+ "k tokens of earlier work are still in context, about $" +
100
+ horizon.toFixed(2) +
101
+ " per " +
102
+ HORIZON_TURNS +
103
+ " turns from here. Open your reply with one short line saying so, and that /clear or a fresh session drops it. Then do what was asked.\n",
104
+ );
105
+ }
106
+
107
+ try {
108
+ run();
109
+ } catch {}
110
+ process.exit(0);
111
+ `;
@@ -0,0 +1,14 @@
1
+ export const RULES_START = "<!-- savemytokens:start -->";
2
+ export const RULES_END = "<!-- savemytokens:end -->";
3
+ export const RULES_BLOCK = `${RULES_START}
4
+ ## Token discipline
5
+
6
+ - Batch shell work. Chain related commands into one call instead of one call per step; every extra
7
+ round trip re-reads the whole conversation.
8
+ - Never dump a large result into context. Pipe long output through \`tail\`/\`grep\`, or write it to a
9
+ file and read back only the part that matters.
10
+ - Delegate multi-step searching, log reading and file discovery to a subagent, and ask it for its
11
+ conclusion rather than its transcript.
12
+ - Once a file exists, edit it. Do not rewrite a whole file to change part of it.
13
+ - Do not re-read a file you have already read in this session unless it changed.
14
+ ${RULES_END}`;
@@ -0,0 +1,22 @@
1
+ export function buildPayload(audit, version, outcomes) {
2
+ const round = (value) => Math.round(value * 1000) / 1000;
3
+ return {
4
+ schema: 1,
5
+ tool: `savemytokens@${version}`,
6
+ agent: audit.scope.adapters.join(","),
7
+ window_days: audit.scope.days,
8
+ sessions: audit.totals.sessions,
9
+ tasks: audit.totals.tasks,
10
+ turns: audit.totals.turns,
11
+ tokens: {
12
+ input: audit.totals.usage.input,
13
+ output: audit.totals.usage.output,
14
+ cache_write: audit.totals.usage.cacheWrite,
15
+ cache_read: audit.totals.usage.cacheRead,
16
+ },
17
+ efficiency_score: audit.score,
18
+ waste_ratio: round(audit.wasteRatio),
19
+ findings: audit.findings.map((f) => ({ id: f.id, waste_ratio: round(f.wasteRatio), confidence: f.confidence })),
20
+ outcomes,
21
+ };
22
+ }