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

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).
claude --version.claude -p)./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.
| Command | Effect | |
|---|---|---|
/sidecard or /sidecard menu | Category menu: ✓/☐ toggles (hotkeys 1–9) and a dropdown for each category's inputs | |
/sidecard now | Show 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 / off | Enable or disable all cards | |
/sidecard <category> | Toggle a category, e.g. /sidecard french | |
/sidecard <category> key=value | Set an input, e.g. /sidecard french level=B1 | |
| `/sidecard generate on\ | off` | Fresh cards from Claude Code's Haiku, on or off |
/sidecard reload | Rescan category files | |
/sidecard status | Show 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.
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.
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.french, python-advanced, ml-general and llm, used before the first batch arrives or when generation is off.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.
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).{"body", "answer"?}; you only write the teaching instructions.Contributions are welcome, especially new categories and better cards:
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.hooks/cards.ts are used before the first generated batch arrives and when generation is off.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.
MIT, see LICENSE.
hooks/register.ts 476 lines1import 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}
476hooks/cards.ts 371 lines1// 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]
371hooks/draw.ts 86 lines1import 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
86hooks/frontmatter.ts 89 lines1import 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}
89hooks/memory.ts 140 lines1import 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