An RPG layer for Claude Code: earn XP from sharp prompts and lean token use, spend skill points on perks, and learn AI lore.

<img src="assets/classes.gif" alt="The four hero classes: Wanderer, Scribe, Alchemist and Sage" width="720">
An RPG layer for Claude Code. Level up by writing sharper prompts, spending tokens wisely and learning how modern AI works.
Install · How it plays · Skill tree · Commands · 📖 Full guide
Most wasted tokens come from vague prompts: Claude has to search for the file you meant, guess what "done" looks like, and write long answers you skim. Prompt Quest turns the habits that fix this into a game you play while you work:
src/auth.ts directly. Claude made 6 reads to find it."/clear, auto-compact), plus tokens, caching, RLHF, Constitutional AI, MoE and interpretability.A sharp prompt earns XP, with a live token ledger:
<img src="assets/band-strong.png" alt="Status band after a rank S prompt: +40 XP, token ledger" width="100%">
A vague prompt costs XP and tells you exactly what to add, with your own prompt rewritten:
<img src="assets/band-weak.png" alt="Status band after a rank D prompt: missing chips and a suggested rewrite" width="100%">
/quest opens the side pane with your hero, stats, last rank and chronicle:
<img src="assets/pane-hero.png" alt="Hero tab: animated pixel-art hero, stats, rank badge and chronicle" width="100%">
<table> <tr> <td width="50%" align="center"><img src="assets/hero.gif" alt="Hero animation: idle, level up, level down" width="320"><br><sub>Idle → level up ✨ → hit by a bad prompt 💥 → downcast 🌧</sub></td> <td width="50%" align="center"><img src="assets/ranks.gif" alt="Animated rank badges S A B C D F" width="400"><br><sub>Rank badges S · A · B · C · D · F</sub></td> </tr> </table>
Bad prompt? You take a hit. A fireball knocks your hero back, the XP you lost floats up as a damage number, and the lost part of the XP bar above your prompt blinks red, then drains away:
<table> <tr> <td width="50%" align="center"><img src="assets/hit.gif" alt="Hero hit by a fireball, -10 damage number floats up, then rain" width="300"><br><sub>Fireball → impact → −10 → rain</sub></td> <td width="50%" align="center"><img src="assets/xp-drain.gif" alt="XP bar: the lost part blinks red, then drains" width="400"><br><sub>The status band's XP bar losing 20 XP</sub></td> </tr> </table>
<sub>The pixel art above is rendered by the plugin's own drawing code (<code>scripts/render-assets.sh</code>). The terminal frames are faithful recreations of the plugin's layout and colors.</sub>
Run these one at a time in Claude Code. Each is a separate command.
1. Add the marketplace
/plugin marketplace add tomikng/prompt-quest
[!TIP] If you open the Add Marketplace dialog from the
/pluginmenu instead, paste onlytomikng/prompt-questinto the box.
2. Install the plugin
/plugin install prompt-quest@prompt-quest
3. Play
/quest
claude plugin marketplace add tomikng/prompt-quest
claude plugin install prompt-quest@prompt-quest
Then start claude and type /quest.
[!NOTE] Requires Claude Code 2.1.289+ (function-hook plugins, an early-access API). Pixel art draws in the terminal. Other surfaces get a text version. The side pane docks beside the transcript when the terminal is at least 144 columns wide.
Updates. Auto-update is off by default for community marketplaces like this one. To turn it on: /plugin → Marketplaces → prompt-quest → Enable auto-update. Claude Code then checks for updates each time a session starts.
To update by hand, use /plugin → Installed → Update now, or from a shell:
claude plugin marketplace update prompt-quest
claude plugin update prompt-quest@prompt-quest
Each prompt you type is graded on your machine with simple heuristics. No model call, no tokens.
| What your prompt has | Points |
|---|---|
| Something concrete: a file, function, command, error text, or a screenshot | +2 |
| or, mid-session, a short follow-up that builds on the task in progress | +1 |
| A reason: because…, so that… | +1 |
| A finish line: should, must, until… | +1 |
| A way to verify: run the tests, check that…, screenshot | +1 |
| A scope: only…, don't…, keep… | +1 |
| A plan first: propose a plan, before editing… | +1 |
| An example: e.g., a code block | +1 |
| A sensible length (12–600 words) | +1 |
| Vague and tiny ("fix it" with no context) | −2 |
| Rank | 👑 S | ⭐ A | 💎 B | ⚪ C | 🔻 D | 💀 F |
|---|---|---|---|---|---|---|
| Points | 5+ | 3–4 | 2 | 1 | 0 | ≤ −1 |
| XP | +30 | +20 | +10 | 0 | −10 | −20 |
S takes the habits that make agentic work go well: verification, scope, a plan or an example. Follow-ups like "now do the same for signup" aren't punished for not repeating context.
Questions are graded differently. You ask because you don't know, so a question is never penalized for not naming a file, a reason or a finish line. A clear question is 💎 B (+10), and one grounded in a file, an error or a screenshot is ⭐ A (+20). Replies to Claude ("yes push them", even "yeas pls") and 1–3 word steering right after a turn are neutral.
/quest rules shows all of this visually, with your last prompt broken down signal by signal:
<img src="assets/pane-rules.png" alt="Rules tab: last prompt breakdown, rank ladder, turn bonuses" width="100%">
The full rules, with worked examples, are in the 📖 Player's Guide.
| Event | XP |
|---|---|
| Cache hit ≥ 80% | +5 |
| Plan mode used in the turn | +5 |
| Text-only reply under 600 output tokens | +5 |
| Interrupted turn | −5 |
| Text-only reply over 8k output tokens | −5 |
/clear after 50k+ tokens of history | +10 |
| Lore card read · quiz answered right · daily quest done | +15 · +25 · +50 |
XP can drop below a level threshold. If you fall below the number of skills you've learned, your newest skills go dormant 💤 until you climb back.
After every turn, Prompt Quest looks at what Claude actually did, including files read and edited, searches run and test commands used, and turns it into advice:
One skill point per level. Tiers unlock in order within a branch. Perks that change Claude's replies can be toggled on and off.
<img src="assets/pane-skills.png" alt="Skills tab with the three branches" width="100%">
| Tier | 🪶 Scribe (read output better) | 🧪 Alchemist (spend tokens wisely) | 🔮 Sage (learn AI) |
|---|---|---|---|
| 1 | TL;DR Rune: replies end with a one-line TL;DR | Coin Purse: token ledger above the prompt | Oracle Quiz: quizzes on lore cards |
| 2 | Glossary Lens: jargon defined inline | Cache Sight: warns when the cache has gone cold | Deep Archives: the advanced lore deck |
| 3 | Why-Trace: the reason before every change | Terse Tongue: concise replies, fewer output tokens | Concept Spotter: a "📜 Lore:" note when an AI concept comes up |
| 4 | Answer-First: lead with the result | Budget Ward: session budget with 50/80/100% alarms | Oracle's Eye: /quest oracle <topic> conjures new cards |
Your class (and your hero's look) follows the branch you've invested in most.
Ranking runs locally, so cheating can't be made impossible, only pointless:
The grader is plain pattern matching, so there's no model to prompt-inject. See Fair play.
| Command | What it does |
|---|---|
/quest | Open the pane (tabs Hero, Skills, Quests, Lore, Rules on keys 1–5) |
/quest rules | How ranking works, visually, with your last prompt broken down |
/quest skills · quests · lore | Open a specific tab |
/quest close | Close the pane (or press Close / x) |
/quest band | Show or hide the status band above the prompt |
/quest budget 50k | Set the session output-token budget (Budget Ward) |
/quest oracle <topic> | New lore card via a small Haiku call (Oracle's Eye) |
/quest feedback [--prompt] <why> | Report a rank that felt wrong (opens a pre-filled GitHub issue) |
/quest idea <text> | Suggest an improvement |
/quest reset confirm | Start over at level 1 |
The grading rules are simple heuristics, and they improve through your reports. If a rank feels wrong, press Report under Last prompt in the Hero tab, or run /quest feedback <why>.
--prompt).Ideas go through /quest idea <text>. All reports land in Issues, labeled rank-feedback, enhancement or bug.
~/.claude/plugins/store/./quest oracle, which uses a few hundred Haiku tokens.Does it change how Claude behaves? Only if you learn and enable a perk (TL;DR Rune, Terse Tongue and so on). Perks add a short section to the system prompt, and toggling one rebuilds the prompt cache once.
Will it cost me tokens? No, apart from the optional Oracle. Perks like Terse Tongue usually save tokens.
Can I hide it? /quest close closes the pane and /quest band hides the status band.
claude --plugin-dir ./plugins/prompt-quest # run it from source
claude plugin validate ./plugins/prompt-quest # check the manifest and hooks
claude plugin test ./plugins/prompt-quest # run the tests
./scripts/render-assets.sh # regenerate README images
plugins/prompt-quest/
├── hooks/register.tsx # hooks, pane, status band, animation loop
├── hooks/data.ts # skills, ranking rules, tips, quests, lore
├── hooks/art.ts # pixel art: hero scenes, rank badges, bars
├── types/index.d.ts # state contract
└── tests/
Issues and PRs are welcome, especially new lore cards and pixel art.
MIT © tomikng
hooks/register.tsx 894 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Card, Grade, Save, Session, Tab } from '../types'
5import { barCells, heroScene, HIT_FRAMES, rankBadge, RANK_GLYPH, scoreMeterCells } from './art'
6import type { HeroClass, Mood } from './art'
7import {
8 BRANCHES, LORE, PROMPT_TIPS, QUEST_XP, SKILLS,
9 bashFiles, checkOf, concretize, emptyTrace, grade, isSearch, LADDER, promptHash, RANK_XP, levelOf, questsFor, RANK_COLOR, relPath, skill, titleOf, turnTips,
10} from './data'
11
12const PANE = 'prompt-quest'
13const FRAME_MS = 200
14const MOOD_MS = 9000
15const CACHE_TTL_MS = 5 * 60 * 1000
16
17const FRESH: Save = {
18 xp: 0, unlocked: [], perksOff: [],
19 stats: { prompts: 0, turns: 0, inTok: 0, outTok: 0, cacheRead: 0, cacheWrite: 0, ranks: {} },
20 day: '', quests: {}, questsDone: [], streak: 0, lastDay: '',
21 loreSeen: [], quizRight: 0, quizTotal: 0, custom: [],
22 budget: 50000, showBand: true, log: [],
23}
24const NEW_SESSION: Session = { outTok: 0, alerts: [], lastTurnAt: 0, last: null, mood: 'idle', moodAt: 0 }
25
26const saveA = atom({ plugin: 'prompt-quest', key: 'save' } as const, FRESH)
27const sessionA = atom({ plugin: 'prompt-quest', key: 'session' } as const, NEW_SESSION)
28const tabA = atom({ plugin: 'prompt-quest', key: 'tab' } as const, 'hero')
29const cardA = atom({ plugin: 'prompt-quest', key: 'card' } as const, 0)
30const quizA = atom({ plugin: 'prompt-quest', key: 'quiz' } as const, null)
31
32// ── derived helpers ───────────────────────────────────────────────────────
33
34/** Skills stay active only while your level covers them; newest go dormant first. */
35const activeSkills = (s: Save) => s.unlocked.slice(0, levelOf(s.xp).level)
36const isActive = (s: Save, id: string) => activeSkills(s).includes(id)
37const points = (s: Save) => Math.max(0, levelOf(s.xp).level - s.unlocked.length)
38
39function heroClass(s: Save): HeroClass {
40 const act = activeSkills(s)
41 let best: HeroClass = 'novice'
42 let n = 0
43 for (const b of BRANCHES) {
44 const c = act.filter(id => skill(id)?.branch === b.id).length
45 if (c > n) { n = c; best = b.id }
46 }
47 return best
48}
49
50const MISSING_LABEL: Record<string, string> = { file: 'which file', why: 'why', 'done-check': 'what done means' }
51const CLASS_ICON: Record<HeroClass, string> = { novice: '🧭', scribe: '🪶', alchemist: '🧪', sage: '🔮' }
52const className = (c: HeroClass) => (c === 'novice' ? 'Wanderer' : BRANCHES.find(b => b.id === c)!.name)
53
54function dayOf(ms: number) {
55 const d = new Date(ms)
56 return new Date(ms - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10)
57}
58
59const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : `${n}`)
60const textBar = (into: number, need: number, width = 10) => {
61 const full = Math.max(0, Math.min(width, Math.round((into / need) * width)))
62 return '#'.repeat(full) + '-'.repeat(width - full)
63}
64
65function deck(s: Save): Card[] {
66 return [
67 ...LORE.filter(c => c.deck === 'foundations'),
68 ...LORE.filter(c => c.deck === 'internals'),
69 ...(isActive(s, 'archives') ? LORE.filter(c => c.deck === 'archives') : []),
70 ...s.custom,
71 ]
72}
73
74const pushLog = (s: Save, line: string): Save => ({ ...s, log: [...s.log, line].slice(-8) })
75
76// ── animation state (module-local; a reload just restarts it) ────────────
77
78let frame = 0
79let paneLive = false
80let look: { cls: HeroClass; mood: Mood; moodAt: number; rank: Grade['rank'] | null; bar: number | null; dmg: number } = {
81 cls: 'novice', mood: 'idle', moodAt: 0, rank: null, bar: null, dmg: 0,
82}
83let bandRequest: string | null = null
84
85/** The mood to draw now, and for a hit how many frames into it we are. */
86function moodNow(now: number): { mood: Mood; t: number } {
87 const age = now - look.moodAt
88 if (look.mood === 'idle' || age >= MOOD_MS) return { mood: 'idle', t: 0 }
89 if (look.mood !== 'hit') return { mood: look.mood, t: 0 }
90 const t = Math.floor(age / FRAME_MS)
91 return t < HIT_FRAMES ? { mood: 'hit', t } : { mood: 'down', t }
92}
93
94/** Share of the XP bar to flash red while a hit drains it. */
95function lostShare(xp: number, t: number) {
96 if (look.dmg >= 0 || t >= HIT_FRAMES) return 0
97 const lv = levelOf(xp)
98 const drain = t < 8 ? 1 : 1 - (t - 8) / (HIT_FRAMES - 8)
99 return Math.min(-look.dmg / lv.need, 1 - lv.into / lv.need) * drain
100}
101
102// ── writes ────────────────────────────────────────────────────────────────
103
104type $T = EngineInterface
105
106async function commit($: $T, fn: (s: Save) => Save) {
107 const s = await update($, saveA, fn)
108 await $.store.set('save', s)
109 return s
110}
111
112/** Applies XP and quest progress in one write and announces level changes. */
113async function award($: $T, delta: number, why: string, questBumps: string[] = []) {
114 let before = FRESH
115 let doneNow: string[] = []
116 const s = await commit($, cur => {
117 before = cur
118 let next: Save = { ...cur, xp: Math.max(0, cur.xp + delta) }
119 if (delta !== 0) next = pushLog(next, `${delta > 0 ? '+' : ''}${delta} XP · ${why}`)
120 const today = questsFor(next.day)
121 for (const id of questBumps) {
122 const q = today.find(x => x.id === id)
123 if (!q || next.questsDone.includes(id)) continue
124 const n = (next.quests[id] ?? 0) + 1
125 next = { ...next, quests: { ...next.quests, [id]: n } }
126 if (n >= q.goal) {
127 next = pushLog({ ...next, xp: next.xp + QUEST_XP, questsDone: [...next.questsDone, id] }, `Quest done: ${q.name} +${QUEST_XP}`)
128 doneNow = [...doneNow, q.name]
129 }
130 }
131 return next
132 })
133 for (const name of doneNow) $.ui.toast(`📜 Quest complete: ${name} +${QUEST_XP} XP`)
134 const a = levelOf(before.xp).level
135 const b = levelOf(s.xp).level
136 const now = await $.clock.now()
137 if (b > a) {
138 $.ui.toast(`✨ LEVEL UP! You are now level ${b} (${titleOf(b)}). +1 skill point. /quest skills`)
139 await setMood($, 'up', now)
140 } else if (b < a) {
141 const dormant = s.unlocked.slice(b)
142 $.ui.toast(`💀 Level down… level ${b}.${dormant.length ? ` Dormant: ${dormant.map(id => skill(id)?.name).join(', ')}` : ''}`)
143 await setMood($, 'hit', now, Math.min(-1, delta))
144 } else if (delta >= 20) await setMood($, 'up', now)
145 else if (delta < 0) {
146 $.ui.toast(`💥 Ouch! ${delta} XP`)
147 await setMood($, 'hit', now, delta)
148 }
149 return s
150}
151
152async function setMood($: $T, mood: Mood, now: number, dmg = 0) {
153 look = { ...look, mood, moodAt: now, dmg }
154 await update($, sessionA, ss => ({ ...ss, mood, moodAt: now, dmg }))
155}
156
157// ── module ────────────────────────────────────────────────────────────────
158
159export const register: Register = on => {
160 let pending: Grade | null = null
161 let pendingText = ''
162 let tools = 0
163 let trace = emptyTrace()
164 // Model and context size of the last main turn: for cache-break and /clear rules.
165 let lastModel: string | null = null
166 let lastContext = 0
167 // Files the last turn touched, so a follow-up naming one counts as building on it.
168 let lastFiles: string[] = []
169
170 on('session.start', async ($, e, next) => {
171 const stored = (await $.store.get('save')) as Partial<Save> | undefined
172 if (stored) await update($, saveA, () => ({ ...FRESH, ...stored, stats: { ...FRESH.stats, ...stored.stats } }))
173 await $.command.register({
174 name: 'quest',
175 description: 'Prompt Quest: hero, skills, quests, lore · /quest [skills|quests|lore|close|band|budget <n>|oracle <topic>|rules|feedback [--prompt] <why>|idea <text>]',
176 })
177 $.clock.every(FRAME_MS, () => {
178 frame += 1
179 void (async () => {
180 const now = await $.clock.now()
181 const { mood, t } = moodNow(now)
182 // The band's XP bar flashes and drains the lost XP, pane open or not.
183 const sinceHit = look.mood === 'hit' ? Math.floor((now - look.moodAt) / FRAME_MS) : -1
184 if (bandRequest && sinceHit >= 0 && sinceHit <= HIT_FRAMES + 2) {
185 const s = await read($, saveA)
186 const lv = levelOf(s.xp)
187 await $.ui.blit({ requestId: bandRequest!, key: 'xpbar', ...barCells(lv.into / lv.need, 20, 0x8b5cf6, 0xf472b6, frame, lostShare(s.xp, sinceHit)) })
188 }
189 if (!paneLive) return
190 if ((await read($, tabA)) !== 'hero') return
191 const r = await $.ui.blit({ requestId: PANE, key: 'hero', ...heroScene(look.cls, mood, frame, { t, dmg: look.dmg }).cells() })
192 if ('deny' in r && r.deny) { paneLive = false; return }
193 if (look.rank) await $.ui.blit({ requestId: PANE, key: 'badge', ...rankBadge(look.rank, frame).cells() })
194 if (look.bar !== null) await $.ui.blit({ requestId: PANE, key: 'xpbar', ...barCells(look.bar, 24, 0x8b5cf6, 0xf472b6, frame) })
195 })()
196 })
197 return next(e)
198 })
199
200 // Grade each prompt you type (no model call; pure heuristics).
201 on('prompt.submit', async ($, e, next) => {
202 if (e.origin.kind !== 'composer' || e.text.trim().startsWith('/')) return next(e)
203 const now = await $.clock.now()
204 const before = await read($, sessionA)
205 const recent = before.lastTurnAt > 0 && (await $.clock.now()) - before.lastTurnAt < 30 * 60_000
206 let g = grade(e.text, e.attachments?.length ?? 0, { recent, files: lastFiles })
207 // Repeating a prompt earns nothing: only a hash is kept, never the text.
208 const hash = promptHash(e.text)
209 const seen = await read($, saveA)
210 if (g.mode !== 'reply' && (seen.recent ?? []).includes(hash)) {
211 g = { ...g, xp: 0, reasons: [...g.reasons, 'repeated prompt: no XP'], tip: 'You sent this prompt before, so it earns no XP this time.' }
212 }
213 pending = g
214 pendingText = e.text
215 tools = 0
216 trace = emptyTrace()
217 const today = dayOf(now)
218 const s = await commit($, cur => {
219 let s2 = cur
220 if (s2.day !== today) {
221 const yesterday = dayOf(now - 86400000)
222 s2 = {
223 ...s2, day: today, quests: {}, questsDone: [],
224 streak: s2.lastDay === yesterday ? s2.streak + 1 : 1, lastDay: today,
225 }
226 }
227 return {
228 ...s2,
229 recent: [...(s2.recent ?? []).filter(x => x !== hash), hash].slice(-50),
230 stats: { ...s2.stats, prompts: s2.stats.prompts + 1, ranks: { ...s2.stats.ranks, [g.rank]: (s2.stats.ranks[g.rank] ?? 0) + 1 } },
231 }
232 })
233 const ss = await read($, sessionA)
234 if (isActive(s, 'cachesight') && ss.lastTurnAt && now - ss.lastTurnAt > CACHE_TTL_MS) {
235 const min = Math.round((now - ss.lastTurnAt) / 60000)
236 $.ui.toast(`🔮 Cache Sight: ${min} min idle. The prompt cache (5-min default TTL) is likely cold, so this turn re-reads the context at full input price.`)
237 }
238 return next(e)
239 })
240
241 // Remember which files Claude had to find, so tips can name them.
242 // /clear after a long conversation is exactly the habit that saves money.
243 on('session.end', async ($, e, next) => {
244 if (e.reason === 'clear') {
245 const ctx = lastContext
246 lastContext = 0
247 if (ctx >= 50_000) await award($, 10, `Clean Slate: /clear with ${Math.round(ctx / 1000)}k history`, ['clear'])
248 else if (ctx > 0) $.ui.toast('🧹 Cleared. (Clean Slate XP needs 50k+ tokens of history to make a difference.)')
249 }
250 return next(e)
251 })
252
253 on('session.compact', async ($, e, next) => {
254 if (e.trigger === 'auto' && !e.agentId) {
255 $.ui.toast('📦 Auto-compact: older history was summarized to free space. /clear between unrelated tasks keeps it rarely needed.')
256 }
257 return next(e)
258 })
259
260 on('tool.call', (_$, e, next) => {
261 tools += 1
262 const name = String(e.tool)
263 const input = e as unknown as Record<string, unknown>
264 const path = typeof input.file_path === 'string' ? input.file_path
265 : typeof input.notebook_path === 'string' ? input.notebook_path : null
266 if (name === 'EnterPlanMode' || name === 'ExitPlanMode') trace.planned = true
267 if (name === 'Read' && path) trace.reads[path] = (trace.reads[path] ?? 0) + 1
268 else if ((name === 'Edit' || name === 'Write' || name === 'MultiEdit' || name === 'NotebookEdit') && path) trace.edits.push(path)
269 else if (name === 'Grep' || name === 'Glob') trace.searches += 1
270 else if (name === 'Bash' && typeof input.command === 'string') {
271 if (isSearch(input.command)) trace.searches += 1
272 const f = bashFiles(input.command)
273 for (const p of f.paths) {
274 if (f.writes) trace.edits.push(p)
275 else trace.reads[p] = (trace.reads[p] ?? 0) + 1
276 }
277 trace.check = checkOf(input.command) ?? trace.check
278 }
279 return next(e)
280 })
281
282 on('turn.complete', async ($, e, next) => {
283 const result = await next(e)
284 if (e.agentId) return result
285 const cwd = await $.session.cwd()
286 const home = cwd.match(/^\/(home|Users)\/[^/]+/)?.[0] ?? ''
287 const g = pending ? concretize(pending, trace, p => relPath(p, cwd, home)) : null
288 pending = null
289 const u = e.usage
290 const now = await $.clock.now()
291 // Prompt XP needs a real turn: usage comes from the API, not from anything typed.
292 let delta = u ? (g?.xp ?? 0) : 0
293 const notes: string[] = g ? [`rank ${g.rank} ${g.xp >= 0 ? '+' : '−'}${Math.abs(g.xp)}`, ...g.reasons] : []
294 const bumps: string[] = ['turn']
295 if (g && (g.rank === 'S' || g.rank === 'A')) bumps.push('sharp')
296 if (g?.reasons.includes('purpose stated')) bumps.push('purpose')
297 if (e.reason === 'aborted') { delta -= 5; notes.push('abandoned turn −5') }
298 if (trace.planned) { delta += 5; notes.push('plan mode +5') }
299 let spendTips: string[] = []
300 if (u) {
301 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
302 const ratio = total ? u.cache_read_input_tokens / total : 0
303 const modelSwitch = lastModel !== null && u.model !== lastModel && ratio < 0.5 && total > 2000
304 // Tip, not a penalty: opusplan switches models on purpose.
305 if (modelSwitch) notes.push('cache break: model switched')
306 lastModel = u.model
307 lastContext = total
308 spendTips = turnTips({ out: u.output_tokens, ratio, total, tools, aborted: e.reason === 'aborted', modelSwitch })
309 if (ratio >= 0.8 && total > 2000) { delta += 5; notes.push('cache hit +5'); bumps.push('cache') }
310 // Output size only matters for text-only turns: code changes are the work, not chatter.
311 const textOnly = trace.edits.length === 0
312 if (textOnly && u.output_tokens < 600) { delta += 5; notes.push('lean reply +5'); bumps.push('lean') }
313 if (textOnly && u.output_tokens > 8000) { delta -= 5; notes.push('heavy output −5') }
314 }
315 const s = await award($, delta, g ? `Rank ${g.rank} prompt` : 'turn', bumps)
316 await commit($, cur => u ? {
317 ...cur,
318 stats: {
319 ...cur.stats, turns: cur.stats.turns + 1,
320 inTok: cur.stats.inTok + u.input_tokens, outTok: cur.stats.outTok + u.output_tokens,
321 cacheRead: cur.stats.cacheRead + u.cache_read_input_tokens, cacheWrite: cur.stats.cacheWrite + u.cache_creation_input_tokens,
322 },
323 } : { ...cur, stats: { ...cur.stats, turns: cur.stats.turns + 1 } })
324 const ss = await update($, sessionA, cur => ({
325 ...cur,
326 lastTurnAt: now,
327 outTok: cur.outTok + (u?.output_tokens ?? 0),
328 last: {
329 prompt: g ? pendingText : undefined, grade: g, xp: delta, notes,
330 tips: [...(g && g.rank !== 'S' ? g.tips : []), ...spendTips],
331 inTok: u?.input_tokens ?? 0, outTok: u?.output_tokens ?? 0,
332 cacheRead: u?.cache_read_input_tokens ?? 0, cacheWrite: u?.cache_creation_input_tokens ?? 0,
333 },
334 }))
335 if (g) look = { ...look, rank: g.rank }
336 lastFiles = [...new Set([...trace.edits, ...Object.keys(trace.reads)].map(f => f.split('/').pop() ?? f))].slice(0, 20)
337 if (g) $.ui.toast(`${RANK_GLYPH[g.rank]} Rank ${g.rank} · ${delta >= 0 ? '+' : ''}${delta} XP${g.xp < 10 ? ` · 💡 ${g.tip}` : ''}`)
338 if (isActive(s, 'budget') && s.budget > 0) {
339 const pct = (ss.outTok / s.budget) * 100
340 const hit = [50, 80, 100].filter(p => pct >= p && !ss.alerts.includes(p))
341 if (hit.length) {
342 const p = hit[hit.length - 1]!
343 $.ui.toast(`🛡 Budget Ward: ${p}% of session output budget (${k(ss.outTok)}/${k(s.budget)})`)
344 await update($, sessionA, cur => ({ ...cur, alerts: [...cur.alerts, ...hit] }))
345 }
346 }
347 return result
348 })
349
350 // Active perks shape how Claude answers (one cached section; toggling busts the cache once).
351 on('prompt.compose', async ($, e, next) => {
352 const r = await next(e)
353 const s = await read($, saveA)
354 const perks = activeSkills(s)
355 .filter(id => !s.perksOff.includes(id))
356 .map(id => skill(id)?.perk)
357 .filter((p): p is string => !!p)
358 if (!perks.length) return r
359 const text = `# Prompt Quest perks (the user unlocked these output styles)\n${perks.map(p => `- ${p}`).join('\n')}`
360 return { sections: [...r.sections, { id: 'prompt-quest:perks', text, scope: 'session' as const }] }
361 })
362
363 // ── /quest ──────────────────────────────────────────────────────────────
364
365 on('command.run', { command: 'quest' }, async ($, e) => {
366 const [sub = '', ...rest] = e.args.trim().split(/\s+/)
367 const arg = rest.join(' ')
368 const open = async (tab: Tab) => {
369 await update($, tabA, () => tab)
370 await $.ui.open({ id: PANE, title: 'Prompt Quest' })
371 }
372 if (/^(close|hide|off|x)$/.test(sub) || /\b(close|hide)\b/.test(arg)) {
373 paneLive = false
374 await $.ui.close({ id: PANE })
375 return { text: 'Prompt Quest closed. /quest opens it again.' }
376 }
377 if (sub === 'band') {
378 const s = await commit($, cur => ({ ...cur, showBand: !cur.showBand }))
379 return { text: `Quest band ${s.showBand ? 'shown' : 'hidden'}.` }
380 }
381 if (sub === 'budget') {
382 const n = Number(arg.replace(/k$/i, '000'))
383 if (!Number.isFinite(n) || n <= 0) return { text: 'Usage: /quest budget 50000 (output tokens per session)' }
384 await commit($, cur => ({ ...cur, budget: Math.round(n) }))
385 return { text: `Session output budget set to ${k(n)} tokens.` }
386 }
387 if (sub === 'oracle') return oracle($, arg, open)
388 if (sub === 'feedback' || sub === 'idea') {
389 const withPrompt = /(^|\s)--prompt\b/.test(arg)
390 return report($, sub === 'idea' ? 'idea' : 'rank', arg.replace(/(^|\s)--prompt\b/, '').trim(), withPrompt)
391 }
392 if (sub === 'reset' && arg === 'confirm') {
393 await commit($, () => FRESH)
394 return { text: 'Your hero was reborn at level 1.' }
395 }
396 const tab: Tab = sub === 'skills' || sub === 'quests' || sub === 'lore' || sub === 'rules' ? sub
397 : sub === 'help' || sub === 'ranking' ? 'rules' : 'hero'
398 await open(tab)
399 return { text: 'Prompt Quest opened. /quest close hides it.' }
400 })
401
402 // ── band above the prompt ───────────────────────────────────────────────
403
404 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
405 const s = await read($, saveA)
406 if (!s.showBand || e.props.hasSurvey) return next(e)
407 const ss = await read($, sessionA)
408 const { Box, Text } = $.ui.resolve(e)
409 const lv = levelOf(s.xp)
410 const cls = heroClass(s)
411 const pts = points(s)
412 const last = ss.last
413 const g = last?.grade
414 const purse = isActive(s, 'purse') && last
415 const tips = last?.tips ?? []
416 const missing = g?.missing ?? []
417 const total = last ? last.inTok + last.cacheRead + last.cacheWrite : 0
418 const ratio = total && last ? Math.round((last.cacheRead / total) * 100) : 0
419 const els = $.ui.resolve(e) as Record<string, any>
420 const Raster = e.surface === 'terminal' ? els.Raster : undefined
421 if (Raster && e.requestId) bandRequest = e.requestId
422 const hitNow = moodNow(await $.clock.now())
423 return (
424 <Box flexDirection="column">
425 <Box gap={1}>
426 <Text>{CLASS_ICON[cls]}</Text>
427 <Text color="#ffd166" bold>Lv {lv.level}</Text>
428 <Text>{titleOf(lv.level)} {className(cls)}</Text>
429 {Raster
430 ? <Raster key="xpbar" {...barCells(lv.into / lv.need, 20, 0x8b5cf6, 0xf472b6, frame, hitNow.mood === 'hit' ? lostShare(s.xp, hitNow.t) : 0)} />
431 : <Text color="#a78bfa">{textBar(lv.into, lv.need)}</Text>}
432 <Text dimColor>{lv.into}/{lv.need} XP</Text>
433 {pts > 0 && <Text color="#fde047" bold>+{pts} skill point{pts > 1 ? 's' : ''}: /quest skills</Text>}
434 {s.streak > 1 && <Text color="#fb923c">🔥 {s.streak}d</Text>}
435 </Box>
436 {g && last && (
437 <Box>
438 <Text backgroundColor={RANK_COLOR[g.rank]} color="#111111" bold> {g.rank} </Text>
439 <Text color={last.xp >= 0 ? '#5fff87' : '#ff5f5f'} bold> {last.xp >= 0 ? '+' : ''}{last.xp} XP</Text>
440 <Text dimColor wrap="truncate-end"> · {last.notes.join(', ')}</Text>
441 </Box>
442 )}
443 {missing.length > 0 && (
444 <Box gap={1}>
445 <Text color="#ff9f43" bold>🎯 Missing:</Text>
446 {missing.map(m => <Text backgroundColor="#3a3a52" color="#ffd166"> {MISSING_LABEL[m] ?? m} </Text>)}
447 {tips.length > 1 && <Text dimColor>({tips.length} tips: /quest)</Text>}
448 </Box>
449 )}
450 {g?.upgrade && <Text color="#5fff87" wrap="truncate-end">✏️ Try: {g.upgrade}</Text>}
451 {!g?.upgrade && tips.length > 0 && (
452 <Text color="#5fb3ff" wrap="truncate-end">💡 {tips[0]}</Text>
453 )}
454 {purse && last && (
455 <Text dimColor wrap="truncate-end">
456 🪙 in {k(last.inTok + last.cacheWrite)} · cache {k(last.cacheRead)} ({ratio}%) · out {k(last.outTok)} · session out {k(ss.outTok)}
457 {isActive(s, 'budget') ? `/${k(s.budget)}` : ''}
458 </Text>
459 )}
460 </Box>
461 )
462 })
463
464 // ── the pane ────────────────────────────────────────────────────────────
465
466 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
467 const s = await read($, saveA)
468 const ss = await read($, sessionA)
469 const tab = await read($, tabA)
470 const { Box, Text, Button } = $.ui.resolve(e)
471 const width = e.props.bodyColumns
472 const terminal = e.surface === 'terminal'
473 look = { ...look, cls: heroClass(s), mood: ss.mood, moodAt: ss.moodAt, dmg: ss.dmg ?? look.dmg, rank: ss.last?.grade?.rank ?? look.rank, bar: levelOf(s.xp).into / levelOf(s.xp).need }
474 paneLive = terminal
475
476 const tabs: [Tab, string][] = [['hero', 'Hero'], ['skills', 'Skills'], ['quests', 'Quests'], ['lore', 'Lore'], ['rules', 'Rules']]
477 const header = (
478 <Box gap={1}>
479 {tabs.map(([id, label], i) => (
480 <Button key={`tab-${id}`} label={label} hotkey={String(i + 1)}
481 variant={tab === id ? 'primary' : 'secondary'} onPress={() => update($, tabA, () => id)} />
482 ))}
483 <Button key="close" label="Close" hotkey="x" role="dismiss"
484 onPress={async () => { paneLive = false; await $.ui.close({ id: PANE }) }} />
485 </Box>
486 )
487
488 let body
489 if (tab === 'skills') body = await skillsTab($, e, s)
490 else if (tab === 'quests') body = questsTab($, e, s)
491 else if (tab === 'lore') body = await loreTab($, e, s)
492 else if (tab === 'rules') body = rulesTab($, e, ss)
493 else body = await heroTab($, e, s, ss, width, terminal)
494
495 return (
496 <Box flexDirection="column" gap={1}>
497 {header}
498 {body}
499 </Box>
500 )
501 })
502}
503
504// ── tabs ──────────────────────────────────────────────────────────────────
505
506type RenderE = Parameters<EngineInterface['ui']['resolve']>[0]
507
508async function heroTab($: $T, e: RenderE, s: Save, ss: Session, width: number, terminal: boolean) {
509 const { Box, Text, Button } = $.ui.resolve(e)
510 const els = $.ui.resolve(e) as Record<string, any>
511 const Raster = terminal ? els.Raster : undefined
512 const lv = levelOf(s.xp)
513 const cls = heroClass(s)
514 const now = await $.clock.now()
515 const { mood, t: hitT } = moodNow(now)
516 const st = s.stats
517 const inAll = st.inTok + st.cacheRead + st.cacheWrite
518 const hit = inAll ? Math.round((st.cacheRead / inAll) * 100) : 0
519 const avgOut = st.turns ? Math.round(st.outTok / st.turns) : 0
520 const ranks = (['S', 'A', 'B', 'C', 'D', 'F'] as const)
521 const g = ss.last?.grade
522 const dormant = s.unlocked.slice(lv.level)
523 const tip = PROMPT_TIPS[(st.prompts + Math.floor(now / 600000)) % PROMPT_TIPS.length]!
524
525 const sheet = (
526 <Box flexDirection="column">
527 <Text bold color="#ffd166">{titleOf(lv.level)} {className(cls)} · Level {lv.level}</Text>
528 <Box gap={1}>
529 {Raster
530 ? <Raster key="xpbar" {...barCells(lv.into / lv.need, 24, 0x8b5cf6, 0xf472b6, frame)} />
531 : <Text color="#a78bfa">{textBar(lv.into, lv.need, 16)}</Text>}
532 <Text>{lv.into}/{lv.need} XP</Text>
533 </Box>
534 <Text dimColor>Total {s.xp} XP · {points(s)} skill point(s) unspent · 🔥 {s.streak}-day streak</Text>
535 <Text> </Text>
536 <Text bold>Stats</Text>
537 <Text>Prompts {st.prompts} · Turns {st.turns}</Text>
538 <Text>Cache hit {hit}% · Avg output {k(avgOut)} tok</Text>
539 <Text>Quiz {s.quizRight}/{s.quizTotal} · Lore {s.loreSeen.filter(x => !x.startsWith('q:')).length}</Text>
540 <Box>
541 {ranks.map(r => (
542 <Text color={RANK_COLOR[r]}>{r}:{st.ranks[r] ?? 0} </Text>
543 ))}
544 </Box>
545 {dormant.length > 0 && <Text color="#ff9f43">💤 Dormant: {dormant.map(id => skill(id)?.name).join(', ')}</Text>}
546 {mood === 'up' && <Text color="#fde047" bold>✨ Your hero is triumphant!</Text>}
547 {mood === 'hit' && <Text color="#ff5f5f" bold>💥 Ouch! Your hero took a hit ({look.dmg} XP).</Text>}
548 {mood === 'down' && <Text color="#93c5fd" bold>🌧 Your hero is downcast… sharpen the next prompt.</Text>}
549 </Box>
550 )
551
552 const scene = heroScene(cls, mood, frame, { t: hitT, dmg: look.dmg }).cells()
553 const art = Raster ? <Raster key="hero" {...scene} /> : null
554 const wide = width >= scene.columns + 30
555
556 const badge = g && Raster ? <Raster key="badge" {...rankBadge(g.rank, frame).cells()} /> : null
557 const rankCard = g && ss.last ? (
558 <Box gap={2}>
559 {badge}
560 <Box flexDirection="column" flexShrink={1}>
561 <Text bold color={RANK_COLOR[g.rank]}>Last prompt: Rank {g.rank} {ss.last.xp >= 0 ? '+' : ''}{ss.last.xp} XP</Text>
562 <Text>{ss.last.notes.join(' · ') || '—'}</Text>
563 {(ss.last.tips ?? []).length > 0 && <Text bold color="#5fb3ff">How to improve</Text>}
564 {(ss.last.tips ?? []).map(t => <Text>💡 {t}</Text>)}
565 {g.upgrade && <Text color="#5fff87">✏️ Try: <Text italic>{g.upgrade}</Text></Text>}
566 <Box gap={1} marginTop={1}>
567 <Text dimColor>Rank feel wrong?</Text>
568 <Button key="report" label="Report" hotkey="r" onPress={() => report($, 'rank', '', false)} />
569 <Button key="report-prompt" label="Report with my prompt" onPress={() => report($, 'rank', '', true)} />
570 </Box>
571 </Box>
572 </Box>
573 ) : (
574 <Text dimColor>Send a prompt to get your first rank. S/A/B earn XP, D/F lose it, and you can drop a level.</Text>
575 )
576
577 return (
578 <Box flexDirection="column" gap={1}>
579 <Box flexDirection={wide ? 'row' : 'column'} gap={2}>
580 {art}
581 {sheet}
582 </Box>
583 {rankCard}
584 <Box flexDirection="column">
585 <Text bold>Chronicle</Text>
586 {s.log.length === 0 && <Text dimColor>Your story has not begun.</Text>}
587 {s.log.slice(-5).map(line => <Text dimColor={!line.startsWith('+')} color={line.startsWith('-') ? '#ff8787' : undefined}>{line}</Text>)}
588 </Box>
589 <Text color="#5fb3ff">💡 {tip}</Text>
590 </Box>
591 )
592}
593
594async function skillsTab($: $T, e: RenderE, s: Save) {
595 const { Box, Text, Button } = $.ui.resolve(e)
596 const pts = points(s)
597 const act = activeSkills(s)
598 return (
599 <Box flexDirection="column" gap={1}>
600 <Text><Text bold color="#fde047">{pts}</Text> skill point{pts === 1 ? '' : 's'} · 1 per level · perks shape Claude’s replies (toggling refreshes the prompt cache once)</Text>
601 {BRANCHES.map(b => (
602 <Box flexDirection="column">
603 <Text bold color={b.color}>{b.icon} {b.name.toUpperCase()} <Text dimColor>({b.theme})</Text></Text>
604 {SKILLS.filter(x => x.branch === b.id).map(sk => {
605 const owned = s.unlocked.includes(sk.id)
606 const live = act.includes(sk.id)
607 const prev = SKILLS.find(x => x.branch === b.id && x.tier === sk.tier - 1)
608 const open = !prev || s.unlocked.includes(prev.id)
609 const off = s.perksOff.includes(sk.id)
610 const icon = owned ? (live ? (off ? '🟡' : '🟢') : '💤') : open ? '⚪' : '🔒'
611 return (
612 <Box flexDirection="column" marginLeft={2}>
613 <Box gap={1}>
614 <Text color={owned ? b.color : undefined} dimColor={!owned && !open}>{'│ '.repeat(0)}{icon} T{sk.tier} {sk.name}</Text>
615 {!owned && open && pts > 0 && (
616 <Button key={`learn-${sk.id}`} label="Learn (1 point)" variant="primary"
617 onPress={async () => {
618 await commit($, cur => points(cur) > 0 && !cur.unlocked.includes(sk.id)
619 ? pushLog({ ...cur, unlocked: [...cur.unlocked, sk.id] }, `Learned ${sk.name}`) : cur)
620 $.ui.toast(`${b.icon} Learned ${sk.name}!`)
621 }} />
622 )}
623 {owned && live && sk.perk && (
624 <Button key={`toggle-${sk.id}`} label={off ? 'Off' : 'On'}
625 onPress={() => commit($, cur => ({
626 ...cur,
627 perksOff: cur.perksOff.includes(sk.id) ? cur.perksOff.filter(x => x !== sk.id) : [...cur.perksOff, sk.id],
628 }))} />
629 )}
630 </Box>
631 <Text dimColor> {sk.blurb}{owned && !live ? ' (dormant: regain your level)' : ''}</Text>
632 </Box>
633 )
634 })}
635 </Box>
636 ))}
637 </Box>
638 )
639}
640
641function questsTab($: $T, e: RenderE, s: Save) {
642 const { Box, Text } = $.ui.resolve(e)
643 const Raster = e.surface === 'terminal' ? ($.ui.resolve(e) as Record<string, any>).Raster : undefined
644 const today = s.day ? questsFor(s.day) : []
645 return (
646 <Box flexDirection="column" gap={1}>
647 <Text bold color="#ffd166">📜 Daily quests · {s.day || 'start by sending a prompt'}</Text>
648 {today.map(q => {
649 const n = Math.min(q.goal, s.quests[q.id] ?? 0)
650 const done = s.questsDone.includes(q.id)
651 return (
652 <Box flexDirection="column">
653 <Text color={done ? '#5fff87' : undefined} bold>{done ? '✅' : '🎯'} {q.name} <Text dimColor>+{QUEST_XP} XP</Text></Text>
654 <Box gap={1} marginLeft={3}>
655 <Text>{q.desc}</Text>
656 {Raster
657 ? <Raster key={`qbar-${q.id}`} {...barCells(n / q.goal, 12, done ? 0x22c55e : 0x38bdf8, done ? 0x86efac : 0xa78bfa)} />
658 : <Text color="#a78bfa">{textBar(n, q.goal, q.goal)}</Text>}
659 <Text dimColor>{n}/{q.goal}</Text>
660 </Box>
661 </Box>
662 )
663 })}
664 <Text dimColor>🔥 Streak: {s.streak} day{s.streak === 1 ? '' : 's'} · new quests every day</Text>
665 <Box flexDirection="column">
666 <Text bold>How XP works</Text>
667 <Text dimColor>Prompt rank: S +30 · A +20 · B +10 · C 0 · D −10 · F −20</Text>
668 <Text dimColor>Rank up by naming files/functions, stating your purpose and saying what “done” means.</Text>
669 <Text dimColor>Turn bonuses: cache hit ≥80% +5 · reply under 600 tokens +5 · interrupted −5 · over 8k output −5</Text>
670 </Box>
671 </Box>
672 )
673}
674
675async function loreTab($: $T, e: RenderE, s: Save) {
676 const { Box, Text, Button } = $.ui.resolve(e)
677 const cards = deck(s)
678 const idx = (await read($, cardA)) % cards.length
679 const card = cards[idx]!
680 const quiz = await read($, quizA)
681 const seen = s.loreSeen.includes(card.id)
682 const quizzed = s.loreSeen.includes(`q:${card.id}`)
683 const deckName = card.deck === 'oracle' ? '🔮 Oracle' : card.deck === 'archives' ? '🏛 Deep Archives' : '📖 Foundations'
684 const showQuiz = isActive(s, 'quiz')
685 const picked = quiz && quiz.cardId === card.id ? quiz.picked : null
686
687 return (
688 <Box flexDirection="column" gap={1}>
689 <Text dimColor>{deckName} · card {idx + 1}/{cards.length}{isActive(s, 'archives') ? '' : ' · learn Deep Archives for more'}</Text>
690 <Box flexDirection="column" borderStyle="round" borderColor="#c58cff" paddingX={1}>
691 <Text bold color="#c58cff">✨ {card.title} {seen ? '✅' : ''}</Text>
692 <Text>{card.body}</Text>
693 </Box>
694 <Box gap={1}>
695 <Button key="prev" label="< Prev" hotkey="p" onPress={() => update($, cardA, n => (n - 1 + cards.length) % cards.length)} />
696 {!seen && (
697 <Button key="learned" label="Learned +15 XP" variant="primary" hotkey="l"
698 onPress={async () => {
699 const cur = await read($, saveA)
700 if (cur.loreSeen.includes(card.id)) return
701 await commit($, x => ({ ...x, loreSeen: [...x.loreSeen, card.id] }))
702 await award($, 15, `Lore: ${card.title}`, ['lore'])
703 }} />
704 )}
705 <Button key="next" label="Next >" hotkey="n" onPress={() => update($, cardA, n => (n + 1) % cards.length)} />
706 </Box>
707 {showQuiz ? (
708 <Box flexDirection="column">
709 <Text bold color="#ffd166">❓ {card.q}</Text>
710 {card.options.map((opt, i) => {
711 const mark = picked === null ? '' : i === card.answer ? ' ✅' : i === picked ? ' ❌' : ''
712 return (
713 <Button key={`opt-${i}`} label={`${'abc'[i]}) ${opt}${mark}`} plain hotkey={'abc'[i]}
714 onPress={async () => {
715 if (picked !== null) return
716 await update($, quizA, () => ({ cardId: card.id, picked: i }))
717 const right = i === card.answer
718 const cur = await read($, saveA)
719 if (cur.loreSeen.includes(`q:${card.id}`)) return
720 await commit($, x => ({
721 ...x, loreSeen: [...x.loreSeen, `q:${card.id}`],
722 quizTotal: x.quizTotal + 1, quizRight: x.quizRight + (right ? 1 : 0),
723 }))
724 if (right) await award($, 25, `Quiz: ${card.title}`, ['quizright'])
725 else $.ui.toast('❌ Not quite. Read the card again!')
726 }} />
727 )
728 })}
729 {picked !== null && <Text color={picked === card.answer ? '#5fff87' : '#ff8787'}>{picked === card.answer ? (quizzed ? 'Correct!' : 'Correct! +25 XP') : 'Wrong. The right answer is marked ✅.'}</Text>}
730 </Box>
731 ) : (
732 <Text dimColor>Learn “Oracle Quiz” (Sage) to quiz yourself for XP.</Text>
733 )}
734 {isActive(s, 'oracle') && <Text dimColor>🔮 /quest oracle <topic> conjures a new card.</Text>}
735 </Box>
736 )
737}
738
739function rulesTab($: $T, e: RenderE, ss: Session) {
740 const { Box, Text } = $.ui.resolve(e)
741 const Raster = e.surface === 'terminal' ? ($.ui.resolve(e) as Record<string, any>).Raster : undefined
742 const g = ss.last?.grade ?? null
743 const mode = g?.mode ?? (g ? 'task' : null)
744 const order = ['F', 'D', 'C', 'B', 'A', 'S'] as const
745 const sign = (n: number) => (n > 0 ? `+${n}` : n < 0 ? `−${-n}` : '0')
746
747 const ladder = (
748 <Box flexDirection="column">
749 {Raster && <Raster key="meter" {...scoreMeterCells(g ? g.rank : null, frame)} />}
750 <Box>
751 {order.map(r => <Text color={RANK_COLOR[r]} bold>{`${r}`.padEnd(6)}</Text>)}
752 </Box>
753 <Box>
754 {LADDER.map(l => <Text dimColor>{l.points.padEnd(6)}</Text>)}
755 </Box>
756 <Box>
757 {order.map(r => <Text color={RANK_COLOR[r]}>{sign(RANK_XP[r]).padEnd(6)}</Text>)}
758 </Box>
759 <Text dimColor>points → rank → XP</Text>
760 </Box>
761 )
762
763 let breakdown
764 if (!g) breakdown = <Text dimColor>Send a prompt and its breakdown appears here.</Text>
765 else if (mode === 'reply') breakdown = (
766 <Box flexDirection="column">
767 <Text>Judged as a <Text bold>reply to Claude</Text> (short, starts with yes/no/ok/sure…).</Text>
768 <Text dimColor>Replies are neutral: rank C, 0 XP, no tips. Steering Claude is never punished.</Text>
769 </Box>
770 )
771 else if (mode === 'question') breakdown = (
772 <Box flexDirection="column">
773 <Text>Judged as a <Text bold>question</Text> (ends with ? or starts with where/what/how/is/can…).</Text>
774 <Text>{g.reasons.includes('grounded') ? '✅' : '⬜'} Points at a file, error or screenshot → A (+20), otherwise B (+10)</Text>
775 <Text dimColor>Questions never lose XP for a missing file/why/done: you ask because you don’t know.</Text>
776 </Box>
777 )
778 else breakdown = (
779 <Box flexDirection="column">
780 {(g.parts ?? []).filter(p => !p.penalty || p.hit).map(p => (
781 <Box gap={1}>
782 <Text>{p.penalty ? '⚠️' : p.hit ? '✅' : '⬜'}</Text>
783 <Text color={p.hit ? (p.pts > 0 ? '#5fff87' : '#ff5f5f') : '#7f849c'} bold>{(p.hit ? sign(p.pts) : '0').padStart(2)}</Text>
784 <Text dimColor={!p.hit}>{p.label}{!p.hit && !p.penalty ? ` (worth ${sign(p.pts)})` : ''}</Text>
785 </Box>
786 ))}
787 <Text> </Text>
788 <Text>Score <Text bold>{g.score ?? '?'}</Text> → Rank <Text bold color={RANK_COLOR[g.rank]}>{g.rank}</Text> → <Text bold color={g.xp >= 0 ? '#5fff87' : '#ff5f5f'}>{sign(g.xp)} XP</Text></Text>
789 </Box>
790 )
791
792 return (
793 <Box flexDirection="column" gap={1}>
794 <Text bold color="#ffd166">📏 How ranking works</Text>
795 <Box flexDirection="column">
796 <Text bold>Your last prompt</Text>
797 {breakdown}
798 </Box>
799 <Box flexDirection="column">
800 <Text bold>The ladder</Text>
801 {ladder}
802 </Box>
803 <Box flexDirection="column">
804 <Text bold>Then the turn adds</Text>
805 <Text>🪙 <Text color="#5fff87">+5</Text> cache hit: ≥80% of input read from the prompt cache (real API counts)</Text>
806 <Text>🗺 <Text color="#5fff87">+5</Text> Plan mode: Claude planned before changing anything</Text>
807 <Text>🪶 <Text color="#5fff87">+5</Text> lean reply: under 600 output tokens (text-only turns)</Text>
808 <Text>✋ <Text color="#ff5f5f">−5</Text> interrupted turn · 🐘 <Text color="#ff5f5f">−5</Text> text reply over 8k tokens</Text>
809 <Text>🔀 <Text dimColor>tip</Text> cache break: model switched mid-session and the cache went cold (no XP lost; opusplan does this on purpose)</Text>
810 <Text>🧹 <Text color="#5fff87">+10</Text> /clear after 50k+ tokens of history (starting fresh is cheaper)</Text>
811 </Box>
812 <Text dimColor>All of this runs locally, with no tokens spent. Full guide: HELP.md · Wrong rank? Press Report on the Hero tab.</Text>
813 </Box>
814 )
815}
816
817// ── Oracle's Eye: a new lore card from Haiku ──────────────────────────────
818
819async function oracle($: $T, topic: string, open: (t: Tab) => Promise<void>) {
820 const s = await read($, saveA)
821 if (!isActive(s, 'oracle')) return { text: "Oracle's Eye is not learned yet (Sage tier 4)." }
822 if (!topic) return { text: 'Usage: /quest oracle <AI topic>, e.g. /quest oracle attention sinks' }
823 $.ui.toast(`🔮 The Oracle gazes into “${topic}”…`)
824 const r = await $.model.complete({
825 model: 'claude-haiku-4-5',
826 maxTokens: 500,
827 system: 'You write short, accurate educational flashcards about AI/ML. Reply with JSON only.',
828 prompt: `Topic: ${topic}\nReturn JSON: {"title": string (<=5 words), "body": string (2-3 plain sentences, accurate, beginner-friendly), "q": string (one quiz question), "options": [3 short strings], "answer": 0|1|2}`,
829 })
830 if (!r.isAnswered) return { text: `The Oracle is silent (${r.reason}).` }
831 const m = r.text.match(/\{[\s\S]*\}/)
832 try {
833 const j = JSON.parse(m ? m[0] : r.text) as Partial<Card>
834 if (!j.title || !j.body || !j.q || !Array.isArray(j.options) || j.options.length !== 3 || typeof j.answer !== 'number') throw new Error('shape')
835 const card: Card = { id: `oracle-${Date.now()}`, deck: 'oracle', title: j.title, body: j.body, q: j.q, options: j.options.map(String), answer: j.answer }
836 const next = await commit($, cur => ({ ...cur, custom: [...cur.custom, card].slice(-40) }))
837 await update($, cardA, () => deck(next).length - 1)
838 await open('lore')
839 const cost = r.usage.input_tokens + r.usage.output_tokens
840 return { text: `🔮 New lore card: ${card.title} (${cost} Haiku tokens).` }
841 } catch {
842 return { text: 'The Oracle mumbled something unreadable. Try another topic.' }
843 }
844}
845
846// ── Feedback: a pre-filled GitHub issue the person reviews and submits ────
847
848const REPO = 'https://github.com/tomikng/prompt-quest'
849
850async function report($: $T, kind: 'rank' | 'idea', comment: string, withPrompt: boolean) {
851 const ss = await read($, sessionA)
852 const last = ss.last
853 const g = last?.grade
854 const version = await $.session.version().then(v => v.version).catch(() => 'unknown')
855 const params = new URLSearchParams()
856 if (kind === 'idea') {
857 params.set('template', 'idea.yml')
858 params.set('title', `Idea: ${comment.slice(0, 60) || '…'}`)
859 if (comment) params.set('idea', comment)
860 } else {
861 if (!g || !last) return { text: 'No graded prompt yet. Send a prompt first, then report its rank.' }
862 params.set('template', 'rank-feedback.yml')
863 params.set('title', `Rank ${g.rank} felt wrong${comment ? `: ${comment.slice(0, 50)}` : ''}`)
864 params.set('rank', g.rank)
865 params.set('details', [
866 `Rank: ${g.rank} (${g.xp >= 0 ? '+' : ''}${g.xp} XP), turn total ${last.xp >= 0 ? '+' : ''}${last.xp} XP`,
867 `Signals: ${g.reasons.join(', ') || 'none'}`,
868 `Missing: ${(g.missing ?? []).join(', ') || 'none'}`,
869 `Notes: ${last.notes.join(', ')}`,
870 `Words: ${(last.prompt ?? '').split(/\s+/).filter(Boolean).length}`,
871 `Plugin 0.4.1 · Claude Code ${version}`,
872 ].join('\n'))
873 if (comment) params.set('why', comment)
874 if (withPrompt && last.prompt) params.set('prompt', last.prompt.slice(0, 1500))
875 }
876 const url = `${REPO}/issues/new?${params.toString().replace(/\+/g, '%20')}`
877 let opened = false
878 for (const argv of [['xdg-open', url], ['open', url]]) {
879 try {
880 const r = await $.process.run(argv, { timeoutMs: 5000 })
881 if (r.exitCode === 0) { opened = true; break }
882 } catch { /* not this platform */ }
883 }
884 const today = dayOf(await $.clock.now())
885 const s = await read($, saveA)
886 if (s.feedbackDay !== today) {
887 await commit($, cur => ({ ...cur, feedbackDay: today }))
888 await award($, 10, kind === 'idea' ? 'Shared an idea' : 'Reported a rank')
889 }
890 $.ui.toast(opened ? '🐞 Opened a pre-filled issue in your browser. Review it and press Submit.' : '🐞 Issue link ready (see the transcript).')
891 const privacy = kind === 'rank' ? (withPrompt ? ' Your prompt text is included; edit it out if you like.' : ' Your prompt text is NOT included.') : ''
892 return { text: `${opened ? 'Opened' : 'Open this link to file it'}: ${url}\nNothing is sent until you press Submit on GitHub.${privacy}` }
893}
894hooks/art.ts 367 lines1import type { Rank } from '../types'
2
3// Pixel art drawn into a Raster with half blocks: one cell = two vertical pixels.
4
5type Px = number // 0x00RRGGBB, or -1 for transparent
6const NONE = -1
7const DEFAULT = 0x01000000
8
9export class Canvas {
10 readonly w: number
11 readonly h: number
12 px: Px[]
13 constructor(w: number, h: number) {
14 this.w = w
15 this.h = h
16 this.px = new Array(w * h).fill(NONE)
17 }
18 set(x: number, y: number, c: Px) {
19 if (c === NONE || x < 0 || y < 0 || x >= this.w || y >= this.h) return
20 this.px[y * this.w + x] = c
21 }
22 get(x: number, y: number) {
23 return this.px[y * this.w + x] ?? NONE
24 }
25 sprite(rows: string[], ox: number, oy: number, pal: Record<string, Px>) {
26 rows.forEach((row, y) => {
27 for (let x = 0; x < row.length; x++) {
28 const c = pal[row[x]!]
29 if (c !== undefined) this.set(ox + x, oy + y, c)
30 }
31 })
32 }
33 // Encode as RasterProps.cells: [codePoint, fg, bg] u32 LE per cell, base64.
34 cells(): { columns: number; rows: number; cells: string } {
35 const rows = Math.ceil(this.h / 2)
36 const bytes = new Uint8Array(this.w * rows * 12)
37 const view = new DataView(bytes.buffer)
38 let o = 0
39 for (let cy = 0; cy < rows; cy++) {
40 for (let x = 0; x < this.w; x++) {
41 const top = this.get(x, cy * 2)
42 const bot = cy * 2 + 1 < this.h ? this.get(x, cy * 2 + 1) : NONE
43 let cp = 0x20, fg = DEFAULT, bg = DEFAULT
44 if (top !== NONE && bot !== NONE) { cp = 0x2580; fg = top; bg = bot }
45 else if (top !== NONE) { cp = 0x2580; fg = top }
46 else if (bot !== NONE) { cp = 0x2584; fg = bot }
47 view.setUint32(o, cp, true)
48 view.setUint32(o + 4, fg, true)
49 view.setUint32(o + 8, bg, true)
50 o += 12
51 }
52 }
53 return { columns: this.w, rows, cells: base64(bytes) }
54 }
55}
56
57const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
58function base64(b: Uint8Array) {
59 let s = ''
60 for (let i = 0; i < b.length; i += 3) {
61 const n = (b[i]! << 16) | ((b[i + 1] ?? 0) << 8) | (b[i + 2] ?? 0)
62 s += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
63 s += i + 1 < b.length ? B64[(n >> 6) & 63]! : '='
64 s += i + 2 < b.length ? B64[n & 63]! : '='
65 }
66 return s
67}
68
69const mix = (a: Px, b: Px, t: number): Px => {
70 const ch = (s: number) => Math.round(((a >> s) & 255) * (1 - t) + ((b >> s) & 255) * t)
71 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
72}
73const grey = (c: Px): Px => {
74 const l = Math.round((((c >> 16) & 255) * 0.3 + ((c >> 8) & 255) * 0.59 + (c & 255) * 0.11) * 0.8)
75 return (l << 16) | (l << 8) | l
76}
77
78// ── Hero scene ────────────────────────────────────────────────────────────
79
80export type HeroClass = 'novice' | 'scribe' | 'alchemist' | 'sage'
81export type Mood = 'idle' | 'up' | 'down' | 'hit'
82
83/** How many animation frames a hit lasts before the hero turns downcast. */
84export const HIT_FRAMES = 16
85
86const HERO = [
87 '....HHHH....',
88 '...HHHHHH...',
89 '..HHHHHHHH..',
90 '..HSSSSSSH..',
91 '..HSESSESH..',
92 '...SSSSSS...',
93 '...SSppSS...',
94 '....SSSS....',
95 '..RRRRRRRR..',
96 '.RRRRTTRRRR.',
97 'SRRRRTTRRRRS',
98 'SRRRBBBBRRRS',
99 '.RRRRTTRRRR.',
100 '.rRRRTTRRRr.',
101 '.rrRRRRRRrr.',
102 '..rrrrrrrr..',
103 '..KK....KK..',
104 '..KK....KK..',
105]
106
107const HAT = [
108 '.....Y......',
109 '.....PP.....',
110 '....PPPP....',
111 '...PPPPPP...',
112 '.PPPPPPPPPP.',
113]
114
115const ROBES: Record<HeroClass, { R: Px; r: Px; T: Px; H: Px }> = {
116 novice: { R: 0x9c7a54, r: 0x6b5235, T: 0xd9c49a, H: 0x4a3220 },
117 scribe: { R: 0x3b82f6, r: 0x1e4fa3, T: 0xffd166, H: 0x2b1d14 },
118 alchemist: { R: 0xe8792b, r: 0x9a4a12, T: 0x2dd4bf, H: 0x7a1f1f },
119 sage: { R: 0x8b5cf6, r: 0x5b30b0, T: 0xfde047, H: 0xe5e5e5 },
120}
121
122function item(cv: Canvas, cls: HeroClass, x: number, y: number, f: number) {
123 if (cls === 'scribe') {
124 cv.sprite(['...W', '..WW', '.WWg', '.Wg.', 'Wg..', 'g...'], x, y - 3 + (f % 8 < 4 ? 0 : -1),
125 { W: 0xffffff, g: 0xffd166 })
126 } else if (cls === 'alchemist') {
127 const liquid = [0x22d3ee, 0x34d399, 0xf472b6, 0xa78bfa][Math.floor(f / 3) % 4]!
128 cv.sprite(['.kk..', '..g..', '.gLg.', 'gLLLg', 'gLLLg', '.ggg.'], x, y, { k: 0x8b5a2b, g: 0xcfe8ff, L: liquid })
129 const by = y - 1 - (f % 5)
130 cv.set(x + 2 + (f % 2), by, mix(liquid, 0xffffff, 0.5))
131 } else if (cls === 'sage') {
132 for (let i = 0; i < 14; i++) cv.set(x + 1, y - 4 + i, 0x8b5a2b)
133 const glow = mix(0x7c3aed, 0x67e8f9, (Math.sin(f / 2) + 1) / 2)
134 cv.sprite(['.O.', 'OOO', '.O.'], x, y - 7, { O: glow })
135 if (f % 6 < 3) cv.set(x + 3, y - 8, 0xffffff)
136 } else {
137 cv.sprite(['.s', '.s', '.s', '.s', 'hhh', '.w'], x, y, { s: 0xd1d5db, h: 0x8b5a2b, w: 0x6b4423 })
138 }
139}
140
141// Seeded star positions so stars stay put between frames.
142const STARS = Array.from({ length: 14 }, (_, i) => ({
143 x: (i * 37 + 11) % 34, y: (i * 17 + 3) % 12, p: i % 5,
144}))
145const FLOWERS = [3, 9, 15, 24, 30]
146
147export function heroScene(cls: HeroClass, mood: Mood, f: number, hit?: { t: number; dmg: number }): Canvas {
148 const W = 34, H = 30
149 const cv = new Canvas(W, H)
150 const ground = H - 5
151 const t = mood === 'hit' ? (hit?.t ?? 0) : -1
152 const impact = t >= 4 && t <= 5
153 // sky gradient
154 const top = mood === 'down' ? 0x1f2430 : t >= 4 ? 0x3b1020 : 0x1b1340
155 const bot = mood === 'down' ? 0x4b5563 : mood === 'up' ? 0xf59e0b : t >= 4 ? 0x7f1d1d : 0x3b5bdb
156 for (let y = 0; y < ground; y++) {
157 let c = mix(top, bot, y / ground)
158 if (impact) c = mix(c, t === 4 ? 0xffffff : 0xef4444, 0.45)
159 for (let x = 0; x < W; x++) cv.set(x, y, c)
160 }
161 // stars or moon
162 if (mood !== 'down') {
163 for (const s of STARS) if ((f + s.p) % 6 !== 0) cv.set(s.x, s.y, (f + s.p) % 6 === 1 ? 0xffffff : 0xc7d2fe)
164 cv.sprite(['.MM.', 'MMMM', 'MMMM', '.MM.'], W - 7, 2, { M: 0xfef3c7 })
165 }
166 // grass
167 for (let y = ground; y < H; y++) {
168 for (let x = 0; x < W; x++) {
169 const base = y === ground ? 0x4ade80 : mix(0x16a34a, 0x14532d, (y - ground) / 5)
170 cv.set(x, y, mood === 'down' ? grey(base) : base)
171 }
172 }
173 for (const fx of FLOWERS) {
174 const sway = (f + fx) % 8 < 4 ? 0 : 1
175 cv.set(fx + sway, ground - 1, [0xf472b6, 0xfde047, 0x60a5fa][fx % 3]!)
176 cv.set(fx, ground, 0x166534)
177 }
178
179 // hero
180 const jump = mood === 'up' ? [0, -2, -3, -2, 0, 0][f % 6]! : 0
181 const bob = mood === 'idle' && f % 8 >= 4 ? 1 : 0
182 const knock = t >= 4 ? ([-3, -2, -2, -1, -1][t - 4] ?? 0) : 0
183 const hx = 9 + knock, hy = ground - 18 + jump + bob + (t === 4 || t === 5 ? 1 : 0)
184 const robe = ROBES[cls]
185 const blink = f % 20 === 0
186 const pal: Record<string, Px> = {
187 H: robe.H, S: 0xf1c27d, E: blink ? 0xf1c27d : 0x1f2937, p: mood === 'down' ? 0x7f1d1d : 0xe11d48,
188 R: robe.R, r: robe.r, T: robe.T, B: 0x3f2a14, K: 0x292524,
189 }
190 if (mood === 'down') for (const k of ['R', 'r', 'T', 'H']) pal[k] = grey(pal[k]!)
191 if (t >= 4 && t < 10) {
192 pal.E = 0xdc2626
193 pal.p = 0x450a0a
194 if (t % 2 === 0) for (const k of ['R', 'r', 'T', 'H', 'S']) pal[k] = mix(pal[k]!, 0xef4444, 0.6)
195 }
196 // soft shadow
197 for (let x = hx + 1; x < hx + 11; x++) cv.set(x, ground, 0x15803d)
198 cv.sprite(HERO, hx, hy, pal)
199 if (cls === 'sage') cv.sprite(HAT, hx, hy - 4, { P: pal.R!, Y: 0xfde047 })
200 item(cv, cls, hx + 12, hy + 7, f)
201
202 if (mood === 'up') {
203 // golden sparkles orbiting the hero
204 for (let i = 0; i < 6; i++) {
205 const a = f / 3 + (i * Math.PI) / 3
206 const x = Math.round(hx + 6 + Math.cos(a) * 9)
207 const y = Math.round(hy + 8 + Math.sin(a) * 9)
208 cv.set(x, y, i % 2 ? 0xfde047 : 0xffffff)
209 }
210 } else if (mood === 'hit') {
211 drawHit(cv, t, hx, hy, W, hit?.dmg ?? 0)
212 } else if (mood === 'down') {
213 // rain cloud and drops
214 cv.sprite(['..CCCC..', '.CCCCCCC', 'CCCCCCCC'], hx + 2, Math.max(0, hy - 7), { C: 0x6b7280 })
215 for (let i = 0; i < 4; i++) {
216 const y = Math.max(0, hy - 4) + ((f + i * 3) % 8)
217 cv.set(hx + 3 + i * 2, y, 0x60a5fa)
218 }
219 }
220 return cv
221}
222
223const DIGITS: Record<string, string[]> = {
224 '0': ['###', '#.#', '#.#', '#.#', '###'], '1': ['.#.', '##.', '.#.', '.#.', '###'],
225 '2': ['###', '..#', '###', '#..', '###'], '3': ['###', '..#', '.##', '..#', '###'],
226 '4': ['#.#', '#.#', '###', '..#', '..#'], '5': ['###', '#..', '###', '..#', '###'],
227 '6': ['###', '#..', '###', '#.#', '###'], '7': ['###', '..#', '.#.', '.#.', '.#.'],
228 '8': ['###', '#.#', '###', '#.#', '###'], '9': ['###', '#.#', '###', '..#', '###'],
229 '-': ['...', '...', '###', '...', '...'],
230}
231
232/** Pixel text with a 1px dark outline so it reads on any sky. */
233function pixelText(cv: Canvas, text: string, x: number, y: number, color: Px) {
234 const glyphs = [...text].map(ch => DIGITS[ch] ?? DIGITS['-']!)
235 const draw = (dx: number, dy: number, c: Px) =>
236 glyphs.forEach((g, i) => cv.sprite(g, x + i * 4 + dx, y + dy, { '#': c }))
237 for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]] as const) draw(dx, dy, 0x1a0505)
238 draw(0, 0, color)
239}
240
241function drawHit(cv: Canvas, t: number, hx: number, hy: number, W: number, dmg: number) {
242 const chestX = hx + 11, chestY = hy + 10
243 if (t < 4) {
244 // a fireball streaks in from the right
245 const x = Math.round(W - 2 - (t / 4) * (W - 2 - chestX))
246 cv.sprite(['..oO.', 'ooOYO', '..oO.'], x, chestY - 1, { o: 0xf97316, O: 0xef4444, Y: 0xfde047 })
247 for (let i = 1; i <= 3; i++) cv.set(x + 4 + i, chestY, mix(0xf97316, 0x1b1340, i / 4))
248 } else if (t < 7) {
249 // impact burst
250 const r = t - 3
251 for (let i = 0; i < 8; i++) {
252 const a = (i * Math.PI) / 4
253 cv.set(Math.round(chestX + Math.cos(a) * r * 2), Math.round(chestY + Math.sin(a) * r * 2), i % 2 ? 0xfde047 : 0xffffff)
254 }
255 cv.sprite(['.Y.', 'YWY', '.Y.'], chestX - 1, chestY - 1, { Y: 0xfde047, W: 0xffffff })
256 }
257 if (t >= 4 && dmg < 0) {
258 // floating damage number, fading out
259 const rise = Math.floor((t - 4) / 2)
260 const fade = t >= 13 ? (t - 12) / 4 : 0
261 const label = String(dmg)
262 pixelText(cv, label, Math.max(1, hx + 6 - label.length * 2), Math.max(1, hy - 3 - rise), mix(0xff3b3b, 0x7f1d1d, fade))
263 }
264}
265
266// ── Rank badges ───────────────────────────────────────────────────────────
267
268const FONT: Record<Rank, string[]> = {
269 S: ['###', '#..', '###', '..#', '###'],
270 A: ['.#.', '#.#', '###', '#.#', '#.#'],
271 B: ['##.', '#.#', '##.', '#.#', '##.'],
272 C: ['###', '#..', '#..', '#..', '###'],
273 D: ['##.', '#.#', '#.#', '#.#', '##.'],
274 F: ['###', '#..', '##.', '#..', '#..'],
275}
276
277export const RANK_PX: Record<Rank, Px> = {
278 S: 0xffd700, A: 0x4ade80, B: 0x60a5fa, C: 0xa3a3a3, D: 0xfb923c, F: 0xef4444,
279}
280
281export function rankBadge(rank: Rank, f: number): Canvas {
282 const W = 11, H = 16
283 const cv = new Canvas(W, H)
284 const fill = RANK_PX[rank]
285 const edge = mix(fill, 0x000000, 0.45)
286 const top = 3
287 // crown for S, star for A
288 if (rank === 'S') cv.sprite(['Y.Y.Y.Y.Y', 'YYYYYYYYY', '.YYRYYRY.'], 1, 0, { Y: 0xffd700, R: 0xef4444 })
289 if (rank === 'A') cv.sprite(['..W..', '.WWW.', '..W..'], 3, 0, { W: f % 4 < 2 ? 0xffffff : 0xbbf7d0 })
290 // shield
291 for (let y = 0; y < 12; y++) {
292 const inset = y < 8 ? 0 : y - 7
293 for (let x = inset; x < W - inset; x++) {
294 const border = x === inset || x === W - 1 - inset || y === 0 || y === 11 || (y >= 8 && (x === inset + 1 || x === W - 2 - inset) && false)
295 cv.set(x, top + y, border ? edge : fill)
296 }
297 }
298 // shine sweep: a diagonal band crossing every ~16 frames
299 const s = (f % 16) - 3
300 for (let y = 0; y < 12; y++) {
301 for (const d of [0, 1]) {
302 const x = s + d - Math.floor(y / 2)
303 const cur = cv.get(x, top + y)
304 if (cur === fill) cv.set(x, top + y, mix(fill, 0xffffff, 0.55))
305 }
306 }
307 // letter
308 const ink = rank === 'S' || rank === 'C' ? 0x3b2f00 : 0xffffff
309 cv.sprite(FONT[rank], 4, top + 3, { '#': rank === 'C' ? 0x262626 : ink })
310 // crack for F
311 if (rank === 'F') cv.sprite(['..k', '.k.', 'k..', '.k.', '..k'], 1, top + 4, { k: 0x450a0a })
312 if (rank === 'D') cv.set(8, top + 9, 0x7c2d12)
313 return cv
314}
315
316// Static text fallback for surfaces without Raster.
317export const RANK_GLYPH: Record<Rank, string> = { S: '👑', A: '⭐', B: '💎', C: '⚪', D: '🔻', F: '💀' }
318
319// ── Glossy gradient bar (XP, quests) ──────────────────────────────────────
320
321export function barCells(frac: number, width: number, from: Px, to: Px, f = 0, lost = 0) {
322 const cv = new Canvas(width, 2)
323 const filled = Math.round(Math.max(0, Math.min(1, frac)) * width)
324 const lostTo = Math.min(width, filled + Math.round(Math.max(0, lost) * width))
325 const glint = filled > 2 ? f % (filled + 8) : -1
326 for (let x = 0; x < width; x++) {
327 if (x < filled) {
328 let c = mix(from, to, width > 1 ? x / (width - 1) : 0)
329 if (x === glint) c = mix(c, 0xffffff, 0.6)
330 cv.set(x, 0, mix(c, 0xffffff, 0.3))
331 cv.set(x, 1, c)
332 } else if (x < lostTo) {
333 const c = f % 2 ? 0xef4444 : 0xfca5a5
334 cv.set(x, 0, mix(c, 0xffffff, 0.2))
335 cv.set(x, 1, c)
336 } else {
337 cv.set(x, 0, 0x3a3a52)
338 cv.set(x, 1, 0x2a2a3c)
339 }
340 }
341 return cv.cells()
342}
343
344// ── Score meter for the Rules view: F D C B A S segments with a marker ────
345
346export function scoreMeterCells(rank: Rank | null, f = 0) {
347 const order: Rank[] = ['F', 'D', 'C', 'B', 'A', 'S']
348 const seg = 6
349 const cv = new Canvas(seg * order.length, 4)
350 order.forEach((r, i) => {
351 for (let x = 0; x < seg; x++) {
352 const c = RANK_PX[r]
353 const edge = x === seg - 1 ? mix(c, 0x000000, 0.35) : c
354 cv.set(i * seg + x, 2, mix(edge, 0xffffff, 0.25))
355 cv.set(i * seg + x, 3, edge)
356 }
357 })
358 if (rank !== null) {
359 const i = order.indexOf(rank)
360 const cx = i * seg + Math.floor(seg / 2) - 1
361 const glow = f % 4 < 2 ? 0xffffff : 0xfde047
362 cv.sprite(['#..#', '.##.'], cx - 1, 0, { '#': glow })
363 cv.set(cx, 1, glow); cv.set(cx + 1, 1, glow)
364 }
365 return cv.cells()
366}
367hooks/data.ts 518 lines1import type { Card, Grade, GradePart, Rank } from '../types'
2
3// ── Skill tree ────────────────────────────────────────────────────────────
4// Three branches, four tiers each. A node needs the one above it.
5// `perk` text goes into the system prompt while the node is active.
6
7export type Branch = 'scribe' | 'alchemist' | 'sage'
8
9export type Skill = {
10 id: string
11 branch: Branch
12 tier: number
13 name: string
14 blurb: string
15 perk?: string
16}
17
18export const BRANCHES: { id: Branch; name: string; icon: string; color: string; theme: string }[] = [
19 { id: 'scribe', name: 'Scribe', icon: '🪶', color: '#5fb3ff', theme: 'read output better' },
20 { id: 'alchemist', name: 'Alchemist', icon: '🧪', color: '#ffb347', theme: 'spend tokens wisely' },
21 { id: 'sage', name: 'Sage', icon: '🔮', color: '#c58cff', theme: 'learn the world of AI' },
22]
23
24export const SKILLS: Skill[] = [
25 {
26 id: 'tldr', branch: 'scribe', tier: 1, name: 'TL;DR Rune',
27 blurb: 'Claude ends substantial replies with a one-line TL;DR.',
28 perk: 'End every substantial reply with a final line starting "TL;DR:" that states the outcome in one sentence.',
29 },
30 {
31 id: 'glossary', branch: 'scribe', tier: 2, name: 'Glossary Lens',
32 blurb: 'Jargon gets a short inline definition the first time it appears.',
33 perk: 'The first time you use a technical term or acronym the user may not know, add a short parenthetical definition (max ~8 words).',
34 },
35 {
36 id: 'why', branch: 'scribe', tier: 3, name: 'Why-Trace',
37 blurb: 'Every change comes with the reason before the what.',
38 perk: 'When you change code or config, state in one short line why the change is needed before describing what changed.',
39 },
40 {
41 id: 'answerfirst', branch: 'scribe', tier: 4, name: 'Answer-First',
42 blurb: 'Lead with the answer, then details; tables for comparisons.',
43 perk: 'Lead with the direct answer or result in the first sentence, then supporting detail. Use a small table when comparing options.',
44 },
45 {
46 id: 'purse', branch: 'alchemist', tier: 1, name: 'Coin Purse',
47 blurb: 'A token ledger appears above the prompt after each turn.',
48 },
49 {
50 id: 'cachesight', branch: 'alchemist', tier: 2, name: 'Cache Sight',
51 blurb: 'Warns when the prompt cache has probably gone cold before you send.',
52 },
53 {
54 id: 'terse', branch: 'alchemist', tier: 3, name: 'Terse Tongue',
55 blurb: 'Claude answers more concisely, so fewer output tokens.',
56 perk: 'Be concise: no preamble, no restating the question, no closing summaries beyond one line. Prefer short sentences and bullet points.',
57 },
58 {
59 id: 'budget', branch: 'alchemist', tier: 4, name: 'Budget Ward',
60 blurb: 'Session output-token budget with alarms at 50/80/100%. /quest budget <n>',
61 },
62 {
63 id: 'quiz', branch: 'sage', tier: 1, name: 'Oracle Quiz',
64 blurb: 'Lore cards come with a quiz: +25 XP per right answer.',
65 },
66 {
67 id: 'archives', branch: 'sage', tier: 2, name: 'Deep Archives',
68 blurb: 'Unlocks the advanced lore deck (KV cache, RLHF, MoE, …).',
69 },
70 {
71 id: 'spotter', branch: 'sage', tier: 3, name: 'Concept Spotter',
72 blurb: 'When a reply touches an AI/ML concept, Claude adds a one-line lore note.',
73 perk: 'If your reply involves an AI/ML concept (tokens, context, caching, embeddings, sampling, agents, etc.), add one final line starting "📜 Lore:" explaining that concept in one sentence. Skip it when no such concept is involved.',
74 },
75 {
76 id: 'oracle', branch: 'sage', tier: 4, name: "Oracle's Eye",
77 blurb: 'Conjure a new lore card on any topic: /quest oracle <topic> (small Haiku call).',
78 },
79]
80
81export const skill = (id: string) => SKILLS.find(s => s.id === id)
82
83// ── Levels ────────────────────────────────────────────────────────────────
84
85export const xpToNext = (level: number) => 100 + 50 * (level - 1)
86
87export function levelOf(xp: number) {
88 let level = 1
89 let floor = 0
90 while (xp >= floor + xpToNext(level)) {
91 floor += xpToNext(level)
92 level += 1
93 }
94 return { level, into: xp - floor, need: xpToNext(level) }
95}
96
97export const titleOf = (level: number) =>
98 level >= 12 ? 'Archmage' : level >= 9 ? 'Master' : level >= 6 ? 'Expert' : level >= 3 ? 'Adept' : 'Apprentice'
99
100// ── Prompt grading (local heuristics, zero tokens) ────────────────────────
101
102export const RANK_XP: Record<Rank, number> = { S: 30, A: 20, B: 10, C: 0, D: -10, F: -20 }
103export const RANK_COLOR: Record<Rank, string> = {
104 S: '#ffd700', A: '#5fff87', B: '#5fb3ff', C: '#bbbbbb', D: '#ff9f43', F: '#ff5f5f',
105}
106
107const QUESTION = /\?\s*$|^(where|what|how|why|when|who|which|is|are|can|could|do|does|did|should|would|will|whats|what's|wheres|where's)\b/i
108const ACK = /^(let'?s (do it|go)|lets (do it|go)|y|yes|yeah|yep|yup|ok|okay|sure|go|go ahead|continue|proceed|do it|thanks|thank you|ty|no|nope|nah|lgtm|ship it|please|sounds good|perfect|great|cool|nice|agreed|correct|right|exactly)\b/i
109const ACK_WORDS = ['yes', 'yeah', 'yep', 'yup', 'ok', 'okay', 'sure', 'no', 'nope', 'nah', 'please', 'pls', 'plz', 'thanks', 'go', 'continue', 'proceed', 'lgtm', 'agreed', 'correct', 'right', 'exactly', 'perfect', 'great', 'cool', 'nice', 'k', 'kk', 'ye', 'ya', 'yea', 'yas', 'yess']
110
111const REAL_WORDS = ['now', 'not', 'new', 'yet', 'yes', 'see', 'set', 'use', 'got', 'god', 'ten', 'one', 'sun', 'sire', 'pure', 'cure', 'go', 'do', 'to', 'so', 'on', 'or', 'nice', 'rice', 'gone', 'nose', 'note', 'okra', 'pleas', 'cool', 'pool', 'tool']
112
113/** Edit distance ≤ 1 (one typo), for short words like "yeas", "yse", "oka". */
114function oneTypo(a: string, b: string) {
115 if (a === b) return true
116 if (Math.abs(a.length - b.length) > 1 || Math.min(a.length, b.length) < 2) return false
117 let i = 0, j = 0, edits = 0
118 while (i < a.length && j < b.length) {
119 if (a[i] === b[j]) { i++; j++; continue }
120 if (++edits > 1) return false
121 if (a.length > b.length) i++
122 else if (b.length > a.length) j++
123 else if (a[i + 1] === b[j] && a[i] === b[j + 1]) { i += 2; j += 2 } // swapped letters
124 else { i++; j++ }
125 }
126 return edits + (a.length - i) + (b.length - j) <= 1
127}
128
129/** Answers to a question Claude asked: "yes push them", "yeas pls", "no, keep it". Judged neutral. */
130const isReply = (t: string, words: number) => {
131 if (words > 8 || /\?\s*$/.test(t)) return false
132 if (ACK.test(t)) return true
133 const first = t.toLowerCase().split(/[\s,.!]+/)[0] ?? ''
134 if (ACK_WORDS.includes(first)) return true
135 // Typos only for 3+ letter reply words, and never for real words one letter away ("now", "yet", "not").
136 if (REAL_WORDS.includes(first)) return false
137 return ACK_WORDS.some(w => w.length >= 3 && oneTypo(first, w))
138}
139const VAGUE = /\b(fix (it|this)|doesn'?t work|not working|make it better|do something|it'?s broken|help)\b/i
140const ANCHOR = /`[^`]+`|(^|\s)[\w.-]*\/[\w./-]+|\b[\w-]+\.(ts|tsx|js|jsx|py|rs|go|md|json|lua|sh|toml|yaml|yml|css|html|c|cpp|h|java|rb|kt|swift|sql)\b|\b\w+\(\)|:\d+\b|https?:\/\/|@[\w./-]+/
141const PURPOSE = /\b(because|so that|goal|in order to|i need|i want|the aim|the point is|ideally|users? (can|can't|cannot|get|see))\b/i
142const DONE = /\b(should|must|expect(ed)?|make sure|until|done when|acceptance|so it (works|returns|shows)|returns?|instead of)\b/i
143const VERIFY = /\b(run (the )?(tests?|build|linter|lint|app)|tests?\b.{0,25}\b(pass|passes|green)|verify|check (that|it|the)|lint|typecheck|type-check|build (passes|succeeds)|reproduce|compare (it )?(with|to)|screenshot|curl)\b/i
144const SCOPE = /\b(only|don'?t|do not|without|keep|leave .{1,30} (alone|as is)|avoid|at most|at least|limit(ed)? to|no new|just the|scope)\b/i
145const PLAN = /\b(plan|propose|outline|options|approach(es)?|before (you )?(code|coding|implement|implementing|change|changing|edit|editing|making|writing)|ask me|interview me|think (it )?through|step by step|trade-?offs?)\b/i
146const EXAMPLE = /```|\b(e\.g\.|for example|for instance|like this|such as|example:)/i
147/** Words that lean on what was just being worked on: "do the same for…", "now also…", "the error". */
148const FOLLOW = /\b(it|this|that|these|those|same|also|again|too|as well|instead|now|next|then|above|previous|rest|remaining|the (error|test|bug|change|fix|diff|output|page|file|function))\b/i
149
150/** Points → rank. S needs 5+, so the top rank goes to prompts that are also verifiable, scoped or planned. */
151export function rankFor(score: number): Rank {
152 return score >= 5 ? 'S' : score >= 3 ? 'A' : score === 2 ? 'B' : score === 1 ? 'C' : score === 0 ? 'D' : 'F'
153}
154/** The ladder as the Rules view draws it. */
155export const LADDER: { rank: Rank; points: string }[] = [
156 { rank: 'F', points: '≤−1' }, { rank: 'D', points: '0' }, { rank: 'C', points: '1' },
157 { rank: 'B', points: '2' }, { rank: 'A', points: '3–4' }, { rank: 'S', points: '5+' },
158]
159
160const TIP = {
161 anchor: 'Point at something concrete: a file (`src/auth.ts`), a function (`refresh()`), a command, or the exact error text.',
162 purpose: 'Add the why ("because users get logged out") so Claude can pick the right trade-off.',
163 criteria: 'Say what done looks like: "tests pass", "no new dependencies", "keep the public API the same".',
164 verify: 'Give Claude a way to check its own work: "run npm test", "compare with the screenshot", "curl the endpoint".',
165 scope: 'Set boundaries: "only touch src/auth.ts", "don’t change the public API", "no new dependencies".',
166 plan: 'For a bigger change, ask for a plan first ("propose a plan before editing") or switch to Plan mode (shift+tab).',
167 terse: 'Give at least one full sentence. Very short prompts make Claude guess, and guesses cost extra turns.',
168 vague: 'Replace "fix it" with what you saw and what you expected instead.',
169 wall: 'Lead with the ask in the first line, then the context. Long specs are fine; give them headings or bullets.',
170}
171
172/** What the session knows when a prompt arrives: was there a turn just now, and which files did it touch. */
173export type GradeContext = { recent: boolean; files: string[] }
174
175export function grade(text: string, attachments = 0, ctx: GradeContext = { recent: false, files: [] }): Grade {
176 const hasImage = attachments > 0 || /\[Image #\d+\]/.test(text)
177 const t = text.replace(/\[Image #\d+\]/g, '').trim()
178 const words = t.split(/\s+/).filter(Boolean).length
179 // Mid-session, a 1–3 word message is steering ("next", "go on", "yeas pls"): neutral, never punished.
180 const reply = isReply(t, words)
181 if ((reply || (ctx.recent && words <= 3 && !ANCHOR.test(t))) && !hasImage) {
182 const tip = reply ? 'Replies like this are fine when answering Claude. No XP gained or lost.' : 'Short steering mid-task is fine. No XP gained or lost.'
183 return { rank: 'C', xp: 0, reasons: [reply ? 'reply to Claude' : 'short steer'], tip, tips: [], missing: [], upgrade: null, mode: 'reply' }
184 }
185 // Questions are judged on clarity, not on file/why/done: you ask because you don't know.
186 if (QUESTION.test(t) && words <= 80) {
187 const concrete = ANCHOR.test(t) || hasImage
188 if (words < 3 && !concrete && !ctx.recent) {
189 const tip = 'Say what the question is about ("where is X saved?") so the answer doesn’t have to guess.'
190 return { rank: 'C', xp: 0, reasons: ['question', 'very short'], tip, tips: [tip], missing: [], upgrade: null, mode: 'question' }
191 }
192 const tip = concrete ? 'Clear, grounded question.' : 'Good question. Naming the feature or file you mean (if you know it) makes the answer sharper.'
193 return {
194 rank: concrete ? 'A' : 'B', xp: concrete ? 20 : 10,
195 reasons: concrete ? ['question', 'grounded'] : ['question'],
196 tip, tips: concrete ? [] : [tip], missing: [], upgrade: null, mode: 'question',
197 }
198 }
199
200 const lower = t.toLowerCase()
201 const anchored = ANCHOR.test(t) || hasImage
202 // A follow-up leans on the task in progress, which is as good as naming it, just shorter.
203 // In an active session, a short instruction leans on the shared context even without "it"/"same".
204 const followUp = !anchored && ctx.recent && words <= 40 &&
205 (FOLLOW.test(t) || ctx.files.some(f => f.length > 2 && lower.includes(f.toLowerCase())) || words >= 4)
206 const structured = /^\s*([-*#]|\d+\.)\s/m.test(t)
207 const flags = {
208 concrete: anchored,
209 follow: followUp,
210 purpose: PURPOSE.test(t),
211 done: DONE.test(t),
212 verify: VERIFY.test(t),
213 scope: SCOPE.test(t),
214 plan: PLAN.test(t),
215 example: EXAMPLE.test(t),
216 length: words >= 12 && words <= 600,
217 vague: !followUp && words < 8 && VAGUE.test(t),
218 terse: !followUp && words < 4 && !(words < 8 && VAGUE.test(t)),
219 wall: words > 800 && !t.includes('```') && !structured,
220 }
221 const parts: GradePart[] = [
222 { label: 'Concrete: a file, function, command, error or screenshot', pts: 2, hit: flags.concrete },
223 { label: 'Follow-up: builds on the task in progress', pts: 1, hit: flags.follow },
224 { label: 'Why: because…, so that…, the goal is…', pts: 1, hit: flags.purpose },
225 { label: 'Done means: should…, must…, until…, returns…', pts: 1, hit: flags.done },
226 { label: 'Verifiable: run the tests, check that…, screenshot, curl', pts: 1, hit: flags.verify },
227 { label: 'Scoped: only…, don’t…, without…, keep…', pts: 1, hit: flags.scope },
228 { label: 'Plan first: propose a plan, options, before editing…', pts: 1, hit: flags.plan },
229 { label: 'Example: e.g., for example, a code block', pts: 1, hit: flags.example },
230 { label: 'Length: 12–600 words', pts: 1, hit: flags.length },
231 { label: 'Vague and tiny ("fix it", "doesn’t work")', pts: -2, hit: flags.vague, penalty: true },
232 { label: 'Too terse (under 4 words)', pts: -1, hit: flags.terse, penalty: true },
233 { label: 'Wall of text (800+ words, no structure or code block)', pts: -1, hit: flags.wall, penalty: true },
234 ]
235 // Follow-ups and anchored prompts can't both score: show only the one that applies.
236 const shown = parts.filter(p => !(p.label.startsWith('Follow-up') && !ctx.recent))
237 let score = 0
238 for (const p of shown) if (p.hit) score += p.pts
239
240 const reasons: string[] = []
241 const tips: string[] = []
242 const add: string[] = []
243 const missing: string[] = []
244 if (flags.vague) { reasons.push('vague'); tips.push(TIP.vague) }
245 if (flags.terse) { reasons.push('too terse'); tips.push(TIP.terse) }
246 if (ANCHOR.test(t)) reasons.push('anchored to code/files')
247 else if (hasImage) reasons.push('screenshot attached')
248 else if (followUp) reasons.push('follow-up')
249 else { tips.push(TIP.anchor); add.push('in <file/function>'); missing.push('file') }
250 if (flags.purpose) reasons.push('purpose stated')
251 else if (!followUp) { tips.push(TIP.purpose); add.push('because <why>'); missing.push('why') }
252 if (flags.done || flags.verify) {
253 if (flags.done) reasons.push('success criteria')
254 if (flags.verify) reasons.push('verifiable')
255 } else { tips.push(TIP.criteria); add.push('done when <check>'); missing.push('done-check') }
256 if (flags.scope) reasons.push('scoped')
257 if (flags.plan) reasons.push('plan first')
258 if (flags.example) reasons.push('example')
259 if (flags.length) reasons.push('good length')
260 if (flags.wall) { reasons.push('wall of text'); tips.push(TIP.wall) }
261 // Bonus habits get a tip only when the prompt is substantial enough to want them.
262 if (!flags.verify && (flags.done || words >= 15)) tips.push(TIP.verify)
263 if (!flags.scope && words >= 15) tips.push(TIP.scope)
264 if (!flags.plan && words >= 40) tips.push(TIP.plan)
265
266 let rank = rankFor(score)
267 // Anti-stuffing: "fix `a.ts` because should" hits every signal but says nothing.
268 if (words < 8 && (rank === 'S' || rank === 'A')) {
269 rank = 'B'
270 reasons.push('short: capped at B')
271 shown.push({ label: 'Under 8 words: capped at rank B (no keyword stuffing)', pts: 0, hit: true, penalty: true })
272 }
273 const short = words > 14 ? `${t.split(/\s+/).slice(0, 12).join(' ')}…` : t.replace(/[.!?]+$/, '')
274 const upgrade = add.length && words <= 400 ? `${short} ${add.join(', ')}.` : null
275 const tip = tips[0] ?? 'Textbook prompt. Keep it up!'
276 return { rank, xp: RANK_XP[rank], reasons, tip, tips, missing, upgrade, mode: 'task', score, parts: shown }
277}
278
279/** Token-spend advice from how a turn actually went. */
280export function turnTips(t: { out: number; ratio: number; total: number; tools: number; aborted: boolean; modelSwitch?: boolean }): string[] {
281 const tips: string[] = []
282 if (t.aborted) tips.push('Interrupted turns still bill the tokens spent so far. A clearer first prompt avoids restarts.')
283 if (t.out > 8000) tips.push('Big reply. Ask for "just the diff", "summary only" or "max 5 bullets" when that is enough. Output costs ~5× input.')
284 if (t.total > 2000 && t.ratio < 0.5) tips.push('Mostly cold cache this turn. Replying within a few minutes reuses it at ~10% of the price; /clear between unrelated tasks keeps context small.')
285 if (t.tools > 15) tips.push(`${t.tools} tool calls. Naming the files or commands up front saves Claude from searching.`)
286 if (t.total > 100000) tips.push(`Every message re-sends the whole history (${Math.round(t.total / 1000)}k tokens now). /clear before starting a new task.`)
287 if (t.modelSwitch) tips.push('Switching model mid-session rebuilds the prompt cache at full price. Pick your model at the start and stick with it.')
288 return tips
289}
290
291// ── Daily quests ──────────────────────────────────────────────────────────
292
293export type Quest = { id: string; name: string; goal: number; desc: string }
294
295export const QUESTS: Quest[] = [
296 { id: 'sharp', name: 'Sharpshooter', goal: 3, desc: 'Send 3 prompts ranked A or S' },
297 { id: 'cache', name: 'Cache Keeper', goal: 3, desc: '3 turns with ≥80% cache hits' },
298 { id: 'lore', name: 'Scholar', goal: 2, desc: 'Read 2 new lore cards' },
299 { id: 'lean', name: 'Lean Blade', goal: 3, desc: '3 turns under 600 output tokens' },
300 { id: 'quizright', name: 'Quiz Champion', goal: 2, desc: 'Answer 2 quiz questions right' },
301 { id: 'turn', name: 'Journeyman', goal: 5, desc: 'Complete 5 turns' },
302 { id: 'purpose', name: 'Why Seeker', goal: 3, desc: 'State your purpose in 3 prompts' },
303 { id: 'clear', name: 'Clean Slate', goal: 1, desc: '/clear after a long conversation (50k+ tokens)' },
304]
305
306export const QUEST_XP = 50
307
308export function questsFor(day: string): Quest[] {
309 let h = 0
310 for (const c of day) h = (h * 31 + c.charCodeAt(0)) >>> 0
311 const pool = [...QUESTS]
312 const picked: Quest[] = []
313 while (picked.length < 3 && pool.length) {
314 h = (h * 1103515245 + 12345) >>> 0
315 picked.push(pool.splice(h % pool.length, 1)[0]!)
316 }
317 return picked
318}
319
320export const PROMPT_TIPS = [
321 'Name the file or function. "fix auth" → "fix the token refresh in src/auth.ts".',
322 'Say what done looks like: "tests in api.test.ts should pass".',
323 'State the why: purpose lets Claude choose the right trade-offs.',
324 'Long sessions re-read their context each turn: /clear between unrelated tasks.',
325 'Cache reads cost ~10% of fresh input. Steady back-and-forth keeps the cache warm.',
326 'Output tokens cost ~5× input. Ask for "just the diff" or "short answer" when that is enough.',
327 'Batch related asks into one prompt instead of five tiny ones.',
328 'Paste the exact error text. It beats describing it.',
329]
330
331// ── Lore ─────────────────────────────────────────────────────────────────
332
333const c = (deck: Card['deck'], id: string, title: string, body: string, q: string, options: string[], answer: number): Card =>
334 ({ id, deck, title, body, q, options, answer })
335
336export const LORE: Card[] = [
337 c('foundations', 'tokens', 'Tokens',
338 'Models read and write text as tokens: chunks that average about 3–4 characters of English. Prices, rate limits and context limits are all counted in tokens, not words.',
339 'Roughly how many English characters is one token on average?', ['About 1', 'About 4', 'About 20'], 1),
340 c('foundations', 'context', 'The Context Window',
341 'The context window is everything the model sees at once: system prompt, tool definitions, the conversation so far, tool results and the reply being written. When it fills up, older material has to be summarized (compacted) or dropped.',
342 'What fills the context window in Claude Code?', ['Only your latest prompt', 'System prompt, history, tool results and the reply', 'Only the model’s answer'], 1),
343 c('foundations', 'inout', 'Input vs Output',
344 'For Claude models an output token costs about 5× an input token. Long answers cost more than long questions, and generating output is also the slow part.',
345 'Which is usually more expensive per token?', ['Input', 'Output', 'They cost the same'], 1),
346 c('foundations', 'caching', 'Prompt Caching',
347 'When a request starts with the same prefix as a recent one (system prompt, earlier turns), the API can serve that prefix from cache. A cache read costs about 10% of normal input. Entries expire after a TTL (5 minutes by default, refreshed on each hit; 1 hour is available).',
348 'A cache read costs roughly what share of a normal input token?', ['~10%', '~50%', '~200%'], 0),
349 c('foundations', 'sampling', 'Sampling & Temperature',
350 'At each step the model outputs a probability for every possible next token, and sampling picks one. Temperature reshapes that distribution: lower is sharper and more deterministic, higher is flatter and more varied.',
351 'Lowering the temperature makes output…', ['More random', 'More deterministic', 'Longer'], 1),
352 c('foundations', 'attention', 'Transformers & Attention',
353 'Modern LLMs are transformers. In self-attention each token computes how relevant every earlier token is and blends their information. Stacking many attention layers builds up rich representations.',
354 'Which mechanism lets each token weigh every other token?', ['Convolution', 'Self-attention', 'Dropout'], 1),
355 c('foundations', 'embeddings', 'Embeddings',
356 'An embedding maps text to a vector of numbers so that similar meanings land close together. Embeddings power semantic search, clustering and retrieval-augmented generation.',
357 'Two sentences with similar meaning have embeddings that are…', ['Close together', 'Orthogonal by design', 'Identical'], 0),
358 c('foundations', 'hallucination', 'Hallucination',
359 'A hallucination is a fluent, confident and false statement. It comes from predicting plausible text. To reduce it, ground the model in sources, let it say “I don’t know”, and verify with tools and tests.',
360 'Which best reduces hallucinations in coding?', ['Asking it to be confident', 'Grounding in files and running tests', 'Raising temperature'], 1),
361 c('foundations', 'tooluse', 'Tool Use',
362 'With tool use the model emits a structured request such as “run Bash with this command”. The harness (here, Claude Code) executes it and sends back the result. The model itself never runs anything.',
363 'Who actually executes a tool call?', ['The model’s weights', 'The harness/client', 'Nobody, it is simulated'], 1),
364 c('foundations', 'training', 'Pretraining & Post-training',
365 'Pretraining teaches next-token prediction over a huge text corpus. Post-training (supervised fine-tuning and reinforcement learning from human or AI feedback) then shapes that raw predictor into a helpful, honest assistant.',
366 'Pretraining mainly teaches the model to…', ['Follow a constitution', 'Predict the next token', 'Use tools'], 1),
367 c('foundations', 'fewshot', 'Few-shot Prompting',
368 'Showing a few input→output examples in the prompt is one of the strongest ways to steer format and style. The model infers the pattern in context, with no retraining.',
369 'Few-shot prompting changes the model’s weights.', ['True', 'False', 'Only for small models'], 1),
370 c('foundations', 'thinking', 'Extended Thinking',
371 'Reasoning models can “think” in tokens before answering. Thinking tokens are billed as output, so higher effort usually means better answers on hard problems in exchange for more tokens and time.',
372 'Thinking tokens are billed as…', ['Free', 'Input tokens', 'Output tokens'], 2),
373 c('internals', 'request', 'What Every Request Carries',
374 'Claude Code doesn’t send just your message. Every request carries the system prompt, your CLAUDE.md, a git status snapshot, every tool definition and the whole conversation so far. When Claude uses a tool, it runs on your machine and the result goes back for another round.',
375 'When Claude reads one of your files, where does the read happen?', ['On Anthropic’s servers', 'On your machine', 'In the browser'], 1),
376 c('internals', 'cachebreak', 'Cache Breakers',
377 'The prompt cache only matches an identical prefix, for the same model. Switching model mid-session, changing settings that alter the system prompt or tools (adding an MCP server, say), changing thinking settings, or going idle past the cache lifetime all make the next request pay full price again.',
378 'Which of these breaks the prompt cache?', ['Replying again within a minute', 'Switching model mid-session', 'Asking a short question'], 1),
379 c('internals', 'planexec', 'Plan Strong, Build Lean',
380 'Planning needs the best reasoning; typing out code mostly doesn’t. Some setups plan with the strongest model and execute with a faster, cheaper one. In Claude Code the `opusplan` model setting does exactly this: Opus in Plan mode, Sonnet otherwise.',
381 'What does the `opusplan` setting use for executing code?', ['Opus', 'Sonnet', 'Haiku'], 1),
382 c('internals', 'adaptive', 'Adaptive Thinking & Effort',
383 'With adaptive thinking the model decides how much to think from how hard the request is: a quick lookup gets little, an architecture question gets a lot. The effort level steers how eagerly it thinks. Thinking tokens bill as output, so lower effort on simple work saves tokens.',
384 'Higher effort mostly means…', ['More thinking tokens on hard problems', 'A bigger context window', 'Cheaper replies'], 0),
385 c('internals', 'clear', 'Why /clear Saves Money',
386 'Every message re-sends the whole conversation. After a few dozen turns that can be 100k+ tokens riding along with each new message, even a one-liner. /clear starts fresh, so the next message carries only the system prompt and your words. Rule of thumb: /clear before a new task.',
387 'After 30 turns, what does your next message carry?', ['Only the new message', 'The whole conversation so far', 'The last 3 messages'], 1),
388 c('internals', 'compact', 'Auto-compact',
389 'When the conversation nears the context limit, Claude Code summarizes the older parts to free space and keeps the recent ones. It’s automatic and normal, but a summary loses detail: put decisions that must survive in CLAUDE.md, and /clear between unrelated tasks so it’s rarely needed.',
390 'Auto-compact makes room by…', ['Deleting your files', 'Summarizing older conversation', 'Switching to a bigger model'], 1),
391 c('internals', 'claudemd', 'CLAUDE.md Is Always On',
392 'Your CLAUDE.md is loaded into every request of the session. That makes it the place for lasting project rules, and also a cost: every line rides along with every message (cached, but still counted). Keep it short and specific.',
393 'Why keep CLAUDE.md short?', ['It is sent with every request', 'Claude ignores long files', 'It slows down git'], 0),
394 c('archives', 'kvcache', 'The KV Cache',
395 'During generation the keys and values of earlier tokens are stored so that each new token reuses them instead of recomputing. This is why long contexts eat GPU memory, and prompt caching persists exactly this work across requests.',
396 'What does the KV cache avoid?', ['Recomputing attention keys/values for earlier tokens', 'Tokenizing text', 'Downloading weights'], 0),
397 c('archives', 'rlhf', 'RLHF',
398 'In reinforcement learning from human feedback, humans compare pairs of outputs, a reward model learns those preferences, and the policy is then optimized with RL (e.g. PPO) to score well under it.',
399 'In RLHF, the reward model is trained on…', ['Raw web text', 'Human preference comparisons', 'Unit tests'], 1),
400 c('archives', 'cai', 'Constitutional AI',
401 'Anthropic’s Constitutional AI has the model critique and revise its own outputs against a written set of principles. AI-generated preference labels (RLAIF) then train harmlessness with far fewer human labels.',
402 'Constitutional AI relies heavily on…', ['AI feedback guided by written principles', 'Bigger GPUs', 'Removing all refusals'], 0),
403 c('archives', 'moe', 'Mixture of Experts',
404 'A Mixture-of-Experts layer holds many expert sub-networks plus a router that sends each token to only a few of them. Total parameters grow while compute per token stays modest.',
405 'What does the router in MoE do?', ['Picks a few experts per token', 'Routes network packets', 'Chooses the temperature'], 0),
406 c('archives', 'specdec', 'Speculative Decoding',
407 'A small draft model guesses several tokens ahead, and the large model checks them all in one parallel pass, keeping the ones it agrees with. Output quality is unchanged and generation gets faster.',
408 'Speculative decoding mainly improves…', ['Accuracy', 'Speed', 'Context length'], 1),
409 c('archives', 'rag', 'Retrieval-Augmented Generation',
410 'RAG retrieves relevant documents (often by embedding similarity) and places them in the context, so the model answers from fresh, citable facts. It is usually a better way than fine-tuning to add knowledge.',
411 'For adding frequently changing facts, prefer…', ['Fine-tuning weekly', 'RAG', 'Higher temperature'], 1),
412 c('archives', 'scaling', 'Scaling Laws',
413 'Loss falls predictably, as a power law, with more parameters, data and compute. The Chinchilla result found that compute-optimal training uses roughly 20 tokens of data per parameter.',
414 'Chinchilla’s compute-optimal ratio is about…', ['1 token per parameter', '20 tokens per parameter', '1000 tokens per parameter'], 1),
415 c('archives', 'interp', 'Interpretability',
416 'Neurons often mix many concepts at once (superposition). Dictionary learning with sparse autoencoders pulls out cleaner “features”, such as one for the Golden Gate Bridge, that can be inspected and even steered.',
417 'Sparse autoencoders are used in interpretability to…', ['Compress weights', 'Extract interpretable features', 'Speed up training'], 1),
418 c('archives', 'mcp', 'Model Context Protocol',
419 'MCP is an open protocol for connecting AI apps to external tools and data. Servers expose tools, resources and prompts, and any MCP client, Claude Code included, can use them.',
420 'An MCP server exposes…', ['Model weights', 'Tools, resources and prompts', 'GPU time'], 1),
421 c('archives', 'agents', 'Agents & Context Engineering',
422 'Agents loop: think, call tools, read results, repeat. Good context engineering keeps that loop lean. Subagents explore in their own context and return only a summary, and compaction summarizes old history.',
423 'Why delegate a broad search to a subagent?', ['It is always cheaper per token', 'Its file dumps stay out of the main context', 'Subagents never make mistakes'], 1),
424 c('archives', 'quant', 'Quantization',
425 'Quantization stores weights at lower precision (8-bit, 4-bit) instead of 16-bit, which cuts memory and speeds up inference at a small cost in quality. It is how big models fit on consumer GPUs.',
426 '4-bit quantization mainly reduces…', ['Memory use', 'Vocabulary size', 'Context window'], 0),
427 c('archives', 'evals', 'Evals',
428 'Evals measure model behavior on fixed test sets: benchmarks, unit-test pass rates, or an LLM acting as judge. Watch out for contamination, where test data leaked into training.',
429 'Benchmark contamination means…', ['Test items leaked into training data', 'The GPU overheated', 'A judge model was used'], 0),
430]
431
432// ── Concrete tips from what Claude actually did this turn ─────────────────
433
434export type Trace = {
435 /** Claude entered or left Plan mode this turn. */
436 planned?: boolean
437 /** Files Claude edited or wrote, in order. */
438 edits: string[]
439 /** How often each file was read. */
440 reads: Record<string, number>
441 /** Searches: grep/rg/find/ls through Bash, Grep, Glob. */
442 searches: number
443 /** The last test/check command Claude ran, if any. */
444 check: string | null
445}
446
447export const emptyTrace = (): Trace => ({ edits: [], reads: {}, searches: 0, check: null })
448
449const SEARCH_CMD = /(^|[|;&]\s*|\s)(grep|rg|find|fd|ls|tree)\s/
450const CHECK_CMD = /^(npm|pnpm|yarn|bun|npx) (run )?(test|lint|check|typecheck)\b|^(pytest|jest|vitest|tsc|eslint|ruff|mypy|make test|cargo (test|check|clippy)|go (test|vet)|mvn test|gradle test|claude plugin (test|validate))\b/
451
452/** The part of a shell command worth quoting back: drop `cd …&&`, pipes and redirects. */
453export function checkOf(command: string): string | null {
454 // Only the command line itself: never heredoc bodies or later lines.
455 const line = command.split('\n')[0]!.split('<<')[0]!
456 const parts = line.split(/&&|;/).map(p => p.split('|')[0]!.replace(/\s*\d?>.*$/, '').trim()).filter(Boolean)
457 const hit = parts.filter(p => CHECK_CMD.test(p)).pop()
458 if (!hit) return null
459 return hit.length > 48 ? `${hit.slice(0, 47)}…` : hit
460}
461
462export const isSearch = (command: string) => SEARCH_CMD.test(` ${command}`)
463
464export function relPath(path: string, cwd: string, home: string) {
465 if (cwd && path.startsWith(`${cwd}/`)) return path.slice(cwd.length + 1)
466 if (home && path.startsWith(`${home}/`)) return `~/${path.slice(home.length + 1)}`
467 return path
468}
469
470/** Swaps generic advice for advice naming the real file and the real check. */
471export function concretize(g: Grade, trace: Trace, rel: (p: string) => string): Grade {
472 const reads = Object.entries(trace.reads).sort((a, b) => b[1] - a[1])
473 const editCount: Record<string, number> = {}
474 for (const f of trace.edits) editCount[f] = (editCount[f] ?? 0) + 1
475 const mostEdited = Object.entries(editCount).sort((a, b) => b[1] - a[1])[0]?.[0]
476 const target = mostEdited ?? reads[0]?.[0]
477 const file = target ? rel(target) : null
478 const lookups = Object.values(trace.reads).reduce((a, b) => a + b, 0) + trace.searches
479 const tips = g.tips.map(t => {
480 if (t === TIP.anchor && file) {
481 const cost = lookups > 1 ? ` Claude made ${lookups} reads/searches to find it.` : ''
482 return `Next time point to \`${file}\` directly.${cost}`
483 }
484 if (t === TIP.criteria && trace.check) {
485 return `Say what done looks like, e.g. "done when \`${trace.check}\` passes". That's the check Claude ended up running.`
486 }
487 return t
488 })
489 if (g.reasons.includes('anchored to code/files') && trace.searches >= 6) {
490 tips.push(`You named a file, but Claude still ran ${trace.searches} searches. Naming the function or the line too narrows it further.`)
491 }
492 if (trace.edits.length && new Set(trace.edits).size > 3 && !g.reasons.includes('success criteria')) {
493 tips.push(`This touched ${new Set(trace.edits).size} files. Saying the scope up front ("only touch ${file ?? 'X'}") keeps changes small.`)
494 }
495 let upgrade = g.upgrade
496 if (upgrade && file) upgrade = upgrade.replace('<file/function>', `\`${file}\``)
497 if (upgrade && trace.check) upgrade = upgrade.replace('<check>', `\`${trace.check}\` passes`)
498 return { ...g, tips, upgrade, tip: tips[0] ?? g.tip }
499}
500
501const PATH_RE = /(?:~\/|\.{0,2}\/)?[\w.-]+(?:\/[\w.-]+)*\.(?:tsx?|jsx?|mjs|cjs|py|rs|go|md|json|lua|sh|toml|ya?ml|css|html|c|cpp|h|java|rb|kt|swift)\b/g
502const WRITE_RE = /sed -i|>>?\s*['"]?[\w~./]|\btee\b|\bpython3?\b|\bnode\b|\bmv\b|\bcp\b|\brsync\b|\bpatch\b/
503
504/** Files a shell command names, and whether it looks like it changes them. */
505export function bashFiles(command: string): { paths: string[]; writes: boolean } {
506 const paths = [...new Set(command.match(PATH_RE) ?? [])]
507 .filter(p => !p.includes('/types/') && !p.endsWith('.d.ts') && !p.includes('node_modules'))
508 return { paths, writes: WRITE_RE.test(command) }
509}
510
511/** A short stable hash of a prompt's words, so repeats can be spotted without keeping the text. */
512export function promptHash(text: string) {
513 const norm = text.toLowerCase().replace(/\[image #\d+\]/g, '').replace(/[^\p{L}\p{N}`./_-]+/gu, ' ').trim()
514 let h = 0x811c9dc5
515 for (let i = 0; i < norm.length; i++) h = Math.imul(h ^ norm.charCodeAt(i), 0x01000193) >>> 0
516 return h.toString(36)
517}
518types/index.d.ts 103 lines1export type Rank = 'S' | 'A' | 'B' | 'C' | 'D' | 'F'
2
3export type Card = {
4 id: string
5 deck: 'foundations' | 'internals' | 'archives' | 'oracle'
6 title: string
7 body: string
8 q: string
9 options: string[]
10 answer: number
11}
12
13export type GradePart = { label: string; pts: number; hit: boolean; penalty?: boolean }
14
15export type Grade = {
16 rank: Rank
17 xp: number
18 reasons: string[]
19 tip: string
20 /** Concrete advice for each thing the prompt was missing. */
21 tips: string[]
22 /** How it was judged: a task, a question, or a reply to Claude. */
23 mode?: 'task' | 'question' | 'reply'
24 /** Raw points before mapping to a rank (tasks only). */
25 score?: number
26 /** Every rule and what it gave, for the Rules view. */
27 parts?: GradePart[]
28 /** What the prompt lacked: 'file', 'why', 'done-check'. */
29 missing?: string[]
30 /** The prompt rewritten with placeholders for what was missing. */
31 upgrade: string | null
32}
33
34export type Save = {
35 xp: number
36 unlocked: string[]
37 perksOff: string[]
38 stats: {
39 prompts: number
40 turns: number
41 inTok: number
42 outTok: number
43 cacheRead: number
44 cacheWrite: number
45 ranks: Record<string, number>
46 }
47 day: string
48 quests: Record<string, number>
49 questsDone: string[]
50 streak: number
51 lastDay: string
52 loreSeen: string[]
53 quizRight: number
54 quizTotal: number
55 custom: Card[]
56 budget: number
57 showBand: boolean
58 /** Hashes of recent prompts (never the text), so repeats earn nothing. */
59 recent?: string[]
60 /** Day feedback last earned XP (once a day). */
61 feedbackDay?: string
62 log: string[]
63}
64
65export type Session = {
66 outTok: number
67 alerts: number[]
68 lastTurnAt: number
69 last: {
70 /** The prompt as typed; session-only, never written to the store. */
71 prompt?: string
72 grade: Grade | null
73 xp: number
74 notes: string[]
75 /** Prompt tips first, then token-spend tips from how the turn went. */
76 tips: string[]
77 inTok: number
78 outTok: number
79 cacheRead: number
80 cacheWrite: number
81 } | null
82 mood: 'idle' | 'up' | 'down' | 'hit'
83 moodAt: number
84 /** XP lost in the last hit, for the damage number. */
85 dmg?: number
86}
87
88export type Tab = 'hero' | 'skills' | 'quests' | 'lore' | 'rules'
89
90export type Quiz = { cardId: string; picked: number | null }
91
92declare module 'claude-code' {
93 interface PluginState {
94 'prompt-quest': {
95 save: Save
96 session: Session
97 tab: Tab
98 card: number
99 quiz: Quiz | null
100 }
101 }
102}
103