tokengolf 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +36 -0
- package/CLAUDE.md +320 -0
- package/README.md +235 -0
- package/dist/cli.js +897 -0
- package/hooks/post-tool-use.js +43 -0
- package/hooks/pre-compact.js +35 -0
- package/hooks/session-end.js +172 -0
- package/hooks/session-start.js +100 -0
- package/hooks/session-stop.js +25 -0
- package/hooks/statusline.sh +72 -0
- package/hooks/user-prompt-submit.js +29 -0
- package/package.json +27 -0
- package/src/cli.js +115 -0
- package/src/components/ActiveRun.js +85 -0
- package/src/components/ScoreCard.js +157 -0
- package/src/components/StartRun.js +156 -0
- package/src/components/StatsView.js +112 -0
- package/src/lib/cost.js +149 -0
- package/src/lib/install.js +163 -0
- package/src/lib/score.js +330 -0
- package/src/lib/state.js +35 -0
- package/src/lib/store.js +76 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
|
|
5
|
+
// Follow symlinks (npm link creates a symlink in the nvm/node bin dir)
|
|
6
|
+
// to find the actual project directory, then resolve hooks/ relative to it.
|
|
7
|
+
const realEntry = fs.realpathSync(process.argv[1]);
|
|
8
|
+
const HOOKS_DIR = path.resolve(path.dirname(realEntry), "../hooks");
|
|
9
|
+
const STATUSLINE_PATH = path.join(HOOKS_DIR, "statusline.sh");
|
|
10
|
+
const WRAPPER_PATH = path.join(HOOKS_DIR, "statusline-wrapper.sh");
|
|
11
|
+
const CLAUDE_DIR = path.join(os.homedir(), ".claude");
|
|
12
|
+
const CLAUDE_SETTINGS = path.join(CLAUDE_DIR, "settings.json");
|
|
13
|
+
|
|
14
|
+
export function installHooks() {
|
|
15
|
+
console.log("\n⛳ TokenGolf — Installing Claude Code hooks\n");
|
|
16
|
+
|
|
17
|
+
let settings = {};
|
|
18
|
+
if (fs.existsSync(CLAUDE_SETTINGS)) {
|
|
19
|
+
try {
|
|
20
|
+
settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, "utf8"));
|
|
21
|
+
console.log(" ✓ Found ~/.claude/settings.json");
|
|
22
|
+
} catch {
|
|
23
|
+
console.log(" ⚠️ Could not parse settings.json — starting fresh");
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
27
|
+
console.log(" ℹ️ Creating ~/.claude/settings.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!settings.hooks) settings.hooks = {};
|
|
31
|
+
|
|
32
|
+
function upsertHook(event, entry) {
|
|
33
|
+
const existing = settings.hooks[event] || [];
|
|
34
|
+
// Remove any existing tokengolf hooks (identified by _tg marker or legacy path patterns)
|
|
35
|
+
const filtered = existing.filter(
|
|
36
|
+
(h) =>
|
|
37
|
+
!h._tg &&
|
|
38
|
+
!h.hooks?.some(
|
|
39
|
+
(e) =>
|
|
40
|
+
e.command?.includes("tokengolf") ||
|
|
41
|
+
e.command?.includes("session-start.js") ||
|
|
42
|
+
e.command?.includes("session-stop.js") ||
|
|
43
|
+
e.command?.includes("session-end.js") ||
|
|
44
|
+
e.command?.includes("pre-compact.js") ||
|
|
45
|
+
e.command?.includes("post-tool-use.js") ||
|
|
46
|
+
e.command?.includes("user-prompt-submit.js"),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
settings.hooks[event] = [...filtered, { _tg: true, ...entry }];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Remove Stop hook if present (replaced by SessionEnd)
|
|
53
|
+
if (settings.hooks.Stop) {
|
|
54
|
+
settings.hooks.Stop = (settings.hooks.Stop || []).filter(
|
|
55
|
+
(h) =>
|
|
56
|
+
!h._tg && !h.hooks?.some((e) => e.command?.includes("session-stop.js")),
|
|
57
|
+
);
|
|
58
|
+
if (settings.hooks.Stop.length === 0) delete settings.hooks.Stop;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
upsertHook("SessionStart", {
|
|
62
|
+
hooks: [
|
|
63
|
+
{
|
|
64
|
+
type: "command",
|
|
65
|
+
command: `node ${path.join(HOOKS_DIR, "session-start.js")}`,
|
|
66
|
+
timeout: 5,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
upsertHook("PostToolUse", {
|
|
72
|
+
matcher: "",
|
|
73
|
+
hooks: [
|
|
74
|
+
{
|
|
75
|
+
type: "command",
|
|
76
|
+
command: `node ${path.join(HOOKS_DIR, "post-tool-use.js")}`,
|
|
77
|
+
timeout: 5,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
upsertHook("UserPromptSubmit", {
|
|
83
|
+
hooks: [
|
|
84
|
+
{
|
|
85
|
+
type: "command",
|
|
86
|
+
command: `node ${path.join(HOOKS_DIR, "user-prompt-submit.js")}`,
|
|
87
|
+
timeout: 5,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
upsertHook("SessionEnd", {
|
|
93
|
+
hooks: [
|
|
94
|
+
{
|
|
95
|
+
type: "command",
|
|
96
|
+
command: `node ${path.join(HOOKS_DIR, "session-end.js")}`,
|
|
97
|
+
timeout: 30,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
upsertHook("PreCompact", {
|
|
103
|
+
hooks: [
|
|
104
|
+
{
|
|
105
|
+
type: "command",
|
|
106
|
+
command: `node ${path.join(HOOKS_DIR, "pre-compact.js")}`,
|
|
107
|
+
timeout: 5,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Install statusLine (non-destructive: wrap existing if present)
|
|
113
|
+
try {
|
|
114
|
+
fs.chmodSync(STATUSLINE_PATH, 0o755);
|
|
115
|
+
} catch {}
|
|
116
|
+
|
|
117
|
+
const existing = settings.statusLine;
|
|
118
|
+
const existingCmd =
|
|
119
|
+
typeof existing === "string" ? existing : (existing?.command ?? null);
|
|
120
|
+
const alreadyOurs =
|
|
121
|
+
existingCmd === STATUSLINE_PATH || existingCmd === WRAPPER_PATH;
|
|
122
|
+
|
|
123
|
+
if (!alreadyOurs && existingCmd) {
|
|
124
|
+
fs.writeFileSync(
|
|
125
|
+
WRAPPER_PATH,
|
|
126
|
+
[
|
|
127
|
+
"#!/usr/bin/env bash",
|
|
128
|
+
"SESSION_JSON=$(cat)",
|
|
129
|
+
`echo "$SESSION_JSON" | ${existingCmd} 2>/dev/null || true`,
|
|
130
|
+
`echo "$SESSION_JSON" | bash ${STATUSLINE_PATH}`,
|
|
131
|
+
].join("\n") + "\n",
|
|
132
|
+
);
|
|
133
|
+
fs.chmodSync(WRAPPER_PATH, 0o755);
|
|
134
|
+
settings.statusLine = {
|
|
135
|
+
type: "command",
|
|
136
|
+
command: WRAPPER_PATH,
|
|
137
|
+
padding: 1,
|
|
138
|
+
};
|
|
139
|
+
console.log(
|
|
140
|
+
" ✓ statusLine → wrapped your existing statusline + tokengolf HUD",
|
|
141
|
+
);
|
|
142
|
+
} else if (!alreadyOurs) {
|
|
143
|
+
settings.statusLine = {
|
|
144
|
+
type: "command",
|
|
145
|
+
command: STATUSLINE_PATH,
|
|
146
|
+
padding: 1,
|
|
147
|
+
};
|
|
148
|
+
console.log(" ✓ statusLine → live HUD in every Claude session");
|
|
149
|
+
} else {
|
|
150
|
+
console.log(" ✓ statusLine → already installed");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
|
|
154
|
+
|
|
155
|
+
console.log(" ✓ SessionStart → injects run context into Claude");
|
|
156
|
+
console.log(" ✓ PostToolUse → tracks tool calls + 80% budget warning");
|
|
157
|
+
console.log(" ✓ UserPromptSubmit → counts prompts + 50% nudge");
|
|
158
|
+
console.log(" ✓ SessionEnd → auto-displays scorecard on /exit");
|
|
159
|
+
console.log(
|
|
160
|
+
" ✓ PreCompact → tracks compaction events for gear achievements",
|
|
161
|
+
);
|
|
162
|
+
console.log("\n ✅ Done! Start a run: tokengolf start\n");
|
|
163
|
+
}
|
package/src/lib/score.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
export const BUDGET_TIERS = [
|
|
2
|
+
{ label: "Diamond", emoji: "💎", max: 0.1, color: "cyan" },
|
|
3
|
+
{ label: "Gold", emoji: "🥇", max: 0.3, color: "yellow" },
|
|
4
|
+
{ label: "Silver", emoji: "🥈", max: 1.0, color: "white" },
|
|
5
|
+
{ label: "Bronze", emoji: "🥉", max: 3.0, color: "yellow" },
|
|
6
|
+
{ label: "Reckless", emoji: "💸", max: Infinity, color: "red" },
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export const EFFORT_LEVELS = {
|
|
10
|
+
low: { label: "Low", emoji: "🪶", color: "green" },
|
|
11
|
+
medium: { label: "Medium", emoji: "⚖️", color: "white" },
|
|
12
|
+
high: { label: "High", emoji: "🔥", color: "yellow" },
|
|
13
|
+
max: { label: "Max", emoji: "💥", color: "magenta", opusOnly: true },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function getEffortLevel(effort) {
|
|
17
|
+
return EFFORT_LEVELS[effort] || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const MODEL_BUDGET_TIERS = {
|
|
21
|
+
haiku: { diamond: 0.15, gold: 0.4, silver: 1.0, bronze: 2.5 },
|
|
22
|
+
sonnet: { diamond: 0.5, gold: 1.5, silver: 4.0, bronze: 10.0 },
|
|
23
|
+
opus: { diamond: 2.5, gold: 7.5, silver: 20.0, bronze: 50.0 },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function getModelBudgets(model) {
|
|
27
|
+
const m = (model || "").toLowerCase();
|
|
28
|
+
if (m.includes("haiku")) return MODEL_BUDGET_TIERS.haiku;
|
|
29
|
+
if (m.includes("opus")) return MODEL_BUDGET_TIERS.opus;
|
|
30
|
+
return MODEL_BUDGET_TIERS.sonnet;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const MODEL_CLASSES = {
|
|
34
|
+
haiku: {
|
|
35
|
+
name: "Haiku",
|
|
36
|
+
label: "Rogue",
|
|
37
|
+
emoji: "🏹",
|
|
38
|
+
difficulty: "Hard",
|
|
39
|
+
color: "red",
|
|
40
|
+
},
|
|
41
|
+
sonnet: {
|
|
42
|
+
name: "Sonnet",
|
|
43
|
+
label: "Fighter",
|
|
44
|
+
emoji: "⚔️",
|
|
45
|
+
difficulty: "Normal",
|
|
46
|
+
color: "cyan",
|
|
47
|
+
},
|
|
48
|
+
opus: {
|
|
49
|
+
name: "Opus",
|
|
50
|
+
label: "Warlock",
|
|
51
|
+
emoji: "🧙",
|
|
52
|
+
difficulty: "Easy",
|
|
53
|
+
color: "magenta",
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const FLOORS = [
|
|
58
|
+
"Write the code",
|
|
59
|
+
"Write the tests",
|
|
60
|
+
"Fix failing tests",
|
|
61
|
+
"Code review pass",
|
|
62
|
+
"PR merged — BOSS 🏆",
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export function getTier(spent) {
|
|
66
|
+
return (
|
|
67
|
+
BUDGET_TIERS.find((t) => spent <= t.max) ||
|
|
68
|
+
BUDGET_TIERS[BUDGET_TIERS.length - 1]
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getModelClass(model = "") {
|
|
73
|
+
const key = Object.keys(MODEL_CLASSES).find((k) =>
|
|
74
|
+
model.toLowerCase().includes(k),
|
|
75
|
+
);
|
|
76
|
+
return MODEL_CLASSES[key] || MODEL_CLASSES.sonnet;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getEfficiencyRating(spent, budget) {
|
|
80
|
+
const pct = spent / budget;
|
|
81
|
+
if (pct <= 0.25) return { label: "LEGENDARY", emoji: "🌟", color: "magenta" };
|
|
82
|
+
if (pct <= 0.5) return { label: "EFFICIENT", emoji: "⚡", color: "cyan" };
|
|
83
|
+
if (pct <= 0.75) return { label: "SOLID", emoji: "✓", color: "green" };
|
|
84
|
+
if (pct <= 1.0) return { label: "CLOSE CALL", emoji: "😅", color: "yellow" };
|
|
85
|
+
return { label: "BUSTED", emoji: "💀", color: "red" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getBudgetPct(spent, budget) {
|
|
89
|
+
return Math.min(Math.round((spent / budget) * 100), 999);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatCost(amount = 0) {
|
|
93
|
+
if (amount === 0) return "$0.00";
|
|
94
|
+
if (amount < 0.01) return `$${(amount * 100).toFixed(3)}¢`;
|
|
95
|
+
return `$${amount.toFixed(4)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatElapsed(startedAt) {
|
|
99
|
+
if (!startedAt) return "—";
|
|
100
|
+
const ms = Date.now() - new Date(startedAt).getTime();
|
|
101
|
+
const s = Math.floor(ms / 1000);
|
|
102
|
+
const m = Math.floor(s / 60);
|
|
103
|
+
const h = Math.floor(m / 60);
|
|
104
|
+
if (h > 0) return `${h}h ${m % 60}m`;
|
|
105
|
+
if (m > 0) return `${m}m ${s % 60}s`;
|
|
106
|
+
return `${s}s`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Returns haiku's share of total spend as a 0–100 integer, or null
|
|
110
|
+
export function getHaikuPct(modelBreakdown, totalSpent) {
|
|
111
|
+
if (!modelBreakdown || !totalSpent) return null;
|
|
112
|
+
const haikuCost = Object.entries(modelBreakdown)
|
|
113
|
+
.filter(([m]) => m.toLowerCase().includes("haiku"))
|
|
114
|
+
.reduce((sum, [, c]) => sum + c, 0);
|
|
115
|
+
if (haikuCost === 0) return null;
|
|
116
|
+
return Math.round((haikuCost / totalSpent) * 100);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function calculateAchievements(run) {
|
|
120
|
+
const achievements = [];
|
|
121
|
+
const won = run.status === "won";
|
|
122
|
+
const pct = run.budget ? run.spent / run.budget : null;
|
|
123
|
+
const mc = getModelClass(run.model);
|
|
124
|
+
|
|
125
|
+
// Hubris fires on death too — ultrathink and still busted
|
|
126
|
+
if (run.thinkingInvocations > 0 && run.status === "died")
|
|
127
|
+
achievements.push({
|
|
128
|
+
key: "hubris",
|
|
129
|
+
label: "Hubris — Used ultrathink, busted anyway",
|
|
130
|
+
emoji: "🤦",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!won) return achievements;
|
|
134
|
+
|
|
135
|
+
if (mc === MODEL_CLASSES.haiku) {
|
|
136
|
+
achievements.push({
|
|
137
|
+
key: "gold_haiku",
|
|
138
|
+
label: "Gold — Completed with Haiku",
|
|
139
|
+
emoji: "🥇",
|
|
140
|
+
});
|
|
141
|
+
if (run.spent < 0.1)
|
|
142
|
+
achievements.push({
|
|
143
|
+
key: "diamond",
|
|
144
|
+
label: "Diamond — Haiku under $0.10",
|
|
145
|
+
emoji: "💎",
|
|
146
|
+
});
|
|
147
|
+
} else if (mc === MODEL_CLASSES.sonnet) {
|
|
148
|
+
achievements.push({
|
|
149
|
+
key: "silver_sonnet",
|
|
150
|
+
label: "Silver — Completed with Sonnet",
|
|
151
|
+
emoji: "🥈",
|
|
152
|
+
});
|
|
153
|
+
} else if (mc === MODEL_CLASSES.opus) {
|
|
154
|
+
achievements.push({
|
|
155
|
+
key: "bronze_opus",
|
|
156
|
+
label: "Bronze — Completed with Opus",
|
|
157
|
+
emoji: "🥉",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (pct !== null) {
|
|
162
|
+
if (pct <= 0.25)
|
|
163
|
+
achievements.push({
|
|
164
|
+
key: "sniper",
|
|
165
|
+
label: "Sniper — Under 25% of budget",
|
|
166
|
+
emoji: "🎯",
|
|
167
|
+
});
|
|
168
|
+
if (pct <= 0.5)
|
|
169
|
+
achievements.push({
|
|
170
|
+
key: "efficient",
|
|
171
|
+
label: "Efficient — Under 50% of budget",
|
|
172
|
+
emoji: "⚡",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (run.spent < 0.1)
|
|
176
|
+
achievements.push({
|
|
177
|
+
key: "penny",
|
|
178
|
+
label: "Penny Pincher — Under $0.10",
|
|
179
|
+
emoji: "🪙",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Effort-based achievements
|
|
183
|
+
if (run.effort) {
|
|
184
|
+
if (run.effort === "low" && pct !== null && pct < 1.0)
|
|
185
|
+
achievements.push({
|
|
186
|
+
key: "speedrunner",
|
|
187
|
+
label: "Speedrunner — Low effort, completed under budget",
|
|
188
|
+
emoji: "🎯",
|
|
189
|
+
});
|
|
190
|
+
if (
|
|
191
|
+
(run.effort === "high" || run.effort === "max") &&
|
|
192
|
+
pct !== null &&
|
|
193
|
+
pct <= 0.25
|
|
194
|
+
)
|
|
195
|
+
achievements.push({
|
|
196
|
+
key: "tryhard",
|
|
197
|
+
label: "Tryhard — High effort, LEGENDARY efficiency",
|
|
198
|
+
emoji: "💪",
|
|
199
|
+
});
|
|
200
|
+
if (run.effort === "max" && mc === MODEL_CLASSES.opus)
|
|
201
|
+
achievements.push({
|
|
202
|
+
key: "archmagus",
|
|
203
|
+
label: "Archmagus — Opus at max effort, completed",
|
|
204
|
+
emoji: "👑",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Fast mode achievements (Opus-only feature)
|
|
209
|
+
if (run.fastMode && mc === MODEL_CLASSES.opus) {
|
|
210
|
+
if (pct !== null && pct < 1.0)
|
|
211
|
+
achievements.push({
|
|
212
|
+
key: "lightning",
|
|
213
|
+
label: "Lightning Run — Opus fast mode, completed under budget",
|
|
214
|
+
emoji: "⚡",
|
|
215
|
+
});
|
|
216
|
+
if (pct !== null && pct <= 0.25)
|
|
217
|
+
achievements.push({
|
|
218
|
+
key: "daredevil",
|
|
219
|
+
label: "Daredevil — Opus fast mode, LEGENDARY efficiency",
|
|
220
|
+
emoji: "🎰",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Rest / multi-session achievements
|
|
225
|
+
const sessions = run.sessionCount || 1;
|
|
226
|
+
if (sessions >= 2)
|
|
227
|
+
achievements.push({
|
|
228
|
+
key: "made_camp",
|
|
229
|
+
label: `Made Camp — Completed across ${sessions} sessions`,
|
|
230
|
+
emoji: "🏕️",
|
|
231
|
+
});
|
|
232
|
+
if (sessions === 1)
|
|
233
|
+
achievements.push({
|
|
234
|
+
key: "no_rest",
|
|
235
|
+
label: "No Rest for the Wicked — Completed in one session",
|
|
236
|
+
emoji: "⚡",
|
|
237
|
+
});
|
|
238
|
+
if (run.fainted)
|
|
239
|
+
achievements.push({
|
|
240
|
+
key: "came_back",
|
|
241
|
+
label: "Came Back — Fainted and finished anyway",
|
|
242
|
+
emoji: "💪",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Compaction achievements
|
|
246
|
+
const compactionEvents = run.compactionEvents || [];
|
|
247
|
+
const manualCompactions = compactionEvents.filter(
|
|
248
|
+
(e) => e.trigger === "manual",
|
|
249
|
+
);
|
|
250
|
+
const autoCompactions = compactionEvents.filter((e) => e.trigger === "auto");
|
|
251
|
+
|
|
252
|
+
if (autoCompactions.length > 0)
|
|
253
|
+
achievements.push({
|
|
254
|
+
key: "overencumbered",
|
|
255
|
+
label: "Overencumbered — Context auto-compacted during run",
|
|
256
|
+
emoji: "📦",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (manualCompactions.length > 0) {
|
|
260
|
+
const minPct = Math.min(
|
|
261
|
+
...manualCompactions.map((e) => e.contextPct ?? 100),
|
|
262
|
+
);
|
|
263
|
+
if (minPct <= 30)
|
|
264
|
+
achievements.push({
|
|
265
|
+
key: "ghost_run",
|
|
266
|
+
label: `Ghost Run — Manual compact at ${minPct}% context`,
|
|
267
|
+
emoji: "🥷",
|
|
268
|
+
});
|
|
269
|
+
else if (minPct <= 40)
|
|
270
|
+
achievements.push({
|
|
271
|
+
key: "ultralight",
|
|
272
|
+
label: `Ultralight — Manual compact at ${minPct}% context`,
|
|
273
|
+
emoji: "🪶",
|
|
274
|
+
});
|
|
275
|
+
else if (minPct <= 50)
|
|
276
|
+
achievements.push({
|
|
277
|
+
key: "traveling_light",
|
|
278
|
+
label: `Traveling Light — Manual compact at ${minPct}% context`,
|
|
279
|
+
emoji: "🎒",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Ultrathink achievements
|
|
284
|
+
const ti = run.thinkingInvocations;
|
|
285
|
+
if (ti > 0) {
|
|
286
|
+
achievements.push({
|
|
287
|
+
key: "spell_cast",
|
|
288
|
+
label: `Spell Cast — Used extended thinking (${ti}×)`,
|
|
289
|
+
emoji: "🔮",
|
|
290
|
+
});
|
|
291
|
+
if (pct !== null && pct <= 0.25)
|
|
292
|
+
achievements.push({
|
|
293
|
+
key: "calculated_risk",
|
|
294
|
+
label: "Calculated Risk — Ultrathink + LEGENDARY efficiency",
|
|
295
|
+
emoji: "🧠",
|
|
296
|
+
});
|
|
297
|
+
if (ti >= 3)
|
|
298
|
+
achievements.push({
|
|
299
|
+
key: "deep_thinker",
|
|
300
|
+
label: `Deep Thinker — ${ti} ultrathink invocations, completed`,
|
|
301
|
+
emoji: "🌀",
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
// Silent Run: thinking was tracked (field exists), zero invocations, SOLID or better, completed
|
|
305
|
+
if (run.thinkingInvocations === 0 && pct !== null && pct <= 0.75)
|
|
306
|
+
achievements.push({
|
|
307
|
+
key: "silent_run",
|
|
308
|
+
label: "Silent Run — No extended thinking, completed under budget",
|
|
309
|
+
emoji: "🤫",
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Multi-model achievements based on Haiku usage ratio
|
|
313
|
+
const haikuPct = getHaikuPct(run.modelBreakdown, run.spent);
|
|
314
|
+
if (haikuPct !== null) {
|
|
315
|
+
if (haikuPct >= 50)
|
|
316
|
+
achievements.push({
|
|
317
|
+
key: "frugal",
|
|
318
|
+
label: `Frugal — Haiku handled ${haikuPct}% of session cost`,
|
|
319
|
+
emoji: "🏹",
|
|
320
|
+
});
|
|
321
|
+
if (haikuPct >= 75)
|
|
322
|
+
achievements.push({
|
|
323
|
+
key: "rogue_run",
|
|
324
|
+
label: `Rogue Run — Haiku handled ${haikuPct}% of session cost`,
|
|
325
|
+
emoji: "🎲",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return achievements;
|
|
330
|
+
}
|
package/src/lib/state.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
export const STATE_DIR = path.join(os.homedir(), '.tokengolf');
|
|
6
|
+
const STATE_FILE = path.join(STATE_DIR, 'current-run.json');
|
|
7
|
+
|
|
8
|
+
function ensureDir() {
|
|
9
|
+
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getCurrentRun() {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function setCurrentRun(run) {
|
|
21
|
+
ensureDir();
|
|
22
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(run, null, 2));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function updateCurrentRun(updates) {
|
|
26
|
+
const run = getCurrentRun();
|
|
27
|
+
if (!run) return null;
|
|
28
|
+
const updated = { ...run, ...updates };
|
|
29
|
+
setCurrentRun(updated);
|
|
30
|
+
return updated;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function clearCurrentRun() {
|
|
34
|
+
if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);
|
|
35
|
+
}
|
package/src/lib/store.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { calculateAchievements } from './score.js';
|
|
4
|
+
import { STATE_DIR } from './state.js';
|
|
5
|
+
const RUNS_FILE = path.join(STATE_DIR, 'runs.json');
|
|
6
|
+
|
|
7
|
+
function ensureDir() {
|
|
8
|
+
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readRuns() {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(fs.readFileSync(RUNS_FILE, 'utf8'));
|
|
14
|
+
} catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeRuns(runs) {
|
|
20
|
+
ensureDir();
|
|
21
|
+
fs.writeFileSync(RUNS_FILE, JSON.stringify(runs, null, 2));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function saveRun(run) {
|
|
25
|
+
const runs = readRuns();
|
|
26
|
+
const achievements = calculateAchievements(run);
|
|
27
|
+
const record = {
|
|
28
|
+
id: `run_${Date.now()}`,
|
|
29
|
+
...run,
|
|
30
|
+
achievements,
|
|
31
|
+
savedAt: new Date().toISOString(),
|
|
32
|
+
};
|
|
33
|
+
runs.push(record);
|
|
34
|
+
writeRuns(runs);
|
|
35
|
+
return record;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getLastRun() {
|
|
39
|
+
const runs = readRuns();
|
|
40
|
+
return runs.length ? runs[runs.length - 1] : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getAllRuns() {
|
|
44
|
+
return readRuns();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getStats() {
|
|
48
|
+
const runs = readRuns().filter(r => r.status !== 'active');
|
|
49
|
+
const wins = runs.filter(r => r.status === 'won');
|
|
50
|
+
const deaths = runs.filter(r => r.status === 'died');
|
|
51
|
+
|
|
52
|
+
const avgSpend = wins.length
|
|
53
|
+
? wins.reduce((sum, r) => sum + (r.spent || 0), 0) / wins.length
|
|
54
|
+
: 0;
|
|
55
|
+
|
|
56
|
+
const bestRun = wins.length
|
|
57
|
+
? wins.reduce((best, r) => (!best || r.spent < best.spent ? r : best), null)
|
|
58
|
+
: null;
|
|
59
|
+
|
|
60
|
+
const allAchievements = runs.flatMap(r => (r.achievements || []).map(a => ({
|
|
61
|
+
...a,
|
|
62
|
+
quest: r.quest,
|
|
63
|
+
earnedAt: r.endedAt,
|
|
64
|
+
})));
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
total: runs.length,
|
|
68
|
+
wins: wins.length,
|
|
69
|
+
deaths: deaths.length,
|
|
70
|
+
avgSpend,
|
|
71
|
+
bestRun,
|
|
72
|
+
recentRuns: runs.slice(-10).reverse(),
|
|
73
|
+
achievements: allAchievements.slice(-20).reverse(),
|
|
74
|
+
winRate: runs.length > 0 ? Math.round((wins.length / runs.length) * 100) : 0,
|
|
75
|
+
};
|
|
76
|
+
}
|