SLOPSHOPPER

GAME MODE · Answer Memory

When Claude asks a question it asked before in this project, the option you chose last time is marked ★ 지난번 선택

newrowsguard
A shopper browsing a rack in a slop shop
README

GAME MODE · Answer Memory

When Claude asks a question it asked before in this project, the option you chose last time is marked in the dialog:

 ☐ 형식
문서 형식을 어떻게 할까요?
❯ 1. 마크다운 표
     표로 정리
  2. 목록
     ★ 지난번 선택 · 글머리표로 정리

Only the dialog's drawing changes: the options, their order and the answer stay as asked. Questions match through spacing, case and a trailing question mark. Answers are kept per project (the last 200 questions); a multi-select answer marks each choice.

Part of the GAME MODE pack. Requires Claude Code 2.1.287+; built and tested on 2.1.291.

Install

claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-answer-memory@bitgame-mods

Settings

intensity (casual)off · casual · hardcore (says when: ★ 지난번 선택 (3시간 전))

Known limits

  • The mark sits in the option's description; the dialog is the engine's own and cannot be redrawn.
  • The test kit cannot stand in for the dialog, so the marking is tested as a function; the dialog was checked in a live session.

한국어

Claude가 같은 질문을 다시 하면, 지난번에 고른 선택지에 ★ 지난번 선택을 표시합니다. 프로젝트별로 최근 200개 질문을 기억합니다.

