SLOPSHOPPER

prompt-quest

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-quest
│ ┃ Prompt Quest ✕ › fix the failing auth test and add an audit log ca╭───────────────╮ │ ┃ [ Hero ] [ Skills ] [ Quests ] [ Lore ] [ Ru │ prompt-quest │ │ ┃ ⏺ Read(src/auth.ts) │ 💥 Ouch! -5 │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ XP │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) ╰───────────────╯ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ prompt-quest │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ 3 pass, 1 fail │ 🔻 Rank D · -5 XP · 💡 Next time point to │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ `src/auth.ts` directly. Claude made 2 │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ● Done. refresh now re│ reads/searches to find it. │ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀ ╰────────────────────────────────────────────╯ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /quest │ ┃ ⎿ prompt-quest: Prompt Quest opened. /quest close hides it. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ Apprentice Wanderer · Level 1 │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ 0/100 XP │ ┃ Total 0 XP · 1 skill point(s) unspent · 🔥 │ ┃ 1-day streak │ ┃ │ ┃ Stats 🧭 Lv 1 Apprentice Wanderer ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ 0/100 XP +1 skill point: /quest skills D -5 XP · rank D −10, cache hit +5 🎯 Missing: which file why what done means (3 tips: /quest) ✏️ Try: fix the failing auth test and add an audit log call in `src/auth.ts`, because <why>, done when `bun test` pa… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🧭 Lv 1 Apprentice Wanderer ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ 0/100 XP +1 skill point: /quest skills D -5 XP · rank D −10, cache hit +5 🎯 Missing: which file why what done means (3 tips: /quest) ✏️ Try: fix the failing auth test and add an audit log call in `src/auth.ts`, because <why>, done wh…
Pane · Prompt Quest
[ Hero ] [ Skills ] [ Quests ] [ Lore ] [ Rules ] [ Close ] ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ Apprentice Wanderer · Level 1 ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ 0/100 XP Total 0 XP · 1 skill point(s) unspent · 🔥 1-day streak Stats Prompts 1 · Turns 1 Cache hit 93% · Avg output 1.5k tok Quiz 0/0 · Lore 0 S:0 A:0 B:0 C:0 D:1 F:0 💥 Ouch! Your hero took a hit (-5 XP). Last prompt: Rank D -5 XP ▄▄▄▄▄▄▄▄▄▄▄ rank D −10 · cache hit +5 ▀▀▀▀▀▀▀▀▀▀▀ How to improve ▀▀▀▀▀▀▀▀▀▀▀ 💡 Next time point to `src/auth.ts` directly. C ▀▀▀▀▀▀▀▀▀▀▀ reads/searches to find it. ▀▀▀▀▀▀▀▀▀▀▀ 💡 Add the why ("because users get logged out") ▀▀▀▀▀▀▀ can pick the right trade-off. ▀▀▀ 💡 Say what done looks like, e.g. "done when `b passes". That's the check Claude ended up runni ✏️ Try: fix the failing auth test and add an aud in `src/auth.ts`, because <why>, done when `bun passes. Rank feel wrong? [ Report ] [ Report with my pr Chronicle -5 XP · Rank D prompt 💡 Batch related asks into one prompt instead of five tiny ones.
README

<img src="assets/classes.gif" alt="The four hero classes: Wanderer, Scribe, Alchemist and Sage" width="720">

⚔️ Prompt Quest

An RPG layer for Claude Code. Level up by writing sharper prompts, spending tokens wisely and learning how modern AI works.

Version Claude Code License: MIT Grading cost Stars

Install · How it plays · Skill tree · Commands · 📖 Full guide


Why

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:

  • 🎯 Every prompt gets a rank, S to F. Name the file, say why, say what done means: you climb. "fix it": you fall.
  • 💡 Tips built from what actually happened. "Next time point to src/auth.ts directly. Claude made 6 reads to find it."
  • 🪙 Token sense. Cache hits, lean replies and a session budget, shown live above your prompt.
  • 📜 AI lore. 31 cards with quizzes: how Claude Code works under the hood (what each request carries, cache breakers, effort, /clear, auto-compact), plus tokens, caching, RLHF, Constitutional AI, MoE and interpretability.
  • 🎨 Animated pixel art. Your hero cheers when you level up, takes a fireball when a prompt ranks badly, and sulks in the rain after.

See it

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>

Install

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 /plugin menu instead, paste only tomikng/prompt-quest into 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

How it plays

Prompt ranks

Each prompt you type is graded on your machine with simple heuristics. No model call, no tokens.

What your prompt hasPoints
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
Points5+3–4210≤ −1
XP+30+20+100−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.

Turn bonuses

EventXP
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

Levels go both ways

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.

Concrete tips

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:

  • 🎯 Missing: chips showing which part your prompt lacked
  • ✏️ Try: your prompt rewritten with the real file and the real check filled in
  • 🪙 Token tips: heavy output, a cold cache, too many tool calls, a context getting large

Skill tree

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)
1TL;DR Rune: replies end with a one-line TL;DRCoin Purse: token ledger above the promptOracle Quiz: quizzes on lore cards
2Glossary Lens: jargon defined inlineCache Sight: warns when the cache has gone coldDeep Archives: the advanced lore deck
3Why-Trace: the reason before every changeTerse Tongue: concise replies, fewer output tokensConcept Spotter: a "📜 Lore:" note when an AI concept comes up
4Answer-First: lead with the resultBudget Ward: session budget with 50/80/100% alarmsOracle's Eye: /quest oracle <topic> conjures new cards

Your class (and your hero's look) follows the branch you've invested in most.

Fair play

Ranking runs locally, so cheating can't be made impossible, only pointless:

  • Repeated prompts earn 0 XP.
  • Short keyword-stuffed prompts are capped at B.
  • XP needs real API turns.

The grader is plain pattern matching, so there's no model to prompt-inject. See Fair play.

Commands

CommandWhat it does
/questOpen the pane (tabs Hero, Skills, Quests, Lore, Rules on keys 1–5)
/quest rulesHow ranking works, visually, with your last prompt broken down
/quest skills · quests · loreOpen a specific tab
/quest closeClose the pane (or press Close / x)
/quest bandShow or hide the status band above the prompt
/quest budget 50kSet 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 confirmStart over at level 1

Feedback makes it smarter

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>.

  • A pre-filled GitHub issue opens in your browser with the rank, the signals the grader saw and your comment. Nothing is sent until you press Submit.
  • Your prompt text is included only if you choose Report with my prompt (or --prompt).
  • Your first report each day earns +10 XP.

Ideas go through /quest idea <text>. All reports land in Issues, labeled rank-feedback, enhancement or bug.

Privacy

  • Prompt grading and tips run locally, with no network calls.
  • Progress is saved in the plugin's own store, a JSON file under ~/.claude/plugins/store/.
  • The only model call is the optional /quest oracle, which uses a few hundred Haiku tokens.
  • Feedback only opens a browser link. You review and submit the issue yourself.

FAQ

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.

Development

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.

License

MIT © tomikng

Source 4 files
hooks/register.tsx 894 lines
1import { 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 &lt;topic&gt; 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}
894
hooks/art.ts 367 lines
1import 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}
367
hooks/data.ts 518 lines
1import 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}
518
types/index.d.ts 103 lines
1export 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