SLOPSHOPPER

sidecard

Tiny disposable learning cards under Claude Code's spinner while it works. Categories are markdown files.

newpanebandspinnerguardcommand
v0.3.1MITupdated 2026-10-05phunterlau/sidecard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sidecard
│ ┃ sidecard ✕ › fix the failing auth test and add an audit log call │ ┃ sidecard · pick categories │ ┃ ⏺ Read(src/auth.ts) │ ┃ No categories found. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ generate with haiku: on ] [ show a card ⎿ Added 2 lines, removed 1 line │ ┃ Add a category: drop a .md file in ⏺ Bash(bun test) │ ┃ ~/.claude/sidecard/categories (or git clone ⎿ 3 pass, 1 fail │ ┃ a repo of them there). │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sidecard │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sidecard
sidecard · pick categories No categories found. [ generate with haiku: on ] [ show a card now ] Add a category: drop a .md file in ~/.claude/sidecard/categories (or git clone a repo of them there).
README

sidecard

English · 简体中文 · 繁體中文

A Claude Code mod that turns the wait while your agent works into tiny, disposable learning cards. It draws a boxed card under the spinner, and removes it the moment Claude needs you or finishes.

⠋ Thinking…
╭─ sidecard · french ───────────────╮
│ pourtant                          │
│ however / yet                     │
│                                   │
│ Il était fatigué, pourtant il a … │
│ ┄ answer in 4s ▰▰▱▱▱▱             │
╰───────────────────────────────────╯

Cards are either read-only or delayed-answer (a countdown, then the answer).

Requirements

  • Claude Code v2.1.287 or later (mods). Check with claude --version.
  • Cards draw in the terminal and the Desktop app's Code tab only (not the VS Code chat panel or claude -p).

Install

/plugin marketplace add phunterlau/sidecard
/plugin install sidecard@sidecard
/reload-plugins

Confirm it loaded: /plugin shows 1 mod active · sidecard. A mod is code that runs with your permissions; read hooks/register.ts first. claude plugin validate . lists every event it hooks and every call it makes.

Use

CommandEffect
/sidecard or /sidecard menuCategory menu: ✓/☐ toggles (hotkeys 1–9) and a dropdown for each category's inputs
/sidecard nowShow a card right away (in the spinner during a turn, above the prompt when idle)
/sidecard review [n]List the last n cards shown (default 10), newest first, with answers and how well you likely remember each
/sidecard on / offEnable or disable all cards
/sidecard <category>Toggle a category, e.g. /sidecard french
/sidecard <category> key=valueSet an input, e.g. /sidecard french level=B1
`/sidecard generate on\off`Fresh cards from Claude Code's Haiku, on or off
/sidecard reloadRescan category files
/sidecard statusShow current settings

Defaults: a card appears 8 seconds into a turn, stays 20 seconds (longer for delayed answers), with at least 45 seconds between cards. It disappears when the turn ends or when Claude asks for permission or input. Settings persist across sessions.

Review and the forgetting curve

Every card shown is remembered, with when, across sessions. /sidecard review lists the most recent ones, so a card you only half-read is not lost:

2. french · 2d ago · seen 2× · memory 50%
   manquer à
   to be missed by

Cards marked high value (bundled gotchas and recall prompts; Haiku rates a few of the generated ones) can come back. The estimated memory of a card is exp(-time since last shown / stability), where stability starts at one day and grows each time the card is shown again, more when the repeat comes just as it was fading and hardly at all when it comes straight away. Once a high-value card drops below 50%, a card slot has a 30% chance of being that card instead of a new one. Low-value cards never come back, and nothing needs an answer: it is time-based only.

Where cards come from

  • Generated: the mod asks Claude Code's own haiku ($.model.complete, low effort) for batches of 10 cards in the background, caches them, and avoids repeats. No separate API key is needed, but it spends your plan or API quota. Turn it off with /sidecard generate off.
  • Bundled: 58 offline cards cover french, python-advanced, ml-general and llm, used before the first batch arrives or when generation is off.

Example cards

Three of the bundled cards, as they look under the spinner: a delayed-answer card after its answer arrives, one mid-countdown, and a read-only card.

╭─ sidecard · python-advanced ────────────────────────╮
│ class A: ...                                        │
│ class B(A): ...   class C(A): ...                   │
│ class D(B, C): ...                                  │
│                                                     │
│ D.__mro__ order?                                    │
│ Think for a moment…                                 │
├┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┤
│ D, B, C, A, object                                  │
│ C3: a class precedes its bases; base order is kept. │
╰─────────────────────────────────────────────────────╯

╭─ sidecard · llm ──────────────╮
│ Why is FlashAttention faster? │
│                               │
│ Think for a moment…           │
│ ┄ answer in 3s ▰▰▰▱▱▱         │
╰───────────────────────────────╯

╭─ sidecard · llm ──────────────────────────────╮
│ Grouped-query attention                       │
│ Several query heads share one key/value head: │
│ smaller KV cache, faster decoding.            │
╰───────────────────────────────────────────────╯

The python-advanced set covers method resolution order, mutable dataclass defaults, @contextmanager cleanup, Protocol and the GIL, next to the classic gotchas (shared lists, mutable default arguments, late-binding closures). The llm set covers sampling (temperature, top-p), attention cost, FlashAttention, grouped-query attention, the KV cache, LoRA, RAG and DPO.

Categories and development

Cards come from categories, and each category is one markdown file. Bundled ones live in categories/: french, python-advanced, ml-general, llm.

A new category appears automatically when its file shows up in either folder, including one level of subfolders, so you can git clone a category pack there:

  • categories/ in this plugin
  • ~/.claude/sidecard/categories/

Files are picked up at session start, when you open the menu, and on /sidecard reload.

---
name: french                 # required: lowercase letters, digits, - or _
title: French                # menu label
color: cyan                  # cyan|green|magenta|yellow|blue|red|white
mode: delayed                # read | delayed
revealAfter: 6               # seconds until the answer (delayed only)
model: haiku
input.level: A2 | A1, A2, B1, B2, C1, C2   # <default> | <options>
---
Teach practical French for a learner at CEFR level {{level}}.
Each card is a phrase with an example, or a recall prompt with the answer in "answer".
  • input.<key>: <default> | <options> adds a dropdown in the menu. Every input needs a default.
  • {{key}} in the body is replaced by the chosen value (or the default).
  • The mod asks the model for a JSON array of {"body", "answer"?}; you only write the teaching instructions.
  • A category with no bundled fallback cards needs generation on.

Contributing

Contributions are welcome, especially new categories and better cards:

  • Add a category: copy a file in categories/, change the frontmatter and the teaching prompt, and open a pull request. Good candidates are other languages, SQL, shell, system design, algorithms, math, or your own field.
  • Add or improve offline cards: the bundled fallbacks in hooks/cards.ts are used before the first generated batch arrives and when generation is off.
  • Improve the mod: card layout is in hooks/draw.ts, the category parser in hooks/frontmatter.ts, history and the forgetting curve in hooks/memory.ts, and the hooks, commands and menu in hooks/register.ts.

Before opening a pull request, run these from a clone of the repo:

claude plugin validate .
claude plugin test .
claude --plugin-dir .      # loads the clone for one session and reloads hooks on save

Please add a test in hooks/*.test.ts for any parser or drawing change.

License

MIT, see LICENSE.

Source 5 files
hooks/register.ts 476 lines
1import type { Register } from 'claude-code'
2import { cards as staticCards, type Card } from './cards'
3import { drawCard, holdMs } from './draw'
4import { fill, parseCards, parseCategory, type Category } from './frontmatter'
5import { formatReview, parseHistory, pickDue, recent, record, type History } from './memory'
6
7const MIN_WAIT_MS = 8_000
8const GAP_MS = 45_000
9const PANE = 'sidecard-menu'
10const LOW_BUFFER = 3
11const BATCH = 10
12const SEEN_CAP = 80
13// Chance that a card the forgetting curve says is fading replaces the normal pick
14const RESURFACE_CHANCE = 0.3
15
16const SYSTEM =
17  'You write tiny flash cards for a developer who is waiting on a coding agent. ' +
18  'Reply with ONLY a JSON array, no prose, no code fences. Each element is {"body": string, "answer"?: string, "value"?: 2 | 3}. ' +
19  'body is at most 5 lines (use \\n), each line at most 52 characters. ' +
20  'answer is a short reveal (at most 3 lines) shown a few seconds later. ' +
21  'value marks the few cards most worth seeing again (about one in four): easy-to-forget gotchas, commonly confused items, high-payoff facts. Omit it otherwise. Never repeat a card.'
22
23type Settings = { enabled: boolean; generate: boolean; off: string[]; values: Record<string, Record<string, string>> }
24type Stats = { calls: number; cards: number; outputTokens: number; lastError: string }
25type Shown = { card: Card; cat: string; at: number }
26
27const HELP =
28  'Usage: /sidecard [menu|on|off|now|review [n]|status|generate on|off|reload|<category> [key=value]]\n' +
29  '  (no args)    open the category menu\n' +
30  '  now          show a card right away\n' +
31  '  review [n]   list the last n cards shown (default 10), with how well you likely remember them\n' +
32  '  <category>   toggle it; add key=value to set an input, e.g. french level=B1\n' +
33  '  generate     use Claude Code\'s Haiku to write fresh cards (uses your plan quota)\n' +
34  '  reload       rescan category files'
35
36let settings: Settings = { enabled: true, generate: true, off: [], values: {} }
37let cats: Category[] = []
38const bufs = new Map<string, Card[]>()
39const seen = new Map<string, string[]>()
40const fallback = new Map<string, Card[]>()
41const generating = new Set<string>()
42let stats: Stats = { calls: 0, cards: 0, outputTokens: 0, lastError: '' }
43// Every card ever shown and when: the review list and the forgetting curve both read it
44let history: History = {}
45
46let turnStart = 0
47let working = false
48let held = false
49let current: Shown | null = null
50let band: Shown | null = null
51let lastShownEnd = -Infinity
52let timerOn = false
53
54const isOn = (name: string) => !settings.off.includes(name)
55const valuesOf = (c: Category) => ({ ...Object.fromEntries(c.inputs.map((i) => [i.key, i.default])), ...settings.values[c.name] })
56const sigOf = (c: Category) => JSON.stringify(valuesOf(c))
57function save($: any) {
58  return $.store.set('settings', settings)
59}
60
61async function discover($: any) {
62  const dirs = [$.plugin.root + '/categories']
63  const home = await $.env.get('HOME')
64  if (home) dirs.push(home + '/.claude/sidecard/categories')
65  const found = new Map<string, Category>()
66  const scan = async (dir: string, depth: number) => {
67    let entries: { name: string; kind: string }[]
68    try {
69      entries = await $.fs.list(dir)
70    } catch {
71      return
72    }
73    for (const en of entries) {
74      if (en.kind === 'dir' && depth < 1) await scan(dir + '/' + en.name, depth + 1)
75      if (en.kind !== 'file' || !en.name.endsWith('.md')) continue
76      try {
77        const src = await $.fs.read(dir + '/' + en.name)
78        const c = typeof src === 'string' ? parseCategory(src) : null
79        if (c) found.set(c.name, c)
80      } catch {}
81    }
82  }
83  for (const d of dirs) await scan(d, 0)
84  cats = [...found.values()].sort((a, b) => a.name.localeCompare(b.name))
85}
86
87function takeStatic(name: string): Card | null {
88  let q = fallback.get(name)
89  if (!q || q.length === 0) {
90    q = staticCards.filter((c) => c.category === name)
91    for (let i = q.length - 1; i > 0; i--) {
92      const j = Math.floor(Math.random() * (i + 1))
93      ;[q[i], q[j]] = [q[j], q[i]]
94    }
95    fallback.set(name, q)
96  }
97  return q.pop() ?? null
98}
99
100function pick(): { card: Card; cat: Category } | null {
101  const pool = cats.filter((c) => isOn(c.name))
102  for (let n = pool.length; n > 0; n--) {
103    const cat = pool.splice(Math.floor(Math.random() * pool.length), 1)[0]
104    const card = bufs.get(cat.name)?.shift() ?? takeStatic(cat.name)
105    if (card) return { card, cat }
106  }
107  return null
108}
109
110// Background: never awaited by a hook on the turn path, never called while drawing
111async function refill($: any, cat: Category) {
112  if (!settings.generate || generating.has(cat.name) || (bufs.get(cat.name)?.length ?? 0) >= LOW_BUFFER) return
113  generating.add(cat.name)
114  try {
115    const sig = sigOf(cat)
116    const prompt =
117      fill(cat.prompt, cat.inputs, valuesOf(cat)) +
118      `\n\nWrite ${BATCH} cards. ` +
119      (cat.mode === 'delayed'
120        ? 'About half the cards are questions or recall prompts: give those a real "answer" (never "Correct!" or a restatement). Plain concept or phrase cards have no "answer".'
121        : 'Do not include "answer".')
122    const r = await $.model.complete({ model: cat.model, system: SYSTEM, prompt, maxTokens: 2500, effort: 'low', timeoutMs: 60_000 })
123    stats.calls += 1
124    if (!r.isAnswered) {
125      stats.lastError = cat.name + ': ' + r.reason
126      await $.store.set('stats', stats)
127      return
128    }
129    stats.outputTokens += r.usage.output_tokens
130    if (sigOf(cat) !== sig) return // the inputs changed meanwhile
131    const known = new Set(seen.get(cat.name) ?? [])
132    const fresh = parseCards(r.text, cat).filter((c) => !known.has(c.id))
133    if (fresh.length === 0) return
134    const buf = [...(bufs.get(cat.name) ?? []), ...fresh]
135    bufs.set(cat.name, buf)
136    stats.cards += fresh.length
137    stats.lastError = ''
138    await $.store.set('buf:' + cat.name, { sig, cards: buf })
139    await $.store.set('stats', stats)
140  } catch (err) {
141    // fall back to the bundled cards
142    stats.lastError = cat.name + ': ' + String(err).slice(0, 80)
143  } finally {
144    generating.delete(cat.name)
145  }
146}
147
148function refillAll($: any) {
149  if (!settings.enabled) return
150  for (const c of cats) if (isOn(c.name)) void refill($, c)
151}
152
153function remember($: any, s: Shown) {
154  const list = [...(seen.get(s.cat) ?? []), s.card.id].slice(-SEEN_CAP)
155  seen.set(s.cat, list)
156  void $.store.set('seen:' + s.cat, list)
157  history = record(history, s.card, s.at)
158  void $.store.set('history', history)
159}
160
161// Re-read so sessions running side by side converge on one history
162async function loadHistory($: any) {
163  history = parseHistory(await $.store.get('history'))
164}
165
166// A high-value card that is fading from memory, from a category that is still on
167function pickFading(now: number): Card | null {
168  return pickDue(history, now, (name) => isOn(name) && cats.some((c) => c.name === name))
169}
170
171async function showNow($: any): Promise<boolean> {
172  const p = pick()
173  if (!p) return false
174  const at = await $.clock.now()
175  const shown = { card: p.card, cat: p.cat.name, at }
176  remember($, shown)
177  held = false
178  if (working) {
179    current = shown
180    band = null
181  } else {
182    band = shown
183    current = null
184  }
185  $.ui.invalidate('ui.render')
186  return true
187}
188
189const colorOf = (name: string) => cats.find((c) => c.name === name)?.color ?? 'white'
190const labelOf = (name: string) => name
191
192
193export const register: Register = (on) => {
194  on('session.start', async ($, e, next) => {
195    const saved = await $.store.get('settings')
196    if (saved && typeof saved === 'object') {
197      const s = saved as Partial<Settings>
198      settings = {
199        enabled: s.enabled !== false,
200        generate: s.generate !== false,
201        off: Array.isArray(s.off) ? s.off : [],
202        values: s.values && typeof s.values === 'object' ? s.values : {},
203      }
204    }
205    const st = await $.store.get('stats')
206    if (st && typeof st === 'object') stats = { ...stats, ...(st as Partial<Stats>) }
207    await discover($)
208    await loadHistory($)
209    for (const c of cats) {
210      const b = (await $.store.get('buf:' + c.name)) as { sig?: string; cards?: Card[] } | undefined
211      if (b?.sig === sigOf(c) && Array.isArray(b.cards)) bufs.set(c.name, b.cards)
212      const sn = await $.store.get('seen:' + c.name)
213      if (Array.isArray(sn)) seen.set(c.name, sn as string[])
214    }
215    if (!timerOn) {
216      timerOn = true
217      // Redraw once a second, only while something that changes with time is on screen
218      $.clock.every(1000, () => {
219        if (working || band) $.ui.invalidate('ui.render')
220        // Top buffers up while Claude works, so a long turn never runs dry
221        if (working) refillAll($)
222      })
223    }
224    // Register commands last: a taken name throws
225    await $.command.register({
226      name: 'sidecard',
227      description: 'Spinner learning cards: menu, on/off, now, review',
228      argumentHint: '[menu|on|off|now|review [n]|<category>]',
229      immediate: true,
230    })
231    return next(e)
232  })
233
234  on('command.run', { command: 'sidecard' }, async ($, e) => {
235    const [head = '', ...rest] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
236    const status = () =>
237      `sidecard ${settings.enabled ? 'on' : 'off'} · generate ${settings.generate ? 'on (haiku)' : 'off'} · ` +
238      cats.map((c) => (isOn(c.name) ? '✓' : '☐') + ' ' + c.name).join('  ') +
239      '\nbuffered ' + cats.map((c) => c.name + ' ' + (bufs.get(c.name)?.length ?? 0)).join(', ') +
240      ` · generated ${stats.cards} cards in ${stats.calls} calls (${stats.outputTokens} output tokens)` +
241      (stats.lastError ? ' · last error: ' + stats.lastError : '')
242
243    if (head === '' || head === 'menu') {
244      await discover($)
245      await $.ui.open({ id: PANE, title: 'sidecard', focus: true, closeOnEscape: true })
246      return {}
247    }
248    if (head === 'help') return { text: status() + '\n' + HELP }
249    if (head === 'status') return { text: status() }
250    if (head === 'reload') {
251      await discover($)
252      return { text: 'Found ' + cats.length + ' categories: ' + cats.map((c) => c.name).join(', ') }
253    }
254    if (head === 'on' || head === 'off') {
255      settings.enabled = head === 'on'
256      current = band = null
257      await save($)
258      if (settings.enabled) refillAll($)
259      return { text: status() }
260    }
261    if (head === 'generate') {
262      settings.generate = rest[0] !== 'off'
263      await save($)
264      if (settings.generate) refillAll($)
265      return { text: status() }
266    }
267    if (head === 'review') {
268      const n = rest[0] === undefined ? 10 : Number(rest[0])
269      if (!Number.isInteger(n) || n < 1) return { text: 'Usage: /sidecard review [n]   (n = how many recent cards, default 10)' }
270      await loadHistory($)
271      const now = await $.clock.now()
272      return { text: formatReview(recent(history, n, now), now) }
273    }
274    if (head === 'now') {
275      return (await showNow($)) ? {} : { text: 'No cards available. Enable a category with /sidecard.' }
276    }
277    const cat = cats.find((c) => c.name === head)
278    if (cat) {
279      const kv = rest.find((r) => r.includes('='))
280      if (kv) {
281        const [k, v] = kv.split('=')
282        const input = cat.inputs.find((i) => i.key === k)
283        const value = input?.options.find((o) => o.toLowerCase() === v)
284        if (!input || !value) return { text: `${cat.name}: ${k} must be one of ${(input?.options ?? []).join(', ') || '(no such input)'}` }
285        settings.values[cat.name] = { ...settings.values[cat.name], [k]: value }
286        bufs.delete(cat.name)
287        settings.off = settings.off.filter((n) => n !== cat.name)
288        await save($)
289        void refill($, cat)
290      } else {
291        settings.off = isOn(cat.name) ? [...settings.off, cat.name] : settings.off.filter((n) => n !== cat.name)
292        await save($)
293      }
294      return { text: status() }
295    }
296    return { text: 'Unknown option "' + head + '".\n' + HELP }
297  })
298
299  on('turn.start', async ($, e, next) => {
300    turnStart = await $.clock.now()
301    working = true
302    held = false
303    current = band = null
304    refillAll($)
305    void loadHistory($)
306    return next(e)
307  })
308
309  on('prompt.submit', async ($, e, next) => {
310    band = null
311    return next(e)
312  })
313
314  // Claude is working again after an attention hold
315  on('tool.call', async ($, e, next) => {
316    held = false
317    return next(e)
318  })
319
320  on('turn.complete', async ($, e, next) => {
321    working = false
322    current = null
323    return next(e)
324  })
325
326  // Primary agent always wins: go dark when it needs the user
327  on('classic.PermissionRequest', async ($, e, next) => {
328    held = true
329    current = null
330    return next(e)
331  })
332  on('classic.Notification', { notification_type: ['permission_prompt', 'agent_needs_input', 'elicitation_dialog'] } as never, async ($, e, next) => {
333    held = true
334    current = null
335    return next(e)
336  })
337
338  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
339    if (!settings.enabled || !working || held) return next(e)
340    const now = await $.clock.now()
341
342    if (current && now - current.at > holdMs(current.card)) {
343      current = null
344      lastShownEnd = now
345    }
346    if (!current && now - turnStart >= MIN_WAIT_MS && now - lastShownEnd >= GAP_MS) {
347      const fading = Math.random() < RESURFACE_CHANCE ? pickFading(now) : null
348      const p = fading ? { card: fading, cat: { name: fading.category } } : pick()
349      if (p) {
350        current = { card: p.card, cat: p.cat.name, at: now }
351        remember($, current)
352      }
353    }
354    if (!current) return next(e)
355
356    const ui = $.ui.resolve(e)
357    const card = drawCard(ui, current.card, {
358      label: labelOf(current.cat),
359      color: colorOf(current.cat),
360      shownFor: (now - current.at) / 1000,
361      columns: e.viewport?.columns ?? 80,
362    })
363    const theirs = await next(e)
364    return ui.Box({ flexDirection: 'column', children: [theirs, card] })
365  })
366
367  // `/sidecard now` while no turn is running: there is no spinner, so draw in the band
368  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
369    if (!band || e.props.hasSurvey || e.props.isWorking || !settings.enabled) return next(e)
370    const now = await $.clock.now()
371    if (now - band.at > holdMs(band.card)) {
372      band = null
373      return next(e)
374    }
375    const ui = $.ui.resolve(e)
376    return drawCard(ui, band.card, {
377      label: labelOf(band.cat),
378      color: colorOf(band.cat),
379      shownFor: (now - band.at) / 1000,
380      columns: e.props.bodyColumns,
381    })
382  })
383
384  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
385    if (e.requestId !== PANE) return next(e)
386    const { Box, Text, Button, Select } = $.ui.resolve(e)
387    const redraw = () => $.ui.invalidate('ui.render')
388
389    const toggle = async (name: string) => {
390      settings.off = isOn(name) ? [...settings.off, name] : settings.off.filter((n) => n !== name)
391      redraw()
392      await save($)
393      const c = cats.find((x) => x.name === name)
394      if (c && isOn(name)) void refill($, c)
395    }
396    const setInput = async (c: Category, key: string, value: string) => {
397      settings.values[c.name] = { ...settings.values[c.name], [key]: value }
398      bufs.delete(c.name)
399      redraw()
400      await save($)
401      void refill($, c)
402    }
403
404    const blocks = cats.map((c, i) => {
405      const vals = valuesOf(c)
406      return Box({
407        key: 'cat-' + c.name,
408        flexDirection: 'column',
409        children: [
410          Box({
411            flexDirection: 'row',
412            columnGap: 2,
413            children: [
414              Button({
415                key: 'toggle-' + c.name,
416                label: (isOn(c.name) ? '✓ ' : '☐ ') + c.title,
417                hotkey: i < 9 ? String(i + 1) : undefined,
418                plain: true,
419                dimColor: !isOn(c.name),
420                onPress: () => toggle(c.name),
421              }),
422              Text({ dimColor: true, children: [c.mode === 'delayed' ? 'answer after ' + c.revealAfter + 's' : 'read only'] }),
423            ],
424          }),
425          ...c.inputs.map((inp) =>
426            Select({
427              key: `input-${c.name}-${inp.key}`,
428              label: '    ' + inp.key,
429              options: inp.options.map((o) => ({ value: o, label: o })),
430              value: vals[inp.key],
431              onSelect: (v: string) => setInput(c, inp.key, v),
432            }),
433          ),
434        ],
435      })
436    })
437
438    return Box({
439      flexDirection: 'column',
440      children: [
441        Text({ bold: true, children: ['sidecard · pick categories'] }),
442        Text({ children: [' '] }),
443        ...(blocks.length ? blocks : [Text({ dimColor: true, children: ['No categories found.'] })]),
444        Text({ children: [' '] }),
445        Box({
446          flexDirection: 'row',
447          columnGap: 3,
448          children: [
449            Button({
450              key: 'generate',
451              label: 'generate with haiku: ' + (settings.generate ? 'on' : 'off'),
452              hotkey: 'g',
453              onPress: async () => {
454                settings.generate = !settings.generate
455                redraw()
456                await save($)
457                if (settings.generate) refillAll($)
458              },
459            }),
460            Button({
461              key: 'now',
462              label: 'show a card now',
463              hotkey: 'n',
464              onPress: async () => {
465                await $.ui.close({ id: PANE })
466                await showNow($)
467              },
468            }),
469          ],
470        }),
471        Text({ dimColor: true, children: ['Add a category: drop a .md file in ~/.claude/sidecard/categories (or git clone a repo of them there).'] }),
472      ],
473    })
474  })
475}
476
hooks/cards.ts 371 lines
1// Offline fallback cards, used when generation is off or a category has no buffered cards yet.
2export type Card = { id: string; category: string; value?: number; body: string; reveal?: { afterSeconds: number; body: string } }
3export const cards: Card[] = [
4 {
5  "id": "french-pourtant",
6  "category": "french",
7  "body": "pourtant\nhowever / yet\n\nIl était fatigué, pourtant il a continué.\nHe was tired, yet he continued."
8 },
9 {
10  "id": "french-besoin",
11  "category": "french",
12  "body": "avoir besoin de\nto need\n\nJ'ai besoin de plus de temps.\nI need more time."
13 },
14 {
15  "id": "french-rendre-compte",
16  "category": "french",
17  "body": "se rendre compte (de)\nto realize\n\nJe me suis rendu compte de mon erreur.\nI realized my mistake."
18 },
19 {
20  "id": "french-ca-depend",
21  "category": "french",
22  "body": "Ça dépend.\nIt depends."
23 },
24 {
25  "id": "french-bientot",
26  "category": "french",
27  "body": "À bientôt !\nSee you soon!"
28 },
29 {
30  "id": "french-des-que",
31  "category": "french",
32  "body": "dès que + indicative\nas soon as\n\nAppelle-moi dès que tu arrives.\nCall me as soon as you arrive."
33 },
34 {
35  "id": "french-habitue",
36  "category": "french",
37  "body": "être habitué à\nto be used to\n\nJe suis habitué au bruit.\nI'm used to the noise."
38 },
39 {
40  "id": "french-recall-habitue",
41  "category": "french",
42  "value": 2,
43  "body": "How would you say:\n“I am used to it.”\n\nThink for a moment…",
44  "reveal": {
45   "afterSeconds": 6,
46   "body": "J'y suis habitué."
47  }
48 },
49 {
50  "id": "french-tenir-a",
51  "category": "french",
52  "body": "tenir à\nto care about / to insist on\n\nJ'y tiens beaucoup.\nIt matters a lot to me."
53 },
54 {
55  "id": "french-du-coup",
56  "category": "french",
57  "body": "du coup\nso / as a result (casual)\n\nDu coup, on est restés à la maison.\nSo we stayed home."
58 },
59 {
60  "id": "french-y-en",
61  "category": "french",
62  "body": "il y en a\nthere are some\n\nIl y en a trois dans le frigo.\nThere are three in the fridge."
63 },
64 {
65  "id": "french-avoir-lair",
66  "category": "french",
67  "body": "avoir l'air + adjective\nto look / seem\n\nTu as l'air fatigué.\nYou look tired."
68 },
69 {
70  "id": "french-plutot",
71  "category": "french",
72  "body": "plutôt\nrather / fairly\n\nC'est plutôt cher.\nIt's rather expensive."
73 },
74 {
75  "id": "french-manquer",
76  "category": "french",
77  "value": 2,
78  "body": "manquer à\nto be missed by\n\nTu me manques.\nI miss you. (You are missing to me.)"
79 },
80 {
81  "id": "french-recall-retard",
82  "category": "french",
83  "value": 2,
84  "body": "How would you say:\n“I'm running late.”\n\nThink for a moment…",
85  "reveal": {
86   "afterSeconds": 6,
87   "body": "Je suis en retard."
88  }
89 },
90 {
91  "id": "french-depuis",
92  "category": "french",
93  "value": 2,
94  "body": "depuis + present\nfor / since (still true)\n\nJ'habite ici depuis deux ans.\nI've lived here for two years."
95 },
96 {
97  "id": "french-quand-meme",
98  "category": "french",
99  "body": "quand même\nall the same / still\n\nC'est difficile, mais j'essaie quand même.\nIt's hard, but I'm trying anyway."
100 },
101 {
102  "id": "french-faillir",
103  "category": "french",
104  "value": 2,
105  "body": "faillir + infinitive\nto almost do\n\nJ'ai failli tomber.\nI almost fell."
106 },
107 {
108  "id": "french-en-train-de",
109  "category": "french",
110  "body": "être en train de\nto be in the middle of\n\nJe suis en train de travailler.\nI'm working right now."
111 },
112 {
113  "id": "french-ne-plus",
114  "category": "french",
115  "body": "ne … plus\nno longer\n\nIl ne fume plus.\nHe doesn't smoke anymore."
116 },
117 {
118  "id": "py-shared-list",
119  "category": "python-advanced",
120  "value": 2,
121  "body": "x = [[]] * 3\nx[0].append(1)\n\nWhat is x?\nThink for a moment…",
122  "reveal": {
123   "afterSeconds": 6,
124   "body": "[[1], [1], [1]]\nAll three elements are the same list."
125  }
126 },
127 {
128  "id": "py-default-arg",
129  "category": "python-advanced",
130  "value": 2,
131  "body": "def f(a, b=[]):\n    b.append(a)\n    return b\n\nf(1); f(2) returns?\nThink for a moment…",
132  "reveal": {
133   "afterSeconds": 6,
134   "body": "[1, 2]\nDefault arguments are evaluated once, at definition time."
135  }
136 },
137 {
138  "id": "py-late-binding",
139  "category": "python-advanced",
140  "value": 2,
141  "body": "fs = [lambda: i for i in range(3)]\n[f() for f in fs]\n\nThink for a moment…",
142  "reveal": {
143   "afterSeconds": 6,
144   "body": "[2, 2, 2]\nClosures capture the variable, not its value."
145  }
146 },
147 {
148  "id": "py-is-vs-eq",
149  "category": "python-advanced",
150  "body": "a = 256; b = 256\na is b  # usually True\n\n`is` compares identity, `==` compares value.\nNever use `is` for numbers or strings."
151 },
152 {
153  "id": "py-dict-order",
154  "category": "python-advanced",
155  "body": "dict preserves insertion order\n(guaranteed since Python 3.7).\n\nUpdating an existing key keeps its position."
156 },
157 {
158  "id": "py-gen-once",
159  "category": "python-advanced",
160  "body": "g = (x*x for x in range(3))\nlist(g); list(g)\n\nSecond call returns?\nThink for a moment…",
161  "reveal": {
162   "afterSeconds": 6,
163   "body": "[]\nGenerators are exhausted after one pass."
164  }
165 },
166 {
167  "id": "py-slots",
168  "category": "python-advanced",
169  "body": "__slots__ removes the per-instance __dict__.\n\nLess memory, faster attribute access,\nbut no arbitrary new attributes."
170 },
171 {
172  "id": "py-walrus",
173  "category": "python-advanced",
174  "body": "if (n := len(xs)) > 10:\n    print(n)\n\n:= assigns inside an expression."
175 },
176 {
177  "id": "py-asyncio-gather",
178  "category": "python-advanced",
179  "body": "await asyncio.gather(a(), b())\n\nRuns both concurrently on one thread.\nCPU-bound work still blocks the loop."
180 },
181 {
182  "id": "py-functools-cache",
183  "category": "python-advanced",
184  "body": "@functools.cache\ndef fib(n): ...\n\nMemoizes by arguments (must be hashable)."
185 },
186 {
187  "id": "py-mro",
188  "category": "python-advanced",
189  "value": 2,
190  "body": "class A: ...\nclass B(A): ...   class C(A): ...\nclass D(B, C): ...\n\nD.__mro__ order?\nThink for a moment…",
191  "reveal": {
192   "afterSeconds": 6,
193   "body": "D, B, C, A, object\nC3: a class precedes its bases; base order is kept."
194  }
195 },
196 {
197  "id": "py-dataclass-mutable",
198  "category": "python-advanced",
199  "value": 2,
200  "body": "@dataclass\nclass C:\n    xs: list = []\n\nWhat happens?\nThink for a moment…",
201  "reveal": {
202   "afterSeconds": 6,
203   "body": "ValueError at class creation.\nUse field(default_factory=list)."
204  }
205 },
206 {
207  "id": "py-contextmanager",
208  "category": "python-advanced",
209  "body": "@contextmanager\ndef cd(p):\n    old = os.getcwd(); os.chdir(p)\n    try: yield\n    finally: os.chdir(old)\n\nWithout try/finally, cleanup is skipped on error."
210 },
211 {
212  "id": "py-protocol",
213  "category": "python-advanced",
214  "body": "class Closer(Protocol):\n    def close(self) -> None: ...\n\nStructural typing: any object with close()\nmatches. No inheritance needed."
215 },
216 {
217  "id": "py-gil",
218  "category": "python-advanced",
219  "body": "The GIL lets one thread run Python bytecode\nat a time.\n\nThreads help I/O-bound work; use processes\nfor CPU-bound work."
220 },
221 {
222  "id": "pt-mean-shape",
223  "category": "ml-general",
224  "body": "x = torch.randn(32, 128)\nx.mean(dim=1).shape\n\nThink for a moment…",
225  "reveal": {
226   "afterSeconds": 6,
227   "body": "torch.Size([32])"
228  }
229 },
230 {
231  "id": "pt-keepdim",
232  "category": "ml-general",
233  "value": 2,
234  "body": "x = torch.randn(32, 128)\nx.sum(dim=1, keepdim=True).shape\n\nThink for a moment…",
235  "reveal": {
236   "afterSeconds": 6,
237   "body": "torch.Size([32, 1])\nkeepdim keeps the reduced axis as size 1."
238  }
239 },
240 {
241  "id": "pt-broadcast",
242  "category": "ml-general",
243  "body": "a: (8, 1, 5)   b: (3, 1)\n(a + b).shape\n\nThink for a moment…",
244  "reveal": {
245   "afterSeconds": 6,
246   "body": "torch.Size([8, 3, 5])\nShapes align from the right; size-1 dims broadcast."
247  }
248 },
249 {
250  "id": "pt-no-grad",
251  "category": "ml-general",
252  "body": "torch.no_grad()\n\nStops autograd from recording operations\ninside the context.\nUse it for inference and evaluation."
253 },
254 {
255  "id": "pt-inference-mode",
256  "category": "ml-general",
257  "body": "torch.inference_mode()\n\nLike no_grad, but faster: tensors also skip\nversion counting and can't be used in autograd later."
258 },
259 {
260  "id": "pt-view-vs-reshape",
261  "category": "ml-general",
262  "value": 2,
263  "body": "view() needs compatible strides and shares storage.\nreshape() copies only if it must.\n\nAfter transpose(), prefer reshape()\nor call .contiguous() first."
264 },
265 {
266  "id": "pt-cpu-gpu-copies",
267  "category": "ml-general",
268  "body": "Moving tensors CPU → GPU repeatedly in a tight loop\ncan dominate runtime.\n\nBatch transfers; use pin_memory + non_blocking=True."
269 },
270 {
271  "id": "pt-zero-grad",
272  "category": "ml-general",
273  "body": "optimizer.zero_grad(set_to_none=True)\n\nFreeing grads instead of zero-filling them\nsaves memory and a kernel launch."
274 },
275 {
276  "id": "pt-detach",
277  "category": "ml-general",
278  "body": "y = x.detach()\n\nShares storage with x but is cut from the graph.\nIn-place edits to y also change x."
279 },
280 {
281  "id": "pt-eval-mode",
282  "category": "ml-general",
283  "value": 2,
284  "body": "model.eval()\n\nSwitches dropout and batch norm to inference behavior.\nIt does NOT disable gradients — pair with no_grad()."
285 },
286 {
287  "id": "llm-tokens",
288  "category": "llm",
289  "body": "Tokens\nModels read tokens, not characters.\n~4 English chars ≈ 1 token."
290 },
291 {
292  "id": "llm-temperature",
293  "category": "llm",
294  "body": "Temperature\nLow = predictable, high = varied.\nIt rescales logits before softmax."
295 },
296 {
297  "id": "llm-context",
298  "category": "llm",
299  "body": "Context window\nEverything the model can see at once:\nprompt + history + its own output."
300 },
301 {
302  "id": "llm-kv-cache",
303  "category": "llm",
304  "body": "KV cache\nStores past keys/values so each new\ntoken costs one step, not a re-read."
305 },
306 {
307  "id": "llm-rag",
308  "category": "llm",
309  "body": "RAG\nRetrieve relevant text, put it in the prompt,\nthen generate. Fresh facts without retraining."
310 },
311 {
312  "id": "llm-recall-hallu",
313  "category": "llm",
314  "value": 2,
315  "body": "Why do LLMs hallucinate?\n\nThink for a moment…",
316  "reveal": {
317   "afterSeconds": 6,
318   "body": "They predict plausible text, not verified facts; nothing forces a ‘don’t know’."
319  }
320 },
321 {
322  "id": "llm-lora",
323  "category": "llm",
324  "body": "LoRA\nTrain small low-rank adapters instead of\nall weights: cheap fine-tuning."
325 },
326 {
327  "id": "llm-recall-attn",
328  "category": "llm",
329  "body": "What does attention compute?\n\nThink for a moment…",
330  "reveal": {
331   "afterSeconds": 6,
332   "body": "A weighted mix of value vectors; weights come from query·key similarity."
333  }
334 },
335 {
336  "id": "llm-top-p",
337  "category": "llm",
338  "body": "Top-p (nucleus) sampling\nKeep the smallest set of tokens whose\nprobabilities sum to p, then sample from it."
339 },
340 {
341  "id": "llm-recall-flash",
342  "category": "llm",
343  "value": 2,
344  "body": "Why is FlashAttention faster?\n\nThink for a moment…",
345  "reveal": {
346   "afterSeconds": 6,
347   "body": "Same math, less memory traffic: it tiles the work so the n×n score matrix never hits GPU HBM."
348  }
349 },
350 {
351  "id": "llm-recall-quadratic",
352  "category": "llm",
353  "value": 2,
354  "body": "Why does attention cost grow quadratically\nwith context length?\n\nThink for a moment…",
355  "reveal": {
356   "afterSeconds": 6,
357   "body": "Every token attends to every other: an n×n score matrix, so compute grows as n²."
358  }
359 },
360 {
361  "id": "llm-gqa",
362  "category": "llm",
363  "body": "Grouped-query attention\nSeveral query heads share one key/value head:\nsmaller KV cache, faster decoding."
364 },
365 {
366  "id": "llm-dpo",
367  "category": "llm",
368  "body": "DPO\nTrains on preference pairs (chosen vs rejected)\nwith a classification-style loss: no separate\nreward model, no RL loop."
369 }
370]
371
hooks/draw.ts 86 lines
1import type { Card } from './cards'
2
3const MAX_INNER = 56
4
5// Wrap a line to `width` columns on spaces; hard-cut words that are too long
6export function wrap(line: string, width: number): string[] {
7  if (line.length <= width) return [line]
8  const out: string[] = []
9  let cur = ''
10  for (const word of line.split(' ')) {
11    if (cur && (cur + ' ' + word).length > width) {
12      out.push(cur)
13      cur = word
14    } else cur = cur ? cur + ' ' + word : word
15    while (cur.length > width) {
16      out.push(cur.slice(0, width))
17      cur = cur.slice(width)
18    }
19  }
20  if (cur) out.push(cur)
21  return out
22}
23
24const pad = (s: string, n: number) => s + ' '.repeat(Math.max(0, n - s.length))
25
26/** What the footer says: a countdown while a delayed answer is coming, the answer once it is. */
27export function footer(card: Card, shownFor: number): { hint: string | null; answer: string[] } {
28  if (!card.reveal) return { hint: null, answer: [] }
29  const left = Math.ceil(card.reveal.afterSeconds - shownFor)
30  if (left > 0) {
31    const total = card.reveal.afterSeconds
32    const done = Math.max(0, Math.min(total, total - left))
33    return { hint: `answer in ${left}s ${'▰'.repeat(done)}${'▱'.repeat(total - done)}`, answer: [] }
34  }
35  return { hint: null, answer: card.reveal.body.split('\n') }
36}
37
38/** A boxed card: `╭─ sidecard · french ─╮`, bold headword, countdown, then the answer. */
39export function drawCard(
40  ui: { Box: any; Text: any },
41  card: Card,
42  opts: { label: string; color: string; shownFor: number; columns: number },
43) {
44  const { Box, Text } = ui
45  const { color } = opts
46  const maxInner = Math.max(24, Math.min(MAX_INNER, opts.columns - 6))
47  const body = card.body.split('\n').flatMap((l) => wrap(l, maxInner))
48  const f = footer(card, opts.shownFor)
49  const answer = f.answer.flatMap((l) => wrap(l, maxInner))
50  const title = ' sidecard · ' + opts.label + ' '
51  const hint = f.hint ? '┄ ' + f.hint : ''
52  const inner = Math.max(title.length + 2, hint.length, ...body.map((l) => l.length), ...answer.map((l) => l.length))
53  const W = inner + 2
54
55  const edge = (s: string) => Text({ color, dimColor: true, children: [s] })
56  const row = (text: string, o: Record<string, unknown> = {}) =>
57    Box({
58      flexDirection: 'row',
59      children: [edge('│ '), Text({ ...o, children: [pad(text, inner)] }), edge(' │')],
60    })
61
62  return Box({
63    flexDirection: 'column',
64    children: [
65      Box({
66        flexDirection: 'row',
67        children: [
68          edge('╭─'),
69          Text({ color, bold: true, children: [title] }),
70          edge('─'.repeat(Math.max(0, W - 2 - title.length)) + '╮'),
71        ],
72      }),
73      ...body.map((l, i) => row(l, i === 0 ? { bold: true } : {})),
74      ...(hint ? [row(hint, { dimColor: true, italic: true })] : []),
75      ...(answer.length
76        ? [edge('├' + '┄'.repeat(W) + '┤'), ...answer.map((l) => row(l, { color: 'green' }))]
77        : []),
78      edge('╰' + '─'.repeat(W) + '╯'),
79    ],
80  })
81}
82
83/** How long a card stays: long enough to read a delayed answer after it arrives. */
84export const holdMs = (card: Card, base = 20_000) =>
85  card.reveal ? Math.max(base, card.reveal.afterSeconds * 1000 + 8_000) : base
86
hooks/frontmatter.ts 89 lines
1import type { Card } from './cards'
2
3export type Input = { key: string; default: string; options: string[] }
4export type Category = {
5  name: string
6  title: string
7  color: string
8  mode: 'read' | 'delayed'
9  revealAfter: number
10  model: string
11  inputs: Input[]
12  prompt: string
13}
14
15const COLORS = ['cyan', 'green', 'magenta', 'yellow', 'blue', 'red', 'white']
16
17/**
18 * A category file: flat `key: value` frontmatter between `---` lines, then the prompt.
19 * Inputs are `input.<key>: <default> | <option>, <option>, ...`.
20 */
21export function parseCategory(src: string): Category | null {
22  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(src)
23  if (!m) return null
24  const meta: Record<string, string> = {}
25  const inputs: Input[] = []
26  for (const line of m[1].split(/\r?\n/)) {
27    const i = line.indexOf(':')
28    if (i < 1 || line.trimStart().startsWith('#')) continue
29    const key = line.slice(0, i).trim()
30    const value = line.slice(i + 1).trim()
31    if (key.startsWith('input.')) {
32      const [def, opts = ''] = value.split('|')
33      const options = opts.split(',').map((s) => s.trim()).filter(Boolean)
34      const d = def.trim()
35      if (d) inputs.push({ key: key.slice(6), default: d, options: options.includes(d) ? options : [d, ...options] })
36    } else meta[key] = value
37  }
38  const name = (meta.name ?? '').toLowerCase()
39  if (!/^[a-z0-9_-]{1,40}$/.test(name) || !m[2].trim()) return null
40  return {
41    name,
42    title: meta.title || name,
43    color: COLORS.includes(meta.color) ? meta.color : 'white',
44    mode: meta.mode === 'read' ? 'read' : 'delayed',
45    revealAfter: Math.min(30, Math.max(2, Number(meta.revealAfter) || 6)),
46    model: meta.model || 'haiku',
47    inputs,
48    prompt: m[2].trim(),
49  }
50}
51
52/** Replace {{key}} with the chosen value, or the input's default. */
53export function fill(prompt: string, inputs: Input[], values: Record<string, string>): string {
54  return prompt.replace(/\{\{(\w+)\}\}/g, (_, k: string) => values[k] ?? inputs.find((i) => i.key === k)?.default ?? '')
55}
56
57export function hash(s: string): string {
58  let h = 5381
59  for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
60  return (h >>> 0).toString(36)
61}
62
63/** The model's reply: a JSON array of { body, answer? }. Anything malformed is dropped. */
64export function parseCards(text: string, cat: Pick<Category, 'name' | 'mode' | 'revealAfter'>): Card[] {
65  const start = text.indexOf('[')
66  const end = text.lastIndexOf(']')
67  if (start < 0 || end <= start) return []
68  let raw: unknown
69  try {
70    raw = JSON.parse(text.slice(start, end + 1))
71  } catch {
72    return []
73  }
74  if (!Array.isArray(raw)) return []
75  const out: Card[] = []
76  for (const r of raw) {
77    if (!r || typeof r !== 'object') continue
78    const { body, answer, value } = r as { body?: unknown; answer?: unknown; value?: unknown }
79    if (typeof body !== 'string' || body.length < 3 || body.length > 500) continue
80    const reveal =
81      cat.mode === 'delayed' && typeof answer === 'string' && answer.trim() && answer.length <= 400
82        ? { afterSeconds: cat.revealAfter, body: answer.trim() }
83        : undefined
84    const v = value === 2 || value === 3 ? value : 1
85    out.push({ id: 'gen-' + hash(cat.name + body), category: cat.name, ...(v > 1 ? { value: v } : {}), body: body.trim(), ...(reveal ? { reveal } : {}) })
86  }
87  return out
88}
89
hooks/memory.ts 140 lines
1import type { Card } from './cards'
2
3const DAY = 86_400_000
4
5export type MemoryOptions = {
6  /** Stability S after a card's first showing: retention is exp(-elapsed / S). */
7  initialStabilityMs: number
8  /** How strongly a well-spaced re-showing grows S. 0 turns growth off. */
9  growth: number
10  /** A card is due for resurfacing once estimated retention falls below this. */
11  resurfaceBelow: number
12  /** Cards with a lower `value` never resurface. */
13  minValue: number
14  /** Most cards kept, and most showings kept per card. */
15  maxCards: number
16  maxShows: number
17}
18
19export const DEFAULT_MEMORY: MemoryOptions = {
20  initialStabilityMs: DAY,
21  growth: 2,
22  resurfaceBelow: 0.5,
23  minValue: 2,
24  maxCards: 300,
25  maxShows: 30,
26}
27
28/** Every card shown, keyed by card id, with when (ms since the epoch, ascending). Plain JSON for `$.store`. */
29export type History = Record<string, { card: Card; shows: number[] }>
30
31export type ReviewItem = { card: Card; shows: number[]; lastShownAt: number; count: number; retention: number }
32
33/** Ebbinghaus forgetting curve: the fraction still remembered after `elapsedMs`. */
34export const retention = (elapsedMs: number, stability: number) => Math.exp(-Math.max(0, elapsedMs) / stability)
35
36/**
37 * Stability rebuilt from the whole showing history. Each re-showing multiplies S by
38 * 1 + growth * (1 - R), R being what had been retained at that moment: a re-showing as the memory
39 * fades strengthens it a lot, a back-to-back repeat (R close to 1) barely at all.
40 */
41export function stabilityOf(shows: number[], o: MemoryOptions = DEFAULT_MEMORY): number {
42  let s = o.initialStabilityMs
43  for (let i = 1; i < shows.length; i++) s *= 1 + o.growth * (1 - retention((shows[i] ?? 0) - (shows[i - 1] ?? 0), s))
44  return s
45}
46
47/** Estimated retention at `now`, measured from the most recent showing. */
48export function retentionAt(shows: number[], now: number, o: MemoryOptions = DEFAULT_MEMORY): number {
49  const last = shows[shows.length - 1]
50  return last === undefined ? 0 : retention(now - last, stabilityOf(shows, o))
51}
52
53const valueOf = (c: Card) => c.value ?? 1
54const lastShow = (shows: number[]) => shows[shows.length - 1] ?? 0
55
56/** A copy of `h` with one more showing of `card` at `t`; the oldest low-value cards go when over the cap. */
57export function record(h: History, card: Card, t: number, o: MemoryOptions = DEFAULT_MEMORY): History {
58  const prev = h[card.id]
59  const out: History = { ...h, [card.id]: { card, shows: [...(prev?.shows ?? []), t].slice(-o.maxShows) } }
60  const ids = Object.keys(out)
61  if (ids.length <= o.maxCards) return out
62  const at = (id: string) => out[id]!
63  ids
64    .sort((a, b) => Number(valueOf(at(a).card) >= o.minValue) - Number(valueOf(at(b).card) >= o.minValue) || lastShow(at(a).shows) - lastShow(at(b).shows))
65    .slice(0, ids.length - o.maxCards)
66    .forEach((id) => delete out[id])
67  return out
68}
69
70/** The `n` most recently shown distinct cards, newest first. */
71export function recent(h: History, n: number, now: number, o: MemoryOptions = DEFAULT_MEMORY): ReviewItem[] {
72  return Object.values(h)
73    .filter((e) => e.shows.length > 0)
74    .sort((a, b) => lastShow(b.shows) - lastShow(a.shows))
75    .slice(0, n)
76    .map((e) => ({
77      card: e.card,
78      shows: e.shows,
79      lastShownAt: lastShow(e.shows),
80      count: e.shows.length,
81      retention: retentionAt(e.shows, now, o),
82    }))
83}
84
85/** A high-value card whose retention has decayed, picked weighted by value * forgotten. */
86export function pickDue(
87  h: History,
88  now: number,
89  allowed: (category: string) => boolean,
90  rng: () => number = Math.random,
91  o: MemoryOptions = DEFAULT_MEMORY,
92): Card | null {
93  const due: { card: Card; w: number }[] = []
94  for (const e of Object.values(h)) {
95    if (valueOf(e.card) < o.minValue || !allowed(e.card.category)) continue
96    const r = retentionAt(e.shows, now, o)
97    if (r < o.resurfaceBelow) due.push({ card: e.card, w: valueOf(e.card) * (1 - r) })
98  }
99  if (due.length === 0) return null
100  let x = rng() * due.reduce((a, d) => a + d.w, 0)
101  for (const d of due) if ((x -= d.w) < 0) return d.card
102  return due[due.length - 1]?.card ?? null
103}
104
105function ago(ms: number): string {
106  const m = Math.floor(Math.max(0, ms) / 60_000)
107  if (m < 1) return 'just now'
108  if (m < 60) return `${m}m ago`
109  if (m < 48 * 60) return `${Math.floor(m / 60)}h ago`
110  return `${Math.floor(m / 1440)}d ago`
111}
112
113/** Text for `/sidecard review`: newest first, answers included, with estimated retention. */
114export function formatReview(items: ReviewItem[], now: number): string {
115  if (items.length === 0) return 'No cards yet. They appear while Claude is working.'
116  const out = [`Recent cards (${items.length})`]
117  items.forEach((it, i) => {
118    out.push(
119      '',
120      `${i + 1}. ${it.card.category} · ${ago(now - it.lastShownAt)} · seen ${it.count}× · memory ${Math.round(it.retention * 100)}%`,
121      ...it.card.body.split('\n').map((l) => `   ${l}`.trimEnd()),
122    )
123    if (it.card.reveal) out.push('', ...it.card.reveal.body.split('\n').map((l) => `   → ${l}`))
124  })
125  return out.join('\n')
126}
127
128/** Tolerant read of whatever `$.store` holds under `history`: anything malformed is dropped. */
129export function parseHistory(raw: unknown): History {
130  const out: History = {}
131  if (!raw || typeof raw !== 'object') return out
132  for (const [id, e] of Object.entries(raw as Record<string, any>)) {
133    const c = e?.card
134    if (!c || typeof c.id !== 'string' || typeof c.category !== 'string' || typeof c.body !== 'string') continue
135    if (!Array.isArray(e.shows) || !e.shows.length || !e.shows.every((t: unknown) => typeof t === 'number')) continue
136    out[id] = { card: c as Card, shows: [...e.shows].sort((a: number, b: number) => a - b) }
137  }
138  return out
139}
140