Source 2 files
hooks/register.tsx 107 lines
1import type { Register } from 'claude-code'
2
3import { intensityOf } from './palette'
4
5// GAME MODE · ANSWER MEMORY
6//
7// When Claude asks a question it asked before in this project, the option you chose
8// last time is marked in the dialog:
9//   어떤 방식으로 고칠까요?
10//   ❯ 테스트 기대값을 바꾼다   ★ 지난번 선택 · 기대값을 초 단위로 맞춘다
11//     구현을 바꾼다            …
12// Only the dialog's drawing changes: the options, their order and the answer stay as asked.
13// Answers are kept per project (the last 200 questions).
14
15const KEEP = 200
16
17type Kept = Record<string, { answer: string; at: number }>
18
19export const register: Register = (on, options) => {
20  const intensity = intensityOf(options.intensity)
21  let key = 'answers'
22  let kept: Kept = {}
23
24  on('session.start', async ($, e, next) => {
25    key = `answers:${await $.session.root()}`
26    kept = keptOf(await $.store.get(key))
27    return next(e)
28  })
29
30  // The answers a dialog brought back, by question.
31  on('tool.call', async ($, e, next) => {
32    const ran = await next(e)
33    if (intensity === 'off' || e.tool !== 'AskUserQuestion' || ran.deny !== undefined || ran.isError === true) return ran
34    const answers = answersOf(ran.result)
35    if (answers.length === 0) return ran
36    const at = await $.clock.now()
37    for (const [question, answer] of answers) kept[keyOf(question)] = { answer, at }
38    const newest = Object.entries(kept)
39      .sort((a, b) => b[1].at - a[1].at)
40      .slice(0, KEEP)
41    kept = Object.fromEntries(newest)
42    await $.store.set(key, kept)
43    return ran
44  }).catch(($, e, next) => next(e))
45
46  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
47    if (intensity === 'off') return next(e)
48    const now = await $.clock.now()
49    let isMarked = false
50    const questions = e.props.questions.map(q => {
51      const marked = markLast(q, kept, intensity === 'hardcore' ? now : null)
52      if (marked !== q) isMarked = true
53      return marked
54    })
55    return isMarked ? next({ ...e, props: { ...e.props, questions } }) : next(e)
56  })
57}
58
59/** The question as it is matched: case, spacing and a trailing question mark ignored. */
60export function keyOf(question: string): string {
61  return question.trim().toLowerCase().replace(/\s+/g, ' ').replace(/[??]+$/, '')
62}
63
64/** The question with the option chosen last time marked in its description; the question itself when none was. */
65export function markLast(question: unknown, kept: Kept, now: number | null): unknown {
66  if (question === null || typeof question !== 'object') return question
67  const q = question as { question?: unknown; options?: unknown }
68  if (typeof q.question !== 'string' || !Array.isArray(q.options)) return question
69  const last = kept[keyOf(q.question)]
70  if (last === undefined) return question
71  const chosen = new Set(last.answer.split(',').map(a => a.trim()))
72  let isMarked = false
73  const optionsOut = q.options.map(o => {
74    if (o === null || typeof o !== 'object') return o
75    const opt = o as { label?: unknown; description?: unknown }
76    if (typeof opt.label !== 'string' || !chosen.has(opt.label)) return o
77    isMarked = true
78    const when = now === null ? '' : ` (${ago(now - last.at)})`
79    const description = typeof opt.description === 'string' && opt.description !== '' ? ` · ${opt.description}` : ''
80    return { ...opt, description: `★ 지난번 선택${when}${description}` }
81  })
82  return isMarked ? { ...q, options: optionsOut } : question
83}
84
85function answersOf(result: unknown): [string, string][] {
86  const answers = result !== null && typeof result === 'object' ? (result as { answers?: unknown }).answers : undefined
87  if (answers === null || typeof answers !== 'object') return []
88  return Object.entries(answers as Record<string, unknown>).filter((x): x is [string, string] => typeof x[1] === 'string' && x[1] !== '')
89}
90
91function keptOf(value: unknown): Kept {
92  if (value === null || typeof value !== 'object') return {}
93  const out: Kept = {}
94  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
95    const o = v as { answer?: unknown; at?: unknown } | null
96    if (o !== null && typeof o === 'object' && typeof o.answer === 'string' && typeof o.at === 'number') out[k] = { answer: o.answer, at: o.at }
97  }
98  return out
99}
100
101function ago(ms: number): string {
102  const minutes = Math.floor(ms / 60_000)
103  if (minutes < 60) return `${Math.max(1, minutes)}분 전`
104  const hours = Math.floor(minutes / 60)
105  return hours < 24 ? `${hours}시간 전` : `${Math.floor(hours / 24)}일 전`
106}
107
hooks/palette.ts 124 lines
1// GAME MODE shared look (copied into each GAME MODE plugin: a hooks module
2// may only import files of its own plugin). Change every copy together;
3// scripts/check.sh fails when the copies differ.
4//
5// nes / gameboy / amber are raw colors made for dark terminals, taken from
6// the design boards. `theme` uses Claude Code's theme keys, so it follows the
7// person's theme: pick it on a light background. Its `win` is empty: windows
8// draw with no fill there.
9
10export type PaletteName = 'nes' | 'gameboy' | 'amber' | 'theme'
11
12export type Palette = {
13  /** Headings, gold, the level. */
14  title: string
15  ok: string
16  warn: string
17  bad: string
18  info: string
19  dim: string
20  text: string
21  /** A window's border. */
22  frame: string
23  /** A window's fill; '' for none. */
24  win: string
25  /** The bright inner edge of a window, and a highlighted badge's text. */
26  edge: string
27  /** A bar's empty cells. */
28  off: string
29}
30
31const PALETTES: Record<PaletteName, Palette> = {
32  nes: {
33    title: '#f8d830',
34    ok: '#58d854',
35    warn: '#ff8a6a',
36    bad: '#f83800',
37    info: '#3cbcfc',
38    dim: '#b8b8f0',
39    text: '#fcfcfc',
40    frame: '#5060d8',
41    win: '#1c2a8a',
42    edge: '#fcfcfc',
43    off: '#2c3a98',
44  },
45  gameboy: {
46    title: '#e0f8d0',
47    ok: '#9bbc0f',
48    warn: '#e0f8d0',
49    bad: '#f8f8a0',
50    info: '#c4e890',
51    dim: '#8bac0f',
52    text: '#e0f8d0',
53    frame: '#9bbc0f',
54    win: '#1e4a1e',
55    edge: '#e0f8d0',
56    off: '#306230',
57  },
58  amber: {
59    title: '#ffd060',
60    ok: '#ffb000',
61    warn: '#ff7040',
62    bad: '#ff5a2a',
63    info: '#ffe0a0',
64    dim: '#c89040',
65    text: '#ffd9a0',
66    frame: '#ffb000',
67    win: '#2a1700',
68    edge: '#ffe0a0',
69    off: '#4a2a00',
70  },
71  theme: {
72    title: 'claude',
73    ok: 'success',
74    warn: 'warning',
75    bad: 'error',
76    info: 'suggestion',
77    dim: 'subtle',
78    text: 'text',
79    frame: 'promptBorder',
80    win: '',
81    edge: 'text',
82    off: 'subtle',
83  },
84}
85
86export function paletteOf(name: unknown): Palette {
87  return name === 'gameboy' || name === 'amber' || name === 'theme' ? PALETTES[name] : PALETTES.nes
88}
89
90export type Intensity = 'off' | 'casual' | 'hardcore'
91
92export function intensityOf(value: unknown): Intensity {
93  return value === 'off' || value === 'hardcore' ? value : 'casual'
94}
95
96/** A bar of `cells` blocks, `percent` of them full. */
97export function bar(percent: number, cells: number): string {
98  const full = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
99  return '█'.repeat(full) + '░'.repeat(cells - full)
100}
101
102/** Box props for a window in this palette: a double border and, where the palette has one, a fill. */
103export function windowProps(pal: Palette): { borderStyle: 'double'; borderColor: string; backgroundColor?: string; paddingX: number } {
104  return pal.win === ''
105    ? { borderStyle: 'double', borderColor: pal.frame, paddingX: 1 }
106    : { borderStyle: 'double', borderColor: pal.frame, backgroundColor: pal.win, paddingX: 1 }
107}
108
109/** `m:ss`, or `h:mm:ss` past an hour. */
110export function clock(ms: number): string {
111  const s = Math.max(0, Math.round(ms / 1000))
112  const h = Math.floor(s / 3600)
113  const m = Math.floor((s % 3600) / 60)
114  const sec = s % 60
115  const two = (n: number) => String(n).padStart(2, '0')
116  return h > 0 ? `${h}:${two(m)}:${two(sec)}` : `${m}:${two(sec)}`
117}
118
119/** A cost in the chosen currency: `$1.23`, or `₩1,722` at `krwPerUsd`. */
120export function money(usd: number, currency: 'usd' | 'krw', krwPerUsd: number): string {
121  if (currency === 'krw') return '₩' + String(Math.round(usd * krwPerUsd)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
122  return '$' + usd.toFixed(2)
123}
124