Study companion: lessons and flashcards from your chats and interests, scheduled with FSRS or SM-2 spaced repetition

Learn from the work you already do. maxlearn is a Claude Code mod that sits beside your chat as a study companion. It watches the topics your conversations touch, teaches you the ideas behind them in short lessons, turns those lessons into flashcards, and schedules reviews with spaced repetition, so what you learn while shipping stays with you.
Most engineering knowledge is picked up in passing: a fix for a Redis eviction bug, a Postgres index that finally made a query fast. A week later it's gone. maxlearn catches those moments and brings them back at the right time.
Recommended: add the yashverma plugin marketplace once, then install. In Claude Code:
/plugin marketplace add yashverma2110/claude-plugins
/plugin install maxlearn@yashverma
Or from a terminal:
claude plugin marketplace add yashverma2110/claude-plugins
claude plugin install maxlearn@yashverma
To update later: /plugin marketplace update yashverma.
Straight from this repo (it is a marketplace too):
/plugin marketplace add yashverma2110/maxlearn
/plugin install maxlearn@maxlearn
From a clone, for one session (handy for development):
git clone https://github.com/yashverma2110/maxlearn.git
claude --plugin-dir ./maxlearn
Use one way only: two installs load two copies.
The pane docks beside the chat in terminals at least 144 columns wide; otherwise run /study. Your data lives in Claude Code's plugin store on your machine, so it carries across sessions.
After installing, start a session. In a terminal at least 144 columns wide the pane docks beside the chat; otherwise run /study. In the terminal, press ctrl+x tab to give the pane the keyboard and esc to give it back.
The first time, a welcome screen asks two things:
kafka), then Next.maxlearn writes your first two lessons right away; the line under the input shows ⟳ Writing lessons… while it works. Changed your mind later? Add an interest with /study <topic>, change Experience in settings, or run /study welcome again.
The tabs sit at the top of the pane: learn · review · quiz · insights · more · settings. Under them, on learn, review and quiz, pick all, from interests or from chats to study only what came from there. Each shows how many items are waiting.
The learn tab (e) shows one lesson at a time: the idea, why it matters, and an example.
enter) adds the lesson's cards to your review. Their first review comes 10 minutes later.n) moves on without adding anything.z) rewrites the lesson in simpler words. Later lessons on that topic start a level lower.MVCC?), paste any term into Explain, or, in the fullscreen terminal, select it with the mouse and press w. A short lesson on that term opens right away, and you return to the lesson you were reading.You can also learn in the chat: /study learn posts a lesson with the same Next and Simplify buttons.
When cards are due, the review tab (r) shows them one at a time.
s to show the answer.1 again · 2 hard · 3 good · 4 easy, or move with j l and press enter. Each button shows when the card comes back.Graded wrong by mistake? u undoes it. A bad card? d drops it for good. Prefer multiple choice? Use quiz (q).
Don't fully get a card? Press Teach me (t) after revealing it, or after answering a quiz question: a lesson on the idea behind it opens, and Next brings you back.
Nothing due? The empty review and quiz offer one chip per topic: + postgres writes 3 flashcards (you learn them first) or 3 quiz questions (ready right away).
📚 3 due · 🔥 5, your due cards and streak.p) now and then: what to improve, what you're strong in, your recent chats, and where your time goes.o): models, focus (daily work or interests), FSRS, automatic cards, and the takeaways line.Press h in the pane for every key. /study tips lists tips for your current state.
/study chat does this right away./study <topic> adds an interest, such as /study redis or /study system design./study learn: a lesson card right in the conversation. Next swaps the next lesson into the same row.+ to go deeper.good · 4d).t): a lesson on the idea behind any flashcard or quiz question, then back to where you were.u undoes a grade (and removes it from your stats); d drops a bad card for good.| View | Shows |
|---|---|
| overview | streak, recall over 30 days, mature cards, a 12-week review heat map, and your interests as chips (press one for 2 new lessons on it) |
| topics | subtopics you explored, topics that need more review (weakest first, with the reason), strong topics, your interests, and time spent per topic in chat, review and learning |
| work | topics from the last 7 days of chat, how many days each came up, and +3 cards for topics with too few cards |
| chats | which topics came up in which chats, and how long each chat spent on each |
/study stats prints the same as text.
| Setting | Options |
|---|---|
| Chat model | session model (cached, cheapest), opus, sonnet, haiku |
| Interest / lesson model | opus, sonnet, haiku |
| Experience | new to this, junior / mid, senior, staff+ |
| Focus | daily work, balanced, my interests |
| Scheduler | SM-2, FSRS (with target recall 85% / 90% / 95%) |
| Auto cards | each interest every off / 12h / 24h / 3 days / week; on chat touch on / off; cards from chat on / off |
| Takeaways | off / every 30s / every 2m |
| Start over | deletes all cards, reviews, lessons, interests and history (asks to confirm), keeps settings, and opens the welcome screen |
| Keys | Do |
|---|---|
i j k l | move the highlight, like ↑ ← ↓ → |
enter | press the highlighted button |
s | reveal the answer |
1 2 3 4 | grade again / hard / good / easy |
a–d | pick a quiz choice |
n / u / d | skip / undo / drop |
t / z / w | teach me / simplify / explain the selected word |
e r q p m o | learn, review, quiz, insights, more, settings |
h / esc | show all keys / close the pane |
| Command | Does |
|---|---|
/study | open the pane |
/study welcome | the welcome screen: interests and experience |
/study <topic> | add an interest and write its first cards |
/study learn | a lesson in the chat |
/study review · /study quiz | open the pane with the keyboard on it |
/study chat | make cards from this chat now |
/study stats · /study tips | progress / tips as text |
maxlearn runs with the same access as Claude Code, like every mod. Here is what it does with it:
| It uses | When | Turn it off |
|---|---|---|
| Model calls on your account | Every 3 turns, one small Haiku call (the learning gate) reads only the new turns; if they taught something, a fork of the chat writes cards (prompt-cached by default) | settings → Auto cards → Cards from chat → off |
| 2 lessons per call when you press Next with nothing queued, and per Simplify | only on your press | |
| 3 cards per interest each period (default 24h), and when a chat touches an interest (at most every 4h) | settings → Auto cards → period off, chat touch off | |
| Your chat transcript | Read to name topics and write cards; sent nowhere except the model calls above | settings → Auto cards → Cards from chat → off |
| Local storage | Cards, reviews, lessons, settings and time per topic, in Claude Code's plugin store | delete the maxlearn_* file in ~/.claude/plugins/store/ |
It reads no files and runs no commands. Nothing leaves your machine except the model calls.
claude plugin validate .
claude plugin test . # 103 tests
The code is split into pure modules (srs, fsrs, analytics, usage, lessons, prompts, keymap, layout, tips, auto, insight) and one hooks module (register.tsx) that holds everything touching Claude Code's engine.
hooks/register.tsx 2796 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
3
4import type {
5 AutoPeriod,
6 Algorithm,
7 Card,
8 ChatModel,
9 ChatSession,
10 ChatTopics,
11 Focus,
12 Generating,
13 Grade,
14 InsightPace,
15 InterestModel,
16 LastAnswer,
17 Retention,
18 LearnFrom,
19 Lesson,
20 Mode,
21 ReviewEvent,
22 SessionTally,
23 StudySettings,
24 View,
25} from '../types'
26import {
27 dots,
28 focusTopics,
29 heatLevel,
30 heatmap,
31 improve,
32 interestRank,
33 overview,
34 recordChatTopics,
35 sessionSummary,
36 sparkline,
37 streakMilestone,
38 strong,
39 topicInsights,
40 workTopics,
41} from './analytics'
42import {
43 HELP_LINES,
44 TAB_ORDER,
45 arrowCell,
46 arrowLines,
47 arrowMap,
48 focusOrder,
49 keyAction,
50 keyHint,
51 mainElement,
52 ringStep,
53 type KeyAction,
54 type KeyContext,
55} from './keymap'
56import { TAB_LABELS, bar, density, masteryColor, pct, type Density } from './layout'
57import {
58 ALGORITHMS,
59 AUTO_PERIODS,
60 CHAT_CONTEXT_CHARS,
61 CHAT_EVERY_TURNS,
62 CHAT_MODELS,
63 DEFAULT_SETTINGS,
64 FOCUS_OPTIONS,
65 INSIGHT_PACES,
66 INTEREST_MODELS,
67 MASTERED,
68 MAX_DECK,
69 MAX_REVIEWS,
70 ON_OFF,
71 PROFICIENCIES,
72 SUGGESTED_INTERESTS,
73 RETENTIONS,
74 TAB_KEYS,
75 TITLE,
76} from './constants'
77import { dueCards, grade, isToLearn, normTopic, parseCards, parseChatReply, previewInterval, shortWait, topicStats, type SchedOptions } from './srs'
78import { AUTO_CHECK_MS, chatMatches, periodDue, seedAutoAt, type AutoAt } from './auto'
79import { gateDigest, gatePrompt, parseGate } from './gate'
80import { pickInsight, statusLine } from './insight'
81import { attributeChat, chatLabel, duration, studyMs, topicUsage } from './usage'
82import {
83 LESSONS_PER_CALL,
84 addSubtopic,
85 cleanTerm,
86 fromMatches,
87 lessonSource,
88 teachPrompt,
89 termPrompt,
90 cardLesson,
91 findLesson,
92 isCardLesson,
93 learn,
94 lessonMarkdown,
95 lessonTopics,
96 nextCardToLearn,
97 parseLessons,
98} from './lessons'
99import { CARD_RUBRIC, LEVEL_BRIEF, STE_RULES, levelFor, simplifyPrompt } from './prompts'
100import { orderedTips, tipFor, type TipContext } from './tips'
101
102// Everything that takes `$` lives in this file: the engine follows `$` into
103// functions declared here and never across an import. The pure parts (state,
104// layout, tips, analytics, srs, keymap) are their own modules.
105
106const PANE = 'maxlearn'
107/** The key strip's Client key. */
108const KEYS = 'keys'
109
110// ── State: atoms the scan reads, so declared here ───────────────────────
111
112const deck = atom({ plugin: 'maxlearn', key: 'deck' } as const, [] as Card[])
113const interests = atom({ plugin: 'maxlearn', key: 'interests' } as const, [] as string[])
114const view = atom({ plugin: 'maxlearn', key: 'view' } as const, {
115 mode: 'review',
116 isRevealed: false,
117} as View)
118const generating = atom({ plugin: 'maxlearn', key: 'generating' } as const, null as Generating | null)
119const settings = atom({ plugin: 'maxlearn', key: 'settings' } as const, DEFAULT_SETTINGS)
120const reviews = atom({ plugin: 'maxlearn', key: 'reviews' } as const, [] as ReviewEvent[])
121const chatTopics = atom({ plugin: 'maxlearn', key: 'chatTopics' } as const, {} as ChatTopics)
122const lastAnswer = atom({ plugin: 'maxlearn', key: 'lastAnswer' } as const, null as LastAnswer | null)
123const lessons = atom({ plugin: 'maxlearn', key: 'lessons' } as const, [] as Lesson[])
124const lessonQueue = atom({ plugin: 'maxlearn', key: 'lessonQueue' } as const, [] as string[])
125const lessonNow = atom({ plugin: 'maxlearn', key: 'lessonNow' } as const, null as string | null)
126const chatRuns = atom({ plugin: 'maxlearn', key: 'chatRuns' } as const, {} as Record<string, string>)
127const chatSessions = atom({ plugin: 'maxlearn', key: 'chatSessions' } as const, [] as ChatSession[])
128const learnTime = atom({ plugin: 'maxlearn', key: 'learnTime' } as const, {} as Record<string, number>)
129const subtopics = atom({ plugin: 'maxlearn', key: 'subtopics' } as const, {} as Record<string, string[]>)
130const session = atom({ plugin: 'maxlearn', key: 'session' } as const, {
131 reviewed: 0,
132 correct: 0,
133 startedAt: 0,
134} as SessionTally)
135
136// ── Actions ────────────────────────────────────────────────────────────────
137
138type $ = EngineInterface
139
140const CARD_ITEM = `{"topic": short category such as "system design" or "TypeScript",
141 "front": one specific question about a mechanism, trade-off or failure mode,
142 "back": the answer first, then why, then one concrete detail (1-3 sentences),
143 "choices": exactly 4 short options for a multiple-choice quiz,
144 "answer": the 0-based index of the correct choice}`
145
146
147const CARD_SHAPE = `Reply with ONLY a JSON array, no prose. Each item:
148${CARD_ITEM}
149
150${CARD_RUBRIC}
151
152${STE_RULES}`
153
154const CHAT_SHAPE = `Reply with ONLY a JSON object, no prose:
155{"topics": up to 5 short engineering topics this work touched, even when no card is worth making,
156 "cards": an array of 0-2 cards}
157Each card:
158${CARD_ITEM}
159
160${CARD_RUBRIC}
161
162${STE_RULES}`
163
164/** Asks the model to reuse names already in use, so one topic keeps one name. */
165function topicHint(cards: Card[], extra: string[] = []): string {
166 const names = [...new Set([...cards.map(c => c.topic), ...extra])].slice(0, 40)
167 return names.length === 0 ? '' : `Reuse one of these topic names when it fits: ${names.join(', ')}.`
168}
169
170export async function saveDeck($: $, change: (cards: Card[]) => Card[]): Promise<Card[]> {
171 const next = await update($, deck, cards => change(cards).slice(-MAX_DECK))
172 await $.store.set('deck', next)
173 await showStatus($)
174 return next
175}
176
177/** `📚 4 due · 🔥 6` under the prompt; nothing before the first card. */
178const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
179
180/** `Writing cards on postgres` or `Writing cards from this chat`. */
181const writingWhat = (label: string) => (label === 'this chat' ? 'from this chat' : `on ${label}`)
182
183/** The line under the prompt: a spinner while cards are written, else `📚 4 due · 🔥 6`. */
184export async function showStatus($: $) {
185 const now = await $.clock.now()
186 const busy = await read($, generating)
187 if (busy !== null) {
188 const elapsed = Math.max(0, now - busy.startedAt)
189 const frame = SPINNER[Math.floor(elapsed / 120) % SPINNER.length]
190 $.ui.status(`${frame} Writing ${busy.noun ?? 'card'}s ${writingWhat(busy.label)} · ${Math.round(elapsed / 1000)}s`)
191 return
192 }
193 const cards = await read($, deck)
194 if (cards.length === 0) {
195 $.ui.status(undefined)
196 return
197 }
198 const o = overview(cards, await read($, reviews), now)
199 const shown = cards.find(c => c.id === insightId)
200 $.ui.status(statusLine(`📚 ${o.dueNow} due${o.streakDays > 0 ? ` · 🔥 ${o.streakDays}` : ''}`, shown))
201}
202
203// The takeaway on the status line and the timer that swaps it. Module state:
204// a reload drops both, and session.start starts them again.
205let insightId: string | undefined
206let insightTimer: Timer | undefined
207
208/** Shows a takeaway from another reviewed, not-due card. */
209async function rotateInsight($: $) {
210 const card = pickInsight(await read($, deck), await $.clock.now(), insightId, Math.random())
211 insightId = card?.id
212 await showStatus($)
213}
214
215/** (Re)starts the rotation at the pace the settings ask for; `off` clears it. */
216async function startInsights($: $, pace: InsightPace) {
217 insightTimer?.cancel()
218 insightTimer = undefined
219 const ms = INSIGHT_PACES.find(p => p.value === pace)?.ms ?? 0
220 if (ms === 0) {
221 insightId = undefined
222 await showStatus($)
223 return
224 }
225 await rotateInsight($)
226 insightTimer = $.clock.every(ms, () => void rotateInsight($))
227}
228
229/**
230 * Adds the model's cards. Quiz questions (`isQuizReady`) must have choices and
231 * are ready at once: answering a multiple-choice question is their first look.
232 */
233async function addCards($: $, text: string, source: Card['source'], isQuizReady = false): Promise<number> {
234 const now = await $.clock.now()
235 let added = 0
236 await saveDeck($, cards => {
237 const parsed = parseCards(text, cards, source, now)
238 const fresh = isQuizReady
239 ? parsed.filter(c => c.choices.length > 0).map(c => ({ ...c, learnedAt: now, due: now }))
240 : parsed
241 added = fresh.length
242 return [...cards, ...fresh]
243 })
244 return added
245}
246
247/** Records the topics the chat touched today, then adds its cards. */
248// ── Where time goes ──
249// Chat time since the last topic check, and when each card or lesson came on
250// screen. Module state: a reload starts both over, losing at most a few turns.
251let pendingChatMs = 0
252const shownAt = new Map<string, number>()
253
254/** Files the chat time since the last check under this chat, split over the topics it touched. */
255async function recordChatTime($: $, topics: string[]) {
256 if (topics.length === 0 || pendingChatMs === 0) return
257 const id = await $.session.id()
258 const first = (await $.session.messages()).find(m => m.role === 'user')?.text
259 const folder = (await $.session.cwd()).split('/').pop() ?? 'chat'
260 const now = await $.clock.now()
261 const ms = pendingChatMs
262 pendingChatMs = 0
263 const next = await update($, chatSessions, list => attributeChat(list, id, chatLabel(first, folder), topics, ms, now))
264 await $.store.set('chatSessions', next)
265}
266
267/** Notes when `id` came on screen, once; the grade or Next measures from here. */
268function markShown(id: string, now: number) {
269 if (!shownAt.has(id)) shownAt.set(id, now)
270}
271
272/** How long `id` was on screen, and forgets it. */
273function takeShown(id: string, now: number): number {
274 const ms = studyMs(shownAt.get(id), now)
275 shownAt.delete(id)
276 return ms
277}
278
279/** Files the topics the chat touched: today's counts, this chat's time, and interests to top up. */
280async function recordTopics($: $, topics: string[]) {
281 if (topics.length === 0) return
282 const now = await $.clock.now()
283 const next = await update($, chatTopics, chat => recordChatTopics(chat, topics, now))
284 await $.store.set('chatTopics', next)
285 await recordChatTime($, topics)
286 await onChatTopics($, topics)
287}
288
289async function addChatReply($: $, text: string): Promise<number> {
290 const now = await $.clock.now()
291 const { topics, cards } = parseChatReply(text, await read($, deck), now)
292 await recordTopics($, topics)
293 if (cards.length > 0) await saveDeck($, existing => [...existing, ...cards])
294 return cards.length
295}
296
297// Rows the gate has already read: the next check reads only what came after.
298// Module state: a reload reads the recent chat once more, newest kept.
299let gatedRows = 0
300
301/**
302 * The automatic chat check: the learning gate first (one cheap call over the
303 * turns since the last check); execution-only work files its topics and stops.
304 */
305async function autoChatCheck($: $): Promise<number> {
306 const rows = await $.session.messages()
307 const fresh = rows.slice(Math.min(gatedRows, rows.length))
308 gatedRows = rows.length
309 const digest = gateDigest(fresh)
310 if (digest === '') return 0
311 const reply = await $.model.complete({ model: 'haiku', effort: 'low', maxTokens: 200, timeoutMs: 30_000, prompt: gatePrompt(digest) })
312 const verdict = reply.isAnswered ? parseGate(reply.text) : undefined
313 // An unreadable verdict fails open: the card writer has its own rule against execution-only work.
314 if (verdict?.kind === 'execution') {
315 await recordTopics($, verdict.topics)
316 return 0
317 }
318 return fromChat($)
319}
320
321export async function fromChat($: $): Promise<number> {
322 const cards = await read($, deck)
323 const known = cards.slice(-40).map(c => `- ${c.front}`).join('\n')
324 const { chatModel } = await read($, settings)
325 const ask = `[maxlearn] Step outside the task for a moment. Name the engineering topics the recent work in this conversation touched. Then, from its engineering or design concepts, pick at most 2 that a software engineer would benefit from remembering long-term: principles, patterns, trade-offs, language or API semantics. Skip anything specific to this one codebase. If the recent work only executed tasks (ran commands, renamed or moved files, edited config, committed, formatted) and explained no reusable idea, give "cards": []. Give "cards": [] if nothing qualifies.
326${topicHint(cards, await read($, interests))}
327Do not repeat these existing cards:
328${known || '(none)'}
329
330${CHAT_SHAPE}`
331
332 if (chatModel === 'session') {
333 const reply = await $.model.fork({ prompt: ask })
334 return reply.isAnswered ? addChatReply($, reply.text) : 0
335 }
336
337 const transcript = await recentChat($)
338 if (transcript === '') return 0
339 const reply = await $.model.complete({
340 model: chatModel,
341 maxTokens: 2048,
342 timeoutMs: 60_000,
343 prompt: `<conversation>\n${transcript}\n</conversation>\n\n${ask.replace('this conversation', 'the conversation above')}`,
344 })
345 return reply.isAnswered ? addChatReply($, reply.text) : 0
346}
347
348/** The newest chat rows as plain text, newest kept when it runs long. */
349async function recentChat($: $): Promise<string> {
350 const rows = await $.session.messages()
351 const lines: string[] = []
352 let size = 0
353 for (const row of [...rows].reverse()) {
354 const line = `${row.role}: ${row.text}`
355 if (row.text.trim() === '') continue
356 if (size + line.length > CHAT_CONTEXT_CHARS) break
357 lines.unshift(line)
358 size += line.length
359 }
360 return lines.join('\n\n')
361}
362
363export async function fromInterest($: $, topic: string, kind: 'cards' | 'quiz' = 'cards'): Promise<number> {
364 await stampAuto($, topic)
365 const now = await $.clock.now()
366 const cards = await read($, deck)
367 const log = await read($, reviews)
368 const t = topicInsights(cards, log, await read($, chatTopics), await read($, interests), now).find(
369 x => x.topic === topic,
370 )
371 const recall = t?.recentAccuracy ?? t?.accuracy ?? null
372 const reviewsOfTopic = log.filter(r => r.topic === topic).length
373 const level = levelFor(
374 t && { cards: t.cards, mastery: t.mastery, recall, reviews: reviewsOfTopic },
375 (await readSimpler($))[topic] ?? 0,
376 (await read($, settings)).proficiency,
377 )
378 const known = cards
379 .filter(c => c.topic === topic)
380 .slice(-30)
381 .map(c => `- ${c.front}`)
382 .join('\n')
383
384 const reply = await $.model.complete({
385 model: (await read($, settings)).interestModel,
386 maxTokens: 4096,
387 timeoutMs: 120_000,
388 effort: 'high',
389 prompt: `Write ${kind === 'quiz' ? 'multiple-choice quiz questions' : 'flashcards'} on "${topic}" for ${LEVEL_BRIEF[level]}. Set "topic" to "${topic}".
390${kind === 'quiz' ? '\nEvery card MUST have 4 "choices" and the right "answer" index. Make the "back" explain why the answer is right.\n' : ''}
391Draft 6 candidate cards in your head. Check each against the rubric below. Reply with only the 3 best.
392${known === '' ? '' : `\nThe learner already has these cards. Do not repeat them; go deeper or cover a different part of ${topic}:\n${known}\n`}
393${CARD_SHAPE}`,
394 })
395 return reply.isAnswered ? addCards($, reply.text, 'interest', kind === 'quiz') : 0
396}
397
398/** A work's result when it already toasted its own failure. */
399const REPORTED = -1
400
401// Requests made while cards are being written wait here, one per label, and
402// run in turn. Module state: a reload drops the queue with the work in flight.
403type Noun = 'card' | 'lesson' | 'simpler lesson' | 'quiz question'
404const queue: { label: string; work: () => Promise<number>; isAuto: boolean; noun: Noun; lessonId?: string }[] = []
405
406/** Runs generation off the current dispatch so a turn never waits on it; one at a time, the rest queued. */
407export function generate($: $, label: string, work: () => Promise<number>, isAuto = false, noun: Noun = 'card', lessonId?: string) {
408 $.clock.after(0, async () => {
409 const busy = await read($, generating)
410 if (busy !== null) {
411 if ((busy.label === label && busy.noun === noun) || queue.some(q => q.label === label && q.noun === noun)) return
412 queue.push({ label, work, isAuto, noun, lessonId })
413 // The queue is module state: ask for a redraw so "Queued next" shows.
414 $.ui.invalidate('ui.render')
415 if (!isAuto) $.ui.toast(`📚 Queued: ${noun}s ${writingWhat(label)}`)
416 return
417 }
418 const startedAt = await $.clock.now()
419 await update($, generating, () => ({ label, startedAt, noun, lessonId }))
420 await showStatus($)
421 // Animate the status line; a reload drops the timer and session.start clears the state.
422 const spinner = $.clock.every(120, () => void showStatus($))
423 try {
424 const n = await work()
425 const auto = isAuto ? ' (auto)' : ''
426 if (n === REPORTED) {
427 // The work already said what went wrong: one toast, not two.
428 } else if (n > 0) $.ui.toast(`📚 ${n} new ${noun}${n === 1 ? '' : 's'} ${writingWhat(label)}${auto}`)
429 else if (!isAuto) $.ui.toast(`📚 No new ${noun}s ${writingWhat(label)}`)
430 } finally {
431 spinner.cancel()
432 await update($, generating, () => null)
433 await showStatus($)
434 const nextUp = queue.shift()
435 if (nextUp !== undefined) generate($, nextUp.label, nextUp.work, nextUp.isAuto, nextUp.noun, nextUp.lessonId)
436 }
437 })
438}
439
440// ── Learning: lessons before flashcards ──
441
442const PENDING = 'pending'
443
444/** Topics in order of need: weak first, then this week's work, interests, the rest of the deck. */
445async function lessonCandidates($: $): Promise<string[]> {
446 const i = await readInsights($)
447 const deckTopics = (await read($, deck)).map(c => c.topic)
448 return [...new Set([...i.improve.map(t => t.topic), ...i.work.map(w => w.topic), ...i.savedInterests, ...deckTopics])]
449}
450
451/**
452 * One model call that writes `LESSONS_PER_CALL` lessons, each with its cards:
453 * the first goes to whoever is waiting, the rest wait for the next Next.
454 */
455async function writeLessons($: $, aim?: string[], source: 'interest' | 'chat' = 'interest'): Promise<number> {
456 const known = await read($, lessons)
457 const topics = aim ?? lessonTopics(await lessonCandidates($), known)
458 if (topics.length === 0) {
459 $.ui.toast('📖 Add an interest first: /study <topic>')
460 return 0
461 }
462 const now = await $.clock.now()
463 const cards = await read($, deck)
464 const log = await read($, reviews)
465 const insights = topicInsights(cards, log, await read($, chatTopics), await read($, interests), now)
466 const simpler = await readSimpler($)
467 const { proficiency } = await read($, settings)
468 const brief = topics
469 .map((topic, n) => {
470 const t = insights.find(x => x.topic === topic)
471 const recall = t?.recentAccuracy ?? t?.accuracy ?? null
472 const level = levelFor(
473 t && { cards: t.cards, mastery: t.mastery, recall, reviews: log.filter(r => r.topic === topic).length },
474 simpler[topic] ?? 0,
475 proficiency,
476 )
477 return `${n + 1}. "${topic}" for ${LEVEL_BRIEF[level]}`
478 })
479 .join('\n')
480 const taught = known.slice(-20).map(l => `- ${l.title}`).join('\n')
481
482 const reply = await $.model.complete({
483 model: (await read($, settings)).interestModel,
484 maxTokens: 4096,
485 timeoutMs: 120_000,
486 prompt: `Teach ${topics.length} short lessons, one per topic, in this order:
487${brief}
488${taught === '' ? '' : `\nDo not repeat these lessons:\n${taught}\n`}
489A lesson teaches ONE idea a learner can use: the mechanism, why it matters, and what goes wrong without it.
490- "title": the idea as a short statement, not a question.
491- "body": 3-5 sentences.
492- "example": one concrete case: a command, a number, a query or a failure.
493- "terms": up to 4 technical terms or abbreviations in your lesson that this learner may not know (for example "DDL", "MVCC").
494- "cards": 1 or 2 flashcards that test exactly what the lesson taught.
495
496Reply with ONLY a JSON array, no prose:
497[{"topic": "...", "title": "...", "body": "...", "example": "...", "terms": ["..."], "cards": [${CARD_ITEM}]}]
498
499${CARD_RUBRIC}
500
501${STE_RULES}`,
502 })
503 if (!reply.isAnswered) return 0
504 const fresh = parseLessons(reply.text, cards, known, now).map(l => ({ ...l, source, cards: l.cards.map(c => ({ ...c, source })) }))
505 if (fresh.length === 0) return 0
506 const all = await update($, lessons, list => [...list, ...fresh].slice(-100))
507 await $.store.set('lessons', all)
508 const queued = await update($, lessonQueue, q => [...q, ...fresh.map(l => l.id)])
509 await $.store.set('lessonQueue', queued)
510 await fillWaiting($)
511 return fresh.length
512}
513
514/** Hands queued lessons to the learn tab and the chat rows waiting for one. */
515async function fillWaiting($: $) {
516 if ((await read($, lessonNow)) === PENDING) {
517 const id = await takeQueued($, (await read($, view)).from)
518 if (id !== undefined) await update($, lessonNow, () => id)
519 }
520 const runs = await read($, chatRuns)
521 for (const [run, id] of Object.entries(runs).sort(([a], [b]) => Number(a) - Number(b))) {
522 if (id !== PENDING) continue
523 const next = await takeQueued($)
524 if (next === undefined) break
525 await update($, chatRuns, r => ({ ...r, [run]: next }))
526 }
527}
528
529/** Takes the first queued lesson the filter shows; the rest keep their order. */
530async function takeQueued($: $, from?: LearnFrom): Promise<string | undefined> {
531 const queue = await read($, lessonQueue)
532 const cards = await read($, deck)
533 const known = await read($, lessons)
534 const at = queue.findIndex(id => {
535 const lesson = findLesson(id, cards, known)
536 return lesson !== undefined && fromMatches(lessonSource(lesson, cards), from)
537 })
538 if (at < 0) return undefined
539 const rest = queue.filter((_, n) => n !== at)
540 await update($, lessonQueue, () => rest)
541 await $.store.set('lessonQueue', rest)
542 return queue[at]
543}
544
545/** Lessons and cards waiting to be learned under each filter. */
546async function learnCounts($: $): Promise<Record<LearnFrom, number>> {
547 const cards = await read($, deck)
548 const known = await read($, lessons)
549 const sources = [
550 ...cards.filter(isToLearn).map(c => c.source),
551 ...(await read($, lessonQueue)).map(id => {
552 const lesson = findLesson(id, cards, known)
553 return lesson === undefined ? undefined : lessonSource(lesson, cards)
554 }),
555 ].filter((x): x is 'interest' | 'chat' => x !== undefined)
556 return {
557 all: sources.length,
558 interests: sources.filter(x => x === 'interest').length,
559 chats: sources.filter(x => x === 'chat').length,
560 }
561}
562
563/**
564 * The next lesson to show: a card still to learn (free), else a written
565 * lesson waiting in the queue (free), else `pending` while two are written,
566 * or undefined when `mayWrite` is false.
567 */
568// Card lessons skipped on the learn tab: they go to the back of the line until
569// every other card and queued lesson has had its turn. Module state.
570const skippedCards = new Set<string>()
571
572async function takeNextLesson($: $, mayWrite: boolean, from?: LearnFrom): Promise<string | undefined> {
573 const shown = new Set([await read($, lessonNow), ...Object.values(await read($, chatRuns))].filter((x): x is string => typeof x === 'string'))
574 const cards = (await read($, deck)).filter(c => fromMatches(c.source, from))
575 const card = nextCardToLearn(cards, new Set([...shown, ...skippedCards]))
576 if (card !== undefined) return cardLesson(card).id
577 const queued = await takeQueued($, from)
578 if (queued !== undefined) return queued
579 // Only skipped cards are left: start them over, oldest first.
580 const again = nextCardToLearn(cards, shown)
581 if (again !== undefined) {
582 skippedCards.clear()
583 return cardLesson(again).id
584 }
585 if (!mayWrite) return undefined
586 const known = await read($, lessons)
587 if (from === 'chats') {
588 // Lessons on what the recent chats touched; none yet means nothing to write from.
589 const topics = lessonTopics((await readInsights($)).work.map(w => w.topic), known)
590 if (topics.length === 0) return undefined
591 generate($, topics.join(' + '), () => writeLessons($, topics, 'chat'), false, 'lesson')
592 return PENDING
593 }
594 if (from === 'interests') {
595 const topics = lessonTopics(await read($, interests), known)
596 if (topics.length === 0) return undefined
597 generate($, topics.join(' + '), () => writeLessons($, topics), false, 'lesson')
598 return PENDING
599 }
600 const [topic] = lessonTopics(await lessonCandidates($), known)
601 generate($, topic ?? 'your interests', () => writeLessons($), false, 'lesson')
602 return PENDING
603}
604
605/** Changes the source filter; on learn, a lesson the new filter hides goes back in line. */
606async function setFrom($: $, from: LearnFrom) {
607 const v = await update($, view, (x): View => ({ ...x, from, cardId: undefined, isRevealed: false, skipped: undefined }))
608 if (v.mode !== 'learn') return
609 const shown = await read($, lessonNow)
610 if (shown !== null && shown !== PENDING) {
611 const cards = await read($, deck)
612 const lesson = findLesson(shown, cards, await read($, lessons))
613 if (lesson !== undefined && fromMatches(lessonSource(lesson, cards), from)) return
614 if (!isCardLesson(shown)) {
615 const queued = await update($, lessonQueue, q => [shown, ...q.filter(id => id !== shown)])
616 await $.store.set('lessonQueue', queued)
617 }
618 }
619 // Free only: a card to learn or a queued lesson; writing waits for a press.
620 const next = await takeNextLesson($, false, from)
621 await update($, lessonNow, () => next ?? null)
622}
623
624// ── Simplify ──
625
626/** How many times each topic was simplified: each one steps its next lessons down a level. */
627async function readSimpler($: $): Promise<Record<string, number>> {
628 return ((await $.store.get('simpler')) as Record<string, number> | undefined) ?? {}
629}
630
631/** Queues a simpler rewrite of lesson `id`, in place; the topic's later lessons start a level lower. */
632function simplify($: $, id: string) {
633 void (async () => {
634 const lesson = findLesson(id, await read($, deck), await read($, lessons))
635 if (lesson === undefined) return
636 generate($, lesson.topic, () => simplifyLesson($, id), false, 'simpler lesson', id)
637 })()
638}
639
640async function simplifyLesson($: $, id: string): Promise<number> {
641 const deckNow = await read($, deck)
642 const lesson = findLesson(id, deckNow, await read($, lessons))
643 if (lesson === undefined) return 0
644 const cardId = isCardLesson(id) ? id.slice('card:'.length) : undefined
645 const cards = cardId !== undefined ? deckNow.filter(c => c.id === cardId) : lesson.cards
646 const reply = await $.model.complete({
647 model: (await read($, settings)).interestModel,
648 maxTokens: 2048,
649 timeoutMs: 90_000,
650 prompt: `${simplifyPrompt(lesson, cards)}\n\n${STE_RULES}`,
651 })
652 if (!reply.isAnswered) return 0
653 const now = await $.clock.now()
654 // Parse against a deck without the old cards, so their fronts do not count as repeats.
655 const others = deckNow.filter(c => !cards.some(o => o.id === c.id))
656 const [simpler] = parseLessons(reply.text, others, [], now)
657 if (simpler === undefined) return 0
658
659 if (cardId !== undefined) {
660 // A card lesson is the card itself: keep its id and schedule, take the simpler words.
661 const words = simpler.cards[0] ?? { front: simpler.title, back: simpler.body, choices: [], answer: -1 }
662 await saveDeck($, list =>
663 list.map(c => (c.id === cardId ? { ...c, front: words.front, back: words.back, choices: words.choices, answer: words.answer } : c)),
664 )
665 } else {
666 const next = await update($, lessons, list =>
667 list.map(l => (l.id === id ? { ...simpler, id: l.id, createdAt: l.createdAt, topic: l.topic } : l)),
668 )
669 await $.store.set('lessons', next)
670 }
671 const counts = await readSimpler($)
672 await $.store.set('simpler', { ...counts, [lesson.topic]: (counts[lesson.topic] ?? 0) + 1 })
673 return 1
674}
675
676/** Reading a lesson is learning it: its card is marked learned, or its cards join the deck. */
677// ── Explain a term ──
678
679/**
680 * Explains a term met in a lesson: one call writes a short lesson on it, shown
681 * at once; the lesson it came from comes back next. The term is filed as a
682 * subtopic of the lesson's topic.
683 */
684async function explainTerm($: $, raw: string, fromId?: string | null, inTopic?: string) {
685 const term = cleanTerm(raw)
686 if (term === undefined) {
687 $.ui.toast('Type or select one term to explain, up to 40 characters.')
688 return
689 }
690 const from = fromId && fromId !== PENDING ? findLesson(fromId, await read($, deck), await read($, lessons)) : undefined
691 const topic = from?.topic ?? inTopic ?? normTopic(term)
692 // The lesson being read waits at the front of the line; the learn tab shows the spinner.
693 if (from !== undefined) {
694 const queued = await update($, lessonQueue, q => [from.id, ...q.filter(id => id !== from.id)])
695 await $.store.set('lessonQueue', queued)
696 }
697 await update($, view, (v): View => ({ ...v, mode: 'learn', isRevealed: false }))
698 await update($, lessonNow, () => PENDING)
699 generate($, term, () => writeTermLesson($, term, topic, from), false, 'lesson')
700}
701
702async function writeTermLesson($: $, term: string, topic: string, from: Lesson | undefined): Promise<number> {
703 const context = from && `${from.title}\n${from.body}`
704 const reply = await $.model.complete({
705 model: (await read($, settings)).interestModel,
706 maxTokens: 2048,
707 timeoutMs: 90_000,
708 prompt: `${termPrompt(term, topic, context)}\n\n${CARD_RUBRIC}\n\n${STE_RULES}`,
709 })
710 const now = await $.clock.now()
711 const [lesson] = reply.isAnswered ? parseLessons(reply.text, await read($, deck), await read($, lessons), now) : []
712 if (lesson === undefined) {
713 // Nothing usable: hand the learn tab back the lesson it came from.
714 if ((await read($, lessonNow)) === PENDING) await update($, lessonNow, () => null)
715 await fillWaiting($)
716 if ((await read($, lessonNow)) === null) await update($, lessonNow, () => (from ? from.id : null))
717 $.ui.toast(`Could not explain "${term}" this time.`)
718 return REPORTED
719 }
720 // An explanation belongs where the lesson it came from belongs.
721 const source = from === undefined ? 'interest' : lessonSource(from, await read($, deck))
722 const explained: Lesson = {
723 ...lesson,
724 topic,
725 subtopic: term,
726 source,
727 cards: lesson.cards.map(c => ({ ...c, topic, subtopic: term, source })),
728 }
729 const all = await update($, lessons, list => [...list, explained].slice(-100))
730 await $.store.set('lessons', all)
731 const map = await update($, subtopics, m => addSubtopic(m, topic, term))
732 await $.store.set('subtopics', map)
733 if ((await read($, lessonNow)) === PENDING) await update($, lessonNow, () => explained.id)
734 else {
735 const queued = await update($, lessonQueue, q => [explained.id, ...q])
736 await $.store.set('lessonQueue', queued)
737 }
738 return 1
739}
740
741/** `w`: explains what the learner selected with the mouse (fullscreen terminal). */
742async function explainSelection($: $, fromId: string | null) {
743 const selected = await $.ui.selection()
744 if (selected === undefined || selected.text.trim() === '') {
745 $.ui.toast('Select a word with the mouse first, or type it in the Explain field.')
746 return
747 }
748 await explainTerm($, selected.text, fromId)
749}
750
751async function markLearned($: $, id: string | null | undefined) {
752 if (!id || id === PENDING) return
753 const lesson = findLesson(id, await read($, deck), await read($, lessons))
754 if (lesson === undefined) return
755 const now = await $.clock.now()
756 await saveDeck($, cards => learn(lesson, cards, now))
757 const ms = takeShown(id, now)
758 if (ms > 0) {
759 const next = await update($, learnTime, t => ({ ...t, [lesson.topic]: (t[lesson.topic] ?? 0) + ms }))
760 await $.store.set('learnTime', next)
761 }
762}
763
764/** The learn tab's Next (`isLearned`) or skip: learn the one shown only on Next, then show the next (writing two when none waits). */
765/** Teach me: a lesson on the idea behind a flashcard or quiz question, shown now; Next goes back. */
766async function teachCard($: $, card: Card, returnTo: Mode) {
767 const shown = await read($, lessonNow)
768 if (shown !== null && shown !== PENDING) {
769 const queued = await update($, lessonQueue, q => [shown, ...q.filter(id => id !== shown)])
770 await $.store.set('lessonQueue', queued)
771 }
772 await update($, view, (v): View => ({ mode: 'learn', isRevealed: false, returnTo, from: v.from }))
773 await update($, lessonNow, () => PENDING)
774 generate($, card.topic, () => writeTeachLesson($, card), false, 'lesson')
775}
776
777async function writeTeachLesson($: $, card: Card): Promise<number> {
778 const reply = await $.model.complete({
779 model: (await read($, settings)).interestModel,
780 maxTokens: 2048,
781 timeoutMs: 90_000,
782 prompt: `${teachPrompt(card)}\n\n${CARD_RUBRIC}\n\n${STE_RULES}`,
783 })
784 const now = await $.clock.now()
785 const [lesson] = reply.isAnswered ? parseLessons(reply.text, await read($, deck), await read($, lessons), now) : []
786 if (lesson === undefined) {
787 if ((await read($, lessonNow)) === PENDING) await update($, lessonNow, () => null)
788 const back = (await read($, view)).returnTo
789 if (back !== undefined) await setMode($, back)
790 $.ui.toast('Could not write that lesson this time.')
791 return REPORTED
792 }
793 const taught: Lesson = {
794 ...lesson,
795 topic: card.topic,
796 source: card.source,
797 fromCard: card.id,
798 cards: lesson.cards.map(c => ({ ...c, topic: card.topic, source: card.source })),
799 }
800 const all = await update($, lessons, list => [...list, taught].slice(-100))
801 await $.store.set('lessons', all)
802 if ((await read($, lessonNow)) === PENDING) await update($, lessonNow, () => taught.id)
803 else {
804 const queued = await update($, lessonQueue, q => [taught.id, ...q])
805 await $.store.set('lessonQueue', queued)
806 }
807 return 1
808}
809
810async function nextOnLearnTab($: $, isLearned: boolean) {
811 // Skipped, a card lesson stays to learn; a written lesson is let go and its cards never join.
812 const current = await read($, lessonNow)
813 if (isLearned) await markLearned($, current)
814 else if (current !== null && isCardLesson(current)) skippedCards.add(current)
815 await update($, lessonNow, () => null)
816 const { returnTo } = await read($, view)
817 if (returnTo !== undefined) {
818 // A Teach-me lesson read: back to the review or quiz it came from.
819 await setMode($, returnTo)
820 return
821 }
822 const next = await takeNextLesson($, true, (await read($, view)).from)
823 await update($, lessonNow, () => next ?? null)
824}
825
826/**
827 * Adds an interest and shows it at once: saved (insights and more list it on
828 * the next draw), the learn tab waiting on it, and two lessons on it queued.
829 */
830async function addInterest($: $, raw: string): Promise<string | undefined> {
831 const topic = normTopic(raw)
832 if (topic === 'general' || raw.trim() === '') return undefined
833 const saved = await update($, interests, list => (list.includes(topic) ? list : [...list, topic]))
834 await $.store.set('interests', saved)
835 await stampAuto($, topic)
836 // The learn tab shows the spinner now and the first lesson the moment it lands.
837 const now = await read($, lessonNow)
838 if (now === null || now === PENDING) await update($, lessonNow, () => PENDING)
839 await setMode($, 'learn')
840 generate($, topic, () => writeLessons($, Array.from({ length: LESSONS_PER_CALL }, () => topic)), false, 'lesson')
841 return topic
842}
843
844// ── Onboarding ──
845
846/** Opens the welcome screen at its first step, keeping any picks. */
847async function startWelcome($: $) {
848 await update($, view, (v): View => ({ mode: 'welcome', isRevealed: false, welcomeStep: 'interests', picked: v.picked ?? [] }))
849}
850
851async function togglePick($: $, raw: string) {
852 const topic = normTopic(raw)
853 if (raw.trim() === '' || topic === 'general') return
854 await update($, view, v => {
855 const picked = v.picked ?? []
856 return { ...v, picked: picked.includes(topic) ? picked.filter(t => t !== topic) : [...picked, topic] }
857 })
858}
859
860/** Saves the picks and the level, then writes one lesson call over the first two picks. */
861async function finishWelcome($: $) {
862 const picked = (await read($, view)).picked ?? []
863 await $.store.set('onboarded', true)
864 if (picked.length > 0) {
865 const saved = await update($, interests, list => [...new Set([...list, ...picked])])
866 await $.store.set('interests', saved)
867 for (const topic of picked) await stampAuto($, topic)
868 const aim = picked.length === 1 ? [picked[0]!, picked[0]!] : picked.slice(0, 2)
869 await update($, lessonNow, now => (now === null ? PENDING : now))
870 generate($, aim.join(' + '), () => writeLessons($, aim), false, 'lesson')
871 }
872 await update($, view, () => ({ mode: 'learn', isRevealed: false }))
873}
874
875/** Every stored key but the settings: what Start over clears. */
876const RESET_KEYS = [
877 'deck', 'reviews', 'lessons', 'lessonQueue', 'interests', 'chatTopics', 'chatSessions',
878 'learnTime', 'autoAt', 'simpler', 'milestones', 'onboarded', 'subtopics',
879]
880
881/**
882 * Start over: deletes every card, review, lesson, interest and the history,
883 * keeps the settings, and opens onboarding. Work still queued is dropped.
884 */
885async function resetAll($: $) {
886 queue.length = 0
887 for (const key of RESET_KEYS) await $.store.delete(key)
888 await update($, deck, () => [])
889 await update($, reviews, () => [])
890 await update($, lessons, () => [])
891 await update($, lessonQueue, () => [])
892 await update($, lessonNow, () => null)
893 await update($, chatRuns, () => ({}))
894 await update($, interests, () => [])
895 await update($, chatTopics, () => ({}))
896 await update($, chatSessions, () => [])
897 await update($, learnTime, () => ({}))
898 await update($, subtopics, () => ({}))
899 await update($, lastAnswer, () => null)
900 await update($, session, s => ({ reviewed: 0, correct: 0, startedAt: s.startedAt }))
901 insightId = undefined
902 await update($, view, (): View => ({ mode: 'welcome', isRevealed: false, welcomeStep: 'interests', picked: [] }))
903 await showStatus($)
904 $.ui.toast('🧹 Everything deleted. Start again with your interests.')
905}
906
907async function skipWelcome($: $) {
908 await $.store.set('onboarded', true)
909 await update($, view, () => ({ mode: 'learn', isRevealed: false }))
910}
911
912// ── Automatic interest cards ──
913
914async function readAutoAt($: $): Promise<AutoAt> {
915 return ((await $.store.get('autoAt')) as AutoAt | undefined) ?? {}
916}
917
918/** Notes that `topic` got cards now, by any route, so its period starts over. */
919async function stampAuto($: $, topic: string) {
920 const at = await readAutoAt($)
921 await $.store.set('autoAt', { ...at, [topic]: await $.clock.now() })
922}
923
924/** Queues an interest's cards on its own; stamped now, so a second trigger in the meantime is a no-op. */
925async function autoInterest($: $, topic: string) {
926 await stampAuto($, topic)
927 generate($, topic, () => fromInterest($, topic), true)
928}
929
930/** The chat touched these topics: make cards for the saved interests among them, within the cooldown. */
931async function onChatTopics($: $, topics: string[]) {
932 if ((await read($, settings)).autoOnChat !== 'on') return
933 const matches = chatMatches(topics, await read($, interests), await readAutoAt($), await $.clock.now())
934 for (const topic of matches.slice(0, 2)) await autoInterest($, topic)
935}
936
937/** One interest whose period ran out gets cards; the next check takes the next one. */
938async function checkPeriod($: $) {
939 const { autoPeriod } = await read($, settings)
940 const ms = AUTO_PERIODS.find(p => p.value === autoPeriod)?.ms ?? 0
941 const topic = periodDue(await read($, interests), await readAutoAt($), await $.clock.now(), ms)
942 if (topic !== undefined) await autoInterest($, topic)
943}
944
945let autoTimers: Timer[] = []
946
947/** (Re)starts the period checks: soon after start, then every 15 minutes; `off` stops them. */
948async function startAuto($: $, period: AutoPeriod) {
949 for (const t of autoTimers) t.cancel()
950 autoTimers = []
951 if (period === 'off') return
952 const at = await readAutoAt($)
953 const seeded = seedAutoAt(await read($, interests), at, await $.clock.now())
954 if (seeded !== at) await $.store.set('autoAt', seeded)
955 autoTimers = [
956 $.clock.after(30_000, () => void checkPeriod($)),
957 $.clock.every(AUTO_CHECK_MS, () => void checkPeriod($)),
958 ]
959}
960
961/** Everything insights draws, read once per draw. */
962export async function readInsights($: $) {
963 const now = await $.clock.now()
964 const cards = await read($, deck)
965 const log = await read($, reviews)
966 const saved = await read($, interests)
967 const { focus } = await read($, settings)
968 const topics = topicInsights(cards, log, await read($, chatTopics), saved, now)
969 const favored = focusTopics(focus, topics, saved)
970 return {
971 now,
972 log,
973 focus,
974 favored,
975 savedInterests: saved,
976 overview: overview(cards, log, now),
977 improve: improve(topics, favored),
978 strong: strong(topics),
979 interests: interestRank(topics),
980 work: workTopics(topics),
981 busy: await read($, generating),
982 settings: await read($, settings),
983 chats: await read($, chatSessions),
984 subtopics: await read($, subtopics),
985 usage: topicUsage(await read($, chatSessions), log, await read($, learnTime)),
986 }
987}
988
989export type Insights = Awaited<ReturnType<typeof readInsights>>
990
991export function tipContext(i: Insights): TipContext {
992 return {
993 cards: i.overview.totalCards,
994 due: i.overview.dueNow,
995 interests: i.savedInterests.length,
996 retention: i.overview.retention30d,
997 thinWorkTopics: i.work.filter(w => w.isThin).length,
998 focus: i.focus,
999 streakDays: i.overview.streakDays,
1000 }
1001}
1002
1003export const focusLabel = (focus: StudySettings['focus']) =>
1004 FOCUS_OPTIONS.find(f => f.value === focus)?.label ?? focus
1005
1006export async function statsText($: $): Promise<string> {
1007 const i = await readInsights($)
1008 const busy = await read($, generating)
1009 const writing = busy === null ? [] : [`⟳ Writing ${busy.noun ?? 'card'}s ${writingWhat(busy.label)}…`]
1010 if (i.overview.totalCards === 0 && i.work.length === 0) {
1011 if (i.savedInterests.length === 0) return 'No cards or interests yet. Add one: /study <topic>'
1012 return [...writing, `Interests: ${i.savedInterests.join(', ')}`, 'No cards yet: they arrive with your first lessons (/study learn).'].join('\n')
1013 }
1014 const o = i.overview
1015 const list = (title: string, rows: { topic: string; reason: string }[]) =>
1016 rows.length === 0 ? [] : [title, ...rows.map(r => ` ${r.topic}: ${r.reason}`)]
1017 return [
1018 ...writing,
1019 `Streak ${o.streakDays}d · ${o.reviewsToday} today · ${sparkline(o.reviews7d)} · retention ${pct(o.retention30d)} · mature ${o.matureCards}/${o.totalCards} · ${o.dueNow} due`,
1020 `Focus: ${focusLabel(i.focus)}`,
1021 ...list('Improve', i.improve),
1022 ...list('Strong', i.strong),
1023 ...list(
1024 'Work this week',
1025 i.work.map(w => ({ topic: w.topic, reason: `${w.chatDays7}d · ${w.cards} cards${w.isThin ? ' · thin' : ''}` })),
1026 ),
1027 ...list('Interests', i.interests),
1028 ...list(
1029 'Time spent (chat · review · learn)',
1030 i.usage.slice(0, 8).map(u => ({
1031 topic: u.topic,
1032 reason: `${duration(u.chatMs)} in ${u.chats} chat${u.chats === 1 ? '' : 's'} · ${duration(u.reviewMs)} · ${duration(u.learnMs)}`,
1033 })),
1034 ),
1035 ].join('\n')
1036}
1037
1038export async function tipsText($: $): Promise<string> {
1039 const tips = orderedTips(tipContext(await readInsights($)))
1040 return ['Tips for maxlearn', ...tips.map(t => `• ${t.text}`)].join('\n')
1041}
1042
1043export async function saveSettings($: $, change: Partial<StudySettings>) {
1044 const next = await update($, settings, s => ({ ...s, ...change }))
1045 await $.store.set('settings', next)
1046 if (change.insightPace !== undefined) await startInsights($, next.insightPace)
1047 if (change.autoPeriod !== undefined) await startAuto($, next.autoPeriod)
1048}
1049
1050/** Switches tab; each tab starts face down, sub-views and toggles kept. */
1051export async function setMode($: $, mode: Mode) {
1052 if (mode === 'learn' && (await read($, lessonNow)) === null) {
1053 // Entering is free: a card to learn or a queued lesson; writing waits for a press.
1054 const next = await takeNextLesson($, false, (await read($, view)).from)
1055 if (next !== undefined) await update($, lessonNow, () => next)
1056 }
1057 await update($, view, v => ({
1058 mode,
1059 isRevealed: false,
1060 insights: v.insights,
1061 more: v.more,
1062 isHelp: v.isHelp,
1063 tipIndex: v.tipIndex,
1064 from: v.from,
1065 }))
1066}
1067
1068export async function setView($: $, change: Partial<View>) {
1069 await update($, view, v => ({ ...v, ...change }))
1070}
1071
1072/** The card the current mode shows: the pinned one, else favored topics first, then the most overdue. */
1073function currentCard(cards: Card[], v: View, now: number, favored: ReadonlySet<string>): Card | undefined {
1074 const pinned = cards.find(c => c.id === v.cardId)
1075 if (pinned) return pinned
1076 const skipped = new Set(v.skipped ?? [])
1077 const due = dueCards(cards, now, favored).filter(c => !skipped.has(c.id) && fromMatches(c.source, v.from))
1078 return v.mode === 'quiz' ? due.find(c => c.choices.length > 0) : due[0]
1079}
1080
1081/** The card review or quiz shows now, and the context keys are read in. */
1082export async function activeCard($: $) {
1083 const now = await $.clock.now()
1084 const cards = await read($, deck)
1085 const v = await read($, view)
1086 const { favored } = await readInsights($)
1087 const card = currentCard(cards, v, now, favored)
1088 const ctx: KeyContext = {
1089 mode: v.mode,
1090 isRevealed: v.isRevealed,
1091 choices: card?.choices.length ?? 0,
1092 cursor: Math.max(0, Math.min((card?.choices.length ?? 1) - 1, v.cursor ?? 0)),
1093 hasCard: card !== undefined,
1094 }
1095 return { card, v, ctx }
1096}
1097
1098/** Runs one action from any input (button, hotkey, ⌨ strip), then moves the ring to what comes next. */
1099export async function runKey($: $, action: KeyAction, card: Card | undefined) {
1100 await applyKey($, action, card)
1101 // A ring move is itself the focus change; refocusing the main button would undo it.
1102 if (action.type !== 'ring') await focusMain($)
1103}
1104
1105/**
1106 * Puts the pane's focus ring on the button Enter should press now. Only lands
1107 * while the pane holds the keys; otherwise the engine says no and nothing moves.
1108 */
1109export async function focusMain($: $) {
1110 const { ctx } = await activeCard($)
1111 const key = mainElement(ctx)
1112 if (key === undefined) return
1113 try {
1114 await $.ui.focus({ requestId: PANE, key })
1115 } catch {
1116 // Best effort: the grade or reveal already happened; a failed move must not undo it.
1117 }
1118}
1119
1120/** j/k in the quiz: moves the ▸ cursor and the focus ring together. */
1121export async function moveCursor($: $, card: Card, step: number) {
1122 await update($, view, x => ({
1123 ...x,
1124 cursor: Math.max(0, Math.min(card.choices.length - 1, (x.cursor ?? 0) + step)),
1125 }))
1126 await focusMain($)
1127}
1128
1129async function applyKey($: $, action: KeyAction, card: Card | undefined) {
1130 switch (action.type) {
1131 case 'tab':
1132 return setMode($, action.mode)
1133 case 'help':
1134 return update($, view, v => ({ ...v, isHelp: !v.isHelp }))
1135 case 'undo':
1136 return undoLast($)
1137 case 'cursor':
1138 return update($, view, v => ({ ...v, cursor: action.index }))
1139 case 'ring':
1140 return moveRing($, action.step)
1141 case 'next':
1142 return update($, view, v => ({ ...faceDown(v) }))
1143 }
1144 if (card === undefined) return
1145 switch (action.type) {
1146 case 'reveal':
1147 return update($, view, v => ({ ...v, cardId: card.id, isRevealed: true }))
1148 case 'skip':
1149 return update($, view, v => ({ ...faceDown(v), skipped: [...(v.skipped ?? []), card.id] }))
1150 case 'grade':
1151 return answer($, card, action.grade)
1152 case 'choose':
1153 return action.index === card.answer
1154 ? answer($, card, 'good', '✓ Correct')
1155 : answer($, card, 'again', `✗ It was: ${card.choices[card.answer]}`)
1156 }
1157}
1158
1159/** The next card face down: the current card's pin, feedback and cursor dropped. */
1160function faceDown(v: View): View {
1161 return {
1162 mode: v.mode,
1163 isRevealed: false,
1164 skipped: v.skipped,
1165 insights: v.insights,
1166 more: v.more,
1167 isHelp: v.isHelp,
1168 tipIndex: v.tipIndex,
1169 from: v.from,
1170 }
1171}
1172
1173export async function answer($: $, card: Card, g: Grade, feedback?: string) {
1174 const now = await $.clock.now()
1175 const cardsBefore = await read($, deck)
1176 const before = cardsBefore.find(c => c.id === card.id) ?? card
1177 const masteryBefore = topicStats(cardsBefore, now).find(s => s.topic === card.topic)?.mastery ?? 0
1178
1179 const opts = schedOptions(await read($, settings))
1180 const cards = await saveDeck($, list => list.map(c => (c.id === card.id ? grade(c, g, now, opts) : c)))
1181 await update($, lastAnswer, () => ({ before, t: now }))
1182 const event: ReviewEvent = {
1183 t: now,
1184 cardId: card.id,
1185 topic: card.topic,
1186 grade: g,
1187 mode: feedback === undefined ? 'review' : 'quiz',
1188 ms: takeShown(card.id, now),
1189 }
1190 const log = await update($, reviews, list => [...list, event].slice(-MAX_REVIEWS))
1191 await $.store.set('reviews', log)
1192 await update($, session, s => ({
1193 reviewed: s.reviewed + 1,
1194 correct: s.correct + (g === 'again' ? 0 : 1),
1195 startedAt: s.startedAt || now,
1196 }))
1197 await update($, view, v =>
1198 feedback === undefined ? faceDown(v) : { ...v, cardId: card.id, isRevealed: true, feedback },
1199 )
1200 await showStatus($)hooks/analytics.ts 257 lines1import type { Card, ChatTopics, Focus, ReviewEvent, SessionTally } from '../types'
2import { dueCards, topicStats, type TopicStats } from './srs'
3
4const KEEP_CHAT_DAYS = 30
5const MATURE_DAYS = 21
6const THIN_CARDS = 3
7/** Added to a favored topic's weakness score, so the focus wins a near tie. */
8const FOCUS_BONUS = 0.25
9
10/** The local calendar day of `t`, as `YYYY-MM-DD`. */
11export function dayKey(t: number): string {
12 const d = new Date(t)
13 const pad = (n: number) => String(n).padStart(2, '0')
14 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
15}
16
17/** The day keys of the last `count` days, oldest first, ending today. */
18export function lastDays(now: number, count: number): string[] {
19 const keys: string[] = []
20 for (let i = count - 1; i >= 0; i--) {
21 const d = new Date(now)
22 d.setDate(d.getDate() - i)
23 keys.push(dayKey(d.getTime()))
24 }
25 return keys
26}
27
28/** Adds one mention per topic to today's bucket and drops days past the window. */
29export function recordChatTopics(chat: ChatTopics, topics: string[], now: number): ChatTopics {
30 const today = dayKey(now)
31 const bucket = { ...chat[today] }
32 for (const topic of topics) bucket[topic] = (bucket[topic] ?? 0) + 1
33
34 const keep = new Set(lastDays(now, KEEP_CHAT_DAYS))
35 const next: ChatTopics = {}
36 for (const [day, counts] of Object.entries({ ...chat, [today]: bucket })) {
37 if (keep.has(day)) next[day] = counts
38 }
39 return next
40}
41
42export type Overview = {
43 streakDays: number
44 reviewsToday: number
45 /** Reviews per day, oldest first, today last. */
46 reviews7d: number[]
47 /** 0..1, or null with no reviews in 30 days. */
48 retention30d: number | null
49 matureCards: number
50 totalCards: number
51 dueNow: number
52}
53
54export function overview(deck: Card[], reviews: ReviewEvent[], now: number): Overview {
55 const perDay = new Map<string, number>()
56 for (const r of reviews) perDay.set(dayKey(r.t), (perDay.get(dayKey(r.t)) ?? 0) + 1)
57
58 // A streak survives until the end of the day after the last review.
59 const days = lastDays(now, 400).reverse()
60 const from = (perDay.get(days[0] ?? '') ?? 0) > 0 ? 0 : 1
61 let streakDays = 0
62 for (const day of days.slice(from)) {
63 if ((perDay.get(day) ?? 0) === 0) break
64 streakDays += 1
65 }
66
67 const window = new Set(lastDays(now, 30))
68 const recent = reviews.filter(r => window.has(dayKey(r.t)))
69 return {
70 streakDays,
71 reviewsToday: perDay.get(dayKey(now)) ?? 0,
72 reviews7d: lastDays(now, 7).map(day => perDay.get(day) ?? 0),
73 retention30d:
74 recent.length === 0 ? null : recent.filter(r => r.grade !== 'again').length / recent.length,
75 matureCards: deck.filter(c => c.intervalDays >= MATURE_DAYS).length,
76 totalCards: deck.length,
77 dueNow: dueCards(deck, now).length,
78 }
79}
80
81export type TopicInsight = TopicStats & {
82 lapses: number
83 /** Recall over the last 14 days, 0..1, or null with no reviews there. */
84 recentAccuracy: number | null
85 /** Days in the last 7 on which chat touched the topic. */
86 chatDays7: number
87 chatMentions7: number
88 chatMentions30: number
89 reviews30: number
90 isInterest: boolean
91}
92
93/** Every topic in the deck, the chat window or the interests, with its signals. */
94export function topicInsights(
95 deck: Card[],
96 reviews: ReviewEvent[],
97 chat: ChatTopics,
98 interests: string[],
99 now: number,
100): TopicInsight[] {
101 const stats = new Map(topicStats(deck, now).map(s => [s.topic, s]))
102 const week = lastDays(now, 7)
103 const days14 = new Set(lastDays(now, 14))
104 const days30 = new Set(lastDays(now, 30))
105 const saved = new Set(interests)
106
107 const topics = new Set([...stats.keys(), ...interests])
108 for (const counts of Object.values(chat)) for (const topic of Object.keys(counts)) topics.add(topic)
109
110 return [...topics].map(topic => {
111 const base = stats.get(topic) ?? { topic, cards: 0, due: 0, mastery: 0, accuracy: null }
112 const recent = reviews.filter(r => r.topic === topic && days14.has(dayKey(r.t)))
113 return {
114 ...base,
115 lapses: deck.filter(c => c.topic === topic).reduce((n, c) => n + c.lapses, 0),
116 recentAccuracy:
117 recent.length === 0 ? null : recent.filter(r => r.grade !== 'again').length / recent.length,
118 chatDays7: week.filter(day => (chat[day]?.[topic] ?? 0) > 0).length,
119 chatMentions7: week.reduce((n, day) => n + (chat[day]?.[topic] ?? 0), 0),
120 chatMentions30: Object.entries(chat)
121 .filter(([day]) => days30.has(day))
122 .reduce((n, [, counts]) => n + (counts[topic] ?? 0), 0),
123 reviews30: reviews.filter(r => r.topic === topic && days30.has(dayKey(r.t))).length,
124 isInterest: saved.has(topic),
125 }
126 })
127}
128
129export type Ranked = TopicInsight & { reason: string }
130
131const pct = (x: number) => `${Math.round(x * 100)}%`
132
133/** Topics used in chat this week, most days first; thin ones need cards. */
134export function workTopics(insights: TopicInsight[]): (TopicInsight & { isThin: boolean })[] {
135 return insights
136 .filter(t => t.chatDays7 > 0)
137 .sort((a, b) => b.chatDays7 - a.chatDays7 || b.chatMentions7 - a.chatMentions7)
138 .map(t => ({ ...t, isThin: t.cards < THIN_CARDS }))
139}
140
141/** The topics the person's focus favors. */
142export function focusTopics(focus: Focus, insights: TopicInsight[], interests: string[]): Set<string> {
143 const work = workTopics(insights).map(t => t.topic)
144 if (focus === 'work') return new Set(work)
145 if (focus === 'interests') return new Set(interests)
146 return new Set([...work, ...interests])
147}
148
149/** Weakest topics first; a favored topic gets a bonus. */
150export function improve(insights: TopicInsight[], favored: ReadonlySet<string>, limit = 3): Ranked[] {
151 const score = (t: TopicInsight) =>
152 0.5 * (1 - t.mastery) +
153 0.3 * (1 - (t.recentAccuracy ?? t.accuracy ?? 0.5)) +
154 0.2 * Math.min(1, t.lapses / t.cards) +
155 (favored.has(t.topic) ? FOCUS_BONUS : 0)
156
157 // A topic held well is not one to improve, however few topics there are.
158 const isHeld = (t: TopicInsight) =>
159 t.mastery >= 0.6 && (t.recentAccuracy ?? t.accuracy ?? 0) >= 0.8
160 return insights
161 .filter(t => t.cards >= 2 && !isHeld(t))
162 .sort((a, b) => score(b) - score(a))
163 .slice(0, limit)
164 .map(t => {
165 const recall = t.recentAccuracy ?? t.accuracy
166 const parts = [recall === null ? 'new' : `${pct(recall)} recall`]
167 if (t.lapses > 0) parts.push(`${t.lapses} lapse${t.lapses === 1 ? '' : 's'}`)
168 return { ...t, reason: parts.join(' · ') }
169 })
170}
171
172/** Topics held well: mature-ish, recalled, enough cards to trust it. */
173export function strong(insights: TopicInsight[], limit = 3): Ranked[] {
174 return insights
175 .filter(t => t.cards >= 3 && t.mastery >= 0.6 && (t.recentAccuracy ?? t.accuracy ?? 0) >= 0.8)
176 .sort((a, b) => b.mastery - a.mastery)
177 .slice(0, limit)
178 .map(t => ({ ...t, reason: `${pct(t.recentAccuracy ?? t.accuracy ?? 0)} recall` }))
179}
180
181/** What the person cares about: saved interests, chat mentions and study, each 0..1. */
182export function interestRank(insights: TopicInsight[], limit = 5): Ranked[] {
183 const maxChat = Math.max(1, ...insights.map(t => t.chatMentions30))
184 const maxStudy = Math.max(1, ...insights.map(t => t.reviews30))
185 const score = (t: TopicInsight) =>
186 (t.isInterest ? 1 : 0) + t.chatMentions30 / maxChat + t.reviews30 / maxStudy
187
188 return insights
189 .filter(t => score(t) > 0)
190 .sort((a, b) => score(b) - score(a))
191 .slice(0, limit)
192 .map(t => {
193 const tags = [t.isInterest && 'saved', t.chatMentions30 > 0 && 'chat', t.reviews30 > 0 && 'studied']
194 return { ...t, reason: tags.filter(Boolean).join(' · ') }
195 })
196}
197
198/** `▁▂▃▄▅▆▇█` scaled to the largest value; zero is the lowest bar. */
199export function sparkline(values: number[]): string {
200 const bars = '▁▂▃▄▅▆▇█'
201 const max = Math.max(...values, 0)
202 return values.map(v => bars[max === 0 ? 0 : Math.round((v / max) * (bars.length - 1))]).join('')
203}
204
205/** `●●●○○○○` for days active out of 7. */
206export function dots(days: number): string {
207 return '●'.repeat(days) + '○'.repeat(Math.max(0, 7 - days))
208}
209
210export const HEAT_WEEKS = 12
211
212/**
213 * Reviews per day over the last `weeks` weeks as rows Monday..Sunday and
214 * columns oldest..this week; days after today are -1.
215 */
216export function heatmap(reviews: ReviewEvent[], now: number, weeks = HEAT_WEEKS): number[][] {
217 const perDay = new Map<string, number>()
218 for (const r of reviews) perDay.set(dayKey(r.t), (perDay.get(dayKey(r.t)) ?? 0) + 1)
219
220 const today = new Date(now)
221 const weekday = (today.getDay() + 6) % 7 // Monday 0
222 const grid: number[][] = Array.from({ length: 7 }, () => new Array<number>(weeks).fill(-1))
223 for (let col = 0; col < weeks; col++) {
224 for (let row = 0; row < 7; row++) {
225 const back = (weeks - 1 - col) * 7 + (weekday - row)
226 if (back < 0) continue
227 const d = new Date(now)
228 d.setDate(d.getDate() - back)
229 grid[row]![col] = perDay.get(dayKey(d.getTime())) ?? 0
230 }
231 }
232 return grid
233}
234
235/** 0..4: how dark a heat-map cell is, against the busiest day shown. */
236export function heatLevel(count: number, max: number): number {
237 if (count <= 0 || max <= 0) return 0
238 return Math.max(1, Math.min(4, Math.ceil((count / max) * 4)))
239}
240
241export function sessionSummary(tally: SessionTally, o: Overview): string {
242 const recall = tally.reviewed === 0 ? '' : ` · ${Math.round((tally.correct / tally.reviewed) * 100)}% recall`
243 const streak = o.streakDays > 0 ? ` · 🔥 ${o.streakDays}-day streak` : ''
244 return `${tally.reviewed} reviewed${recall}${streak}`
245}
246
247const STREAK_MILESTONES = [3, 7, 14, 30, 60, 100, 365]
248
249/**
250 * The milestone `streakDays` reached today, unless already shown today; a
251 * streak that breaks and grows back earns it again. Keys are `day:days`.
252 */
253export function streakMilestone(streakDays: number, shown: string[], now: number): string | undefined {
254 const key = `${dayKey(now)}:${streakDays}`
255 return STREAK_MILESTONES.includes(streakDays) && !shown.includes(key) ? key : undefined
256}
257hooks/keymap.ts 183 lines1import type { Grade, Mode } from '../types'
2
3/** Tab order for `[`, `]` and shift+arrows. */
4export const TAB_ORDER: Mode[] = ['learn', 'review', 'quiz', 'progress', 'add', 'settings']
5
6export type KeyContext = {
7 mode: Mode
8 isRevealed: boolean
9 /** Choices on the current quiz card; 0 when none. */
10 choices: number
11 /** The highlighted choice in quiz mode. */
12 cursor: number
13 hasCard: boolean
14}
15
16export type KeyAction =
17 | { type: 'reveal' }
18 | { type: 'grade'; grade: Grade }
19 | { type: 'skip' }
20 | { type: 'choose'; index: number }
21 | { type: 'cursor'; index: number }
22 | { type: 'next' }
23 | { type: 'tab'; mode: Mode }
24 | { type: 'undo' }
25 | { type: 'help' }
26 /** Move the highlight back (-1) or forward (1), as an arrow key would. */
27 | { type: 'ring'; step: -1 | 1 }
28
29export type Key = { key: string; shift?: boolean; ctrl?: boolean; meta?: boolean }
30
31const isConfirm = (k: string) => k === ' ' || k === 'space' || k === 'return' || k === 'enter'
32const GRADE_DIGITS: Record<string, Grade> = { '1': 'again', '2': 'hard', '3': 'good', '4': 'easy' }
33const CHOICE_KEYS = ['a', 'b', 'c', 'd']
34
35/** What one key does in this context, or undefined to ignore it. */
36export function keyAction(k: Key, ctx: KeyContext): KeyAction | undefined {
37 if (k.ctrl || k.meta) return undefined
38
39 // Anywhere in the pane.
40 const tab = TAB_ORDER.indexOf(ctx.mode)
41 const step = (d: number) => TAB_ORDER[(tab + d + TAB_ORDER.length) % TAB_ORDER.length] ?? 'review'
42 if (k.key === ']' || (k.shift && k.key === 'right')) return { type: 'tab', mode: step(1) }
43 if (k.key === '[' || (k.shift && k.key === 'left')) return { type: 'tab', mode: step(-1) }
44 if (k.key === '?' || k.key === 'h') return { type: 'help' }
45 if (k.key === 'u') return { type: 'undo' }
46 if (!ctx.hasCard) return undefined
47
48 if (ctx.mode === 'review') {
49 if (!ctx.isRevealed) {
50 if (isConfirm(k.key) || k.key === 's' || k.key === 'l') return { type: 'reveal' }
51 if (k.key === 'right' || k.key === 'n') return { type: 'skip' }
52 return undefined
53 }
54 const digit = GRADE_DIGITS[k.key]
55 if (digit) return { type: 'grade', grade: digit }
56 // i j k l sit like the arrows: i ↑, j ←, k ↓, l →.
57 // Arrows and i j k l move the highlight across the grades; 1-4 and enter grade.
58 if (['left', 'up', 'j', 'i'].includes(k.key)) return { type: 'ring', step: -1 }
59 if (['right', 'down', 'l', 'k'].includes(k.key)) return { type: 'ring', step: 1 }
60 if (isConfirm(k.key)) return { type: 'grade', grade: 'good' }
61 return undefined
62 }
63
64 if (ctx.mode === 'quiz') {
65 if (ctx.isRevealed) {
66 return isConfirm(k.key) || k.key === 'right' || k.key === 'l' || k.key === 'n' ? { type: 'next' } : undefined
67 }
68 const last = ctx.choices - 1
69 if (k.key === 'up' || k.key === 'i') return { type: 'cursor', index: Math.max(0, ctx.cursor - 1) }
70 if (k.key === 'down' || k.key === 'k') return { type: 'cursor', index: Math.min(last, ctx.cursor + 1) }
71 if (isConfirm(k.key) || k.key === 'l') return { type: 'choose', index: ctx.cursor }
72 const direct = CHOICE_KEYS.indexOf(k.key) >= 0 ? CHOICE_KEYS.indexOf(k.key) : Number(k.key) - 1
73 if (Number.isInteger(direct) && direct >= 0 && direct <= last) return { type: 'choose', index: direct }
74 if (k.key === 'right' || k.key === 'n') return { type: 'skip' }
75 }
76 return undefined
77}
78
79/**
80 * The element that should hold the pane's focus ring now, so Enter does the
81 * obvious thing and the arrows start from it; undefined when nothing to act on.
82 */
83export function mainElement(ctx: KeyContext): string | undefined {
84 if (!ctx.hasCard) return undefined
85 if (ctx.mode === 'review') return ctx.isRevealed ? 'grade-good' : 'reveal'
86 if (ctx.mode === 'quiz') return ctx.isRevealed ? 'next' : `choice-${ctx.cursor}`
87 return undefined
88}
89
90/** What i j k l do right now, as the arrows they stand in for; absent keys do nothing. */
91export type ArrowMap = Partial<Record<'i' | 'j' | 'k' | 'l', string>>
92
93const ARROW: Record<keyof ArrowMap, string> = { i: '↑', j: '←', k: '↓', l: '→' }
94
95/** Shown in every state, so the keys are always in view. */
96export function arrowMap(ctx: KeyContext): ArrowMap {
97 // Other tabs, and a study tab with nothing due: the keys walk the highlight.
98 if ((ctx.mode !== 'review' && ctx.mode !== 'quiz') || !ctx.hasCard) return { i: '', j: '', k: '', l: '' }
99 if (ctx.mode === 'review') return ctx.isRevealed ? { i: '', j: '', k: '', l: '' } : { l: 'show' }
100 return ctx.isRevealed ? { l: 'next' } : { i: 'up', k: 'down', l: 'choose' }
101}
102
103/** One key's cell in the map: `i: ↑ easy`, or `j: ←` with no word. */
104export function arrowCell(key: keyof ArrowMap, word: string | undefined): string {
105 return word === undefined ? '' : `${key}: ${ARROW[key]}${word === '' ? '' : ` ${word}`}`
106}
107
108/**
109 * The map as two lines in the shape of the keys, an inverted T: `i` above `k`,
110 * `j k l` below. The top line is padded so `i` sits over `k`.
111 */
112export function arrowLines(map: ArrowMap): [string, string] {
113 const left = arrowCell('j', map.j)
114 const bottom = [left, arrowCell('k', map.k), arrowCell('l', map.l)].filter(c => c !== '').join(' ')
115 const over = map.k === undefined ? 0 : left === '' ? 0 : left.length + 2
116 return [' '.repeat(over) + arrowCell('i', map.i), bottom]
117}
118
119/**
120 * The hint beside the map: the keys that are not i j k l. It describes the
121 * pane's focus ring (Enter presses); space needs the ⌨ strip clicked.
122 */
123export function keyHint(ctx: KeyContext): string {
124 if (ctx.mode === 'learn') return 'enter: next, adds it to review'
125 if (ctx.mode !== 'review' && ctx.mode !== 'quiz') return 'enter selects · esc closes'
126 if (!ctx.hasCard) return 'enter selects · esc closes'
127 if (ctx.mode === 'review') return ctx.isRevealed ? 'enter grades · 1 again 2 hard 3 good 4 easy' : 's or enter reveals the answer'
128 return ctx.isRevealed ? 'enter: next card' : 'a–d pick · enter chooses'
129}
130
131export const HELP_LINES: [string, string][] = [
132 ['s', 'reveal the answer'],
133 ['j l i k', 'move across the grades (← → ↑ ↓)'],
134 ['1 2 3 4', 'grade again · hard · good · easy'],
135 ['enter', 'grade the highlighted one'],
136 ['i k', 'move up / down the quiz choices'],
137 ['l', 'choose the quiz choice / next'],
138 ['a–d', 'pick a quiz choice'],
139 ['n', 'skip a card'],
140 ['enter', 'press the highlighted button'],
141 ['u', 'undo the last grade'],
142 ['d', 'drop a bad card for good'],
143 ['z', 'simplify the lesson shown'],
144 ['t', 'teach me: a lesson on the card shown'],
145 ['w', 'explain the word you selected'],
146 ['r q p m o', 'review · quiz · insights · more · settings'],
147 ['h', 'show or hide this list'],
148 ['x', 'close the pane'],
149 ['i j k l', 'move the highlight on insights, more, settings'],
150 ['esc', 'close the pane (/study opens it again)'],
151 ['click ⌨', 'arrows too, if no keybinding takes them'],
152]
153
154const FOCUSABLE = new Set(['Button', 'Input', 'Select'])
155
156/** The keys of the focusable elements in a drawn tree, in draw order: the order the ring walks. */
157export function focusOrder(tree: unknown): string[] {
158 const keys: string[] = []
159 const walk = (node: unknown) => {
160 if (Array.isArray(node)) return node.forEach(walk)
161 if (node === null || typeof node !== 'object') return
162 const el = node as { type?: unknown; props?: { key?: unknown }; children?: unknown }
163 if (typeof el.type === 'string' && FOCUSABLE.has(el.type) && typeof el.props?.key === 'string') {
164 keys.push(el.props.key)
165 }
166 walk(el.children)
167 }
168 walk(tree)
169 return keys
170}
171
172/**
173 * Where i j k l move the highlight outside the card tabs: back (i, j) or
174 * forward (k, l) through `order`, stopping at the ends. Nothing focused yet
175 * starts at the first element going forward, the last going back.
176 */
177export function ringStep(order: string[], current: string | undefined, step: -1 | 1): string | undefined {
178 if (order.length === 0) return undefined
179 const at = current === undefined ? -1 : order.indexOf(current)
180 if (at < 0) return step > 0 ? order[0] : order[order.length - 1]
181 return order[Math.max(0, Math.min(order.length - 1, at + step))]
182}
183hooks/layout.ts 29 lines1import type { Mode } from '../types'
2
3/** Below this many body columns the pane draws its compact layout. */
4export const ROOMY_FROM = 44
5
6export type Density = 'compact' | 'roomy'
7
8export function density(bodyColumns: number | undefined): Density {
9 return (bodyColumns ?? 40) >= ROOMY_FROM ? 'roomy' : 'compact'
10}
11
12export const TAB_LABELS: Record<Density, Record<Mode, string>> = {
13 roomy: { welcome: 'welcome', learn: 'learn', review: 'review', quiz: 'quiz', progress: 'insights', add: 'more', settings: 'settings' },
14 compact: { welcome: 'welcome', learn: 'learn', review: 'rev', quiz: 'quiz', progress: 'ins', add: 'more', settings: 'set' },
15}
16
17/** Mastery color: red while weak, yellow while growing, green once held. */
18export function masteryColor(mastery: number): string {
19 return mastery < 0.3 ? 'red' : mastery < 0.7 ? 'yellow' : 'green'
20}
21
22/** `███░░░` at `cells` wide. */
23export function bar(fraction: number, cells: number): { filled: string; empty: string } {
24 const n = Math.round(Math.max(0, Math.min(1, fraction)) * cells)
25 return { filled: '█'.repeat(n), empty: '░'.repeat(cells - n) }
26}
27
28export const pct = (x: number | null) => (x === null ? '–' : `${Math.round(x * 100)}%`)
29hooks/constants.ts 103 lines1import type {
2 Algorithm,
3 Retention,
4 AutoPeriod,
5 ChatModel,
6 Focus,
7 InsightPace,
8 InterestModel,
9 Mode,
10 StudySettings,
11} from '../types'
12
13export const TITLE = 'maxlearn'
14/** Mine the chat for new concepts every this many finished turns. */
15export const CHAT_EVERY_TURNS = 3
16export const MAX_DECK = 500
17export const MAX_REVIEWS = 5000
18/** How much recent chat a non-session model reads, in characters. */
19export const CHAT_CONTEXT_CHARS = 16_000
20/** A topic counts as mastered from here. */
21export const MASTERED = 0.7
22
23export const TAB_KEYS: Record<Mode, string> = { welcome: 'w', learn: 'e', review: 'r', quiz: 'q', progress: 'p', add: 'm', settings: 'o' }
24
25export const CHAT_MODELS: { value: ChatModel; label: string }[] = [
26 { value: 'session', label: 'session model (cached)' },
27 { value: 'opus', label: 'opus' },
28 { value: 'sonnet', label: 'sonnet' },
29 { value: 'haiku', label: 'haiku' },
30]
31export const INTEREST_MODELS: { value: InterestModel; label: string }[] = [
32 { value: 'opus', label: 'opus' },
33 { value: 'sonnet', label: 'sonnet' },
34 { value: 'haiku', label: 'haiku' },
35]
36export const FOCUS_OPTIONS: { value: Focus; label: string }[] = [
37 { value: 'work', label: 'daily work' },
38 { value: 'balanced', label: 'balanced' },
39 { value: 'interests', label: 'my interests' },
40]
41export const DEFAULT_SETTINGS: StudySettings = {
42 chatModel: 'session',
43 interestModel: 'sonnet',
44 focus: 'balanced',
45 insightPace: '30s',
46 autoPeriod: '24h',
47 autoOnChat: 'on',
48 chatCards: 'on',
49 algorithm: 'sm2',
50 retention: '0.9',
51 proficiency: 'senior',
52}
53
54export const INSIGHT_PACES: { value: InsightPace; label: string; ms: number }[] = [
55 { value: 'off', label: 'off', ms: 0 },
56 { value: '30s', label: 'every 30s', ms: 30_000 },
57 { value: '2m', label: 'every 2m', ms: 120_000 },
58]
59
60const HOUR = 60 * 60 * 1000
61export const AUTO_PERIODS: { value: AutoPeriod; label: string; ms: number }[] = [
62 { value: 'off', label: 'off', ms: 0 },
63 { value: '12h', label: '12h', ms: 12 * HOUR },
64 { value: '24h', label: '24h', ms: 24 * HOUR },
65 { value: '3d', label: '3 days', ms: 72 * HOUR },
66 { value: '7d', label: 'week', ms: 168 * HOUR },
67]
68export const ON_OFF: { value: 'on' | 'off'; label: string }[] = [
69 { value: 'on', label: 'on' },
70 { value: 'off', label: 'off' },
71]
72
73export const ALGORITHMS: { value: Algorithm; label: string }[] = [
74 { value: 'sm2', label: 'SM-2' },
75 { value: 'fsrs', label: 'FSRS' },
76]
77export const RETENTIONS: { value: Retention; label: string }[] = [
78 { value: '0.85', label: '85%' },
79 { value: '0.9', label: '90%' },
80 { value: '0.95', label: '95%' },
81]
82
83export const PROFICIENCIES: { value: StudySettings['proficiency']; label: string; hint: string }[] = [
84 { value: 'intro', label: 'New to this', hint: 'Every term explained, one step at a time.' },
85 { value: 'mid', label: 'Junior / mid', hint: 'Solid basics; learning how things work inside.' },
86 { value: 'senior', label: 'Senior', hint: 'Internals, trade-offs and production failure modes.' },
87 { value: 'staff', label: 'Staff+', hint: 'System-level trade-offs and second-order effects.' },
88]
89
90/** Interests offered on the welcome screen; any other topic can be typed. */
91export const SUGGESTED_INTERESTS = [
92 'system design',
93 'postgres',
94 'redis',
95 'typescript',
96 'react',
97 'distributed systems',
98 'kubernetes',
99 'security',
100 'testing',
101 'git',
102]
103hooks/srs.ts 246 lines1import type { Algorithm, Card, Grade } from '../types'
2import { initialMemory, intervalFor, nextMemory, type Memory } from './fsrs'
3import { isWeakFront } from './prompts'
4
5export const DAY = 24 * 60 * 60 * 1000
6const MATURE_DAYS = 21
7
8export type SchedOptions = { algorithm: Algorithm; retention: number }
9export const SM2: SchedOptions = { algorithm: 'sm2', retention: 0.9 }
10
11/**
12 * Reschedules `card` after the learner graded their recall, with SM-2 or FSRS.
13 * Returns a new card; never mutates the input. `jitter` scales the interval;
14 * left out, it is ±5% so cards made together do not stay due together.
15 */
16export function schedule(card: Card, grade: Grade, now: number, jitter = fuzz(), opts: SchedOptions = SM2): Card {
17 const next = opts.algorithm === 'fsrs' ? scheduleFsrs(card, grade, now, jitter, opts.retention) : scheduleSm2(card, grade, now, jitter)
18 return { ...next, lastReviewAt: now }
19}
20
21/** The card's FSRS memory: its own, else one seeded from its SM-2 interval, else none yet. */
22function memoryOf(card: Card): Memory | undefined {
23 if (card.stability !== undefined && card.difficulty !== undefined) {
24 return { stability: card.stability, difficulty: card.difficulty }
25 }
26 if (card.intervalDays > 0) return { stability: card.intervalDays, difficulty: initialMemory('good').difficulty }
27 return undefined
28}
29
30function scheduleFsrs(card: Card, grade: Grade, now: number, jitter: number, retention: number): Card {
31 const before = memoryOf(card)
32 const last = card.lastReviewAt ?? card.due - card.intervalDays * DAY
33 const m = before === undefined ? initialMemory(grade) : nextMemory(before, grade, (now - last) / DAY)
34 const base = { ...card, stability: m.stability, difficulty: m.difficulty }
35 if (grade === 'again') {
36 return { ...base, reps: 0, lapses: card.lapses + 1, intervalDays: 0, due: now + RELEARN_MS }
37 }
38 const intervalDays = Math.max(1, Math.round(intervalFor(m.stability, retention) * jitter))
39 return { ...base, reps: card.reps + 1, intervalDays, due: now + intervalDays * DAY }
40}
41
42function scheduleSm2(card: Card, grade: Grade, now: number, jitter: number): Card {
43 if (grade === 'again') {
44 // A lapse: relearn soon, in this session if possible.
45 const ease = Math.max(MIN_EASE, card.ease - 0.2)
46 return { ...card, ease, reps: 0, lapses: card.lapses + 1, intervalDays: 0, due: now + RELEARN_MS }
47 }
48
49 const ease = Math.max(MIN_EASE, card.ease + EASE_DELTA[grade])
50 const base =
51 card.reps === 0 ? 1 : card.reps === 1 ? 3 : Math.max(1, card.intervalDays) * card.ease
52 const intervalDays = Math.max(1, Math.round(base * GRADE_FACTOR[grade] * jitter))
53
54 return { ...card, ease, reps: card.reps + 1, intervalDays, due: now + intervalDays * DAY }
55}
56
57const MIN_EASE = 1.3
58const RELEARN_MS = 10 * 60 * 1000
59const EASE_DELTA: Record<Exclude<Grade, 'again'>, number> = { hard: -0.15, good: 0, easy: 0.15 }
60const GRADE_FACTOR: Record<Exclude<Grade, 'again'>, number> = { hard: 0.6, good: 1, easy: 1.3 }
61
62function fuzz(): number {
63 return 0.95 + Math.random() * 0.1
64}
65
66/** How long until `card` comes back if graded `grade` at `now`: the schedule without its jitter. */
67export function previewInterval(card: Card, grade: Grade, now = 0, opts: SchedOptions = SM2): number {
68 return schedule(card, grade, now, 1, opts).due - now
69}
70
71/** `10m`, `3h`, `4d`, `2mo`: a short wait for a button label. */
72export function shortWait(ms: number): string {
73 const minutes = Math.round(ms / 60_000)
74 if (minutes < 60) return `${Math.max(1, minutes)}m`
75 const hours = Math.round(minutes / 60)
76 if (hours < 24) return `${hours}h`
77 const days = Math.round(hours / 24)
78 return days < 60 ? `${days}d` : `${Math.round(days / 30)}mo`
79}
80
81/** A card nobody has seen the answer of yet: it is taught on the learn screen before it is tested. */
82export function isToLearn(c: Card): boolean {
83 return c.reps === 0 && c.learnedAt === undefined
84}
85
86/**
87 * Cards due now: topics in `boost` first, then most overdue first. Never makes
88 * a card due early, and never tests a card whose answer was not shown yet.
89 */
90export function dueCards(deck: Card[], now: number, boost?: ReadonlySet<string>): Card[] {
91 const rank = (c: Card) => (boost?.has(c.topic) ? 0 : 1)
92 return deck.filter(c => c.due <= now && !isToLearn(c)).sort((a, b) => rank(a) - rank(b) || a.due - b.due)
93}
94
95const TOPIC_ALIASES: Record<string, string> = {
96 postgresql: 'postgres',
97 pg: 'postgres',
98 ts: 'typescript',
99 js: 'javascript',
100 k8s: 'kubernetes',
101 'distributed system': 'distributed systems',
102}
103
104/** One spelling per topic, so "PostgreSQL" and "postgres" count together. */
105export function normTopic(topic: string): string {
106 const key = topic.trim().toLowerCase().replace(/\s+/g, ' ')
107 return TOPIC_ALIASES[key] ?? (key || 'general')
108}
109
110/** Records a recall attempt for the proficiency counters, then reschedules. */
111export function grade(card: Card, g: Grade, now: number, opts: SchedOptions = SM2): Card {
112 const isCorrect = g !== 'again'
113 return schedule(
114 { ...card, seen: card.seen + 1, correct: card.correct + (isCorrect ? 1 : 0) },
115 g,
116 now,
117 undefined,
118 opts,
119 )
120}
121
122export type TopicStats = {
123 topic: string
124 cards: number
125 due: number
126 /** 0..1: how far the topic's cards are toward a mature (21-day) interval. */
127 mastery: number
128 /** 0..1, or null before any attempt. */
129 accuracy: number | null
130}
131
132export function topicStats(deck: Card[], now: number): TopicStats[] {
133 const byTopic = new Map<string, Card[]>()
134 for (const c of deck) byTopic.set(c.topic, [...(byTopic.get(c.topic) ?? []), c])
135
136 return [...byTopic.entries()]
137 .map(([topic, cards]) => {
138 const seen = cards.reduce((n, c) => n + c.seen, 0)
139 const correct = cards.reduce((n, c) => n + c.correct, 0)
140 return {
141 topic,
142 cards: cards.length,
143 due: cards.filter(c => c.due <= now).length,
144 mastery:
145 cards.reduce((n, c) => n + Math.min(1, c.intervalDays / MATURE_DAYS), 0) /
146 cards.length,
147 accuracy: seen === 0 ? null : correct / seen,
148 }
149 })
150 .sort((a, b) => a.mastery - b.mastery)
151}
152
153type RawCard = {
154 topic?: unknown
155 front?: unknown
156 back?: unknown
157 choices?: unknown
158 answer?: unknown
159}
160
161/** Parses the model's JSON reply into new cards, dropping malformed ones and duplicates. */
162export function parseCards(
163 text: string,
164 existing: Card[],
165 source: Card['source'],
166 now: number,
167): Card[] {
168 const start = text.indexOf('[')
169 const end = text.lastIndexOf(']')
170 if (start < 0 || end <= start) return []
171
172 let raw: unknown
173 try {
174 raw = JSON.parse(text.slice(start, end + 1))
175 } catch {
176 return []
177 }
178 if (!Array.isArray(raw)) return []
179 return toCards(raw, existing, source, now)
180}
181
182/**
183 * Parses a chat reply: `{ "topics": [...], "cards": [...] }`, or a bare card array
184 * (older replies, and the interest path).
185 */
186export function parseChatReply(
187 text: string,
188 existing: Card[],
189 now: number,
190): { topics: string[]; cards: Card[] } {
191 const start = text.indexOf('{')
192 const end = text.lastIndexOf('}')
193 const isObject = start >= 0 && end > start && (text.indexOf('[') < 0 || start < text.indexOf('['))
194 if (!isObject) return { topics: [], cards: parseCards(text, existing, 'chat', now) }
195
196 let raw: { topics?: unknown; cards?: unknown }
197 try {
198 raw = JSON.parse(text.slice(start, end + 1))
199 } catch {
200 return { topics: [], cards: [] }
201 }
202 const topics = Array.isArray(raw.topics)
203 ? [...new Set(raw.topics.filter((t): t is string => typeof t === 'string').map(normTopic))].slice(0, 5)
204 : []
205 const cards = Array.isArray(raw.cards) ? toCards(raw.cards, existing, 'chat', now) : []
206 return { topics, cards }
207}
208
209export function toCards(raw: unknown[], existing: Card[], source: Card['source'], now: number): Card[] {
210 const seenFronts = new Set(existing.map(c => c.front.trim().toLowerCase()))
211 const cards: Card[] = []
212 for (const r of raw as RawCard[]) {
213 if (typeof r.front !== 'string' || typeof r.back !== 'string') continue
214 // A generic question tests nothing; keep the deck to cards worth reviewing.
215 if (isWeakFront(r.front)) continue
216 const key = r.front.trim().toLowerCase()
217 if (seenFronts.has(key)) continue
218 seenFronts.add(key)
219
220 const choices = Array.isArray(r.choices)
221 ? r.choices.filter((x): x is string => typeof x === 'string').slice(0, 4)
222 : []
223 const answer = typeof r.answer === 'number' ? r.answer : -1
224 const hasQuiz = choices.length >= 2 && answer >= 0 && answer < choices.length
225
226 cards.push({
227 id: `${now.toString(36)}-${cards.length}-${Math.random().toString(36).slice(2, 6)}`,
228 topic: typeof r.topic === 'string' ? normTopic(r.topic) : 'general',
229 front: r.front.trim(),
230 back: r.back.trim(),
231 choices: hasQuiz ? choices : [],
232 answer: hasQuiz ? answer : -1,
233 source,
234 createdAt: now,
235 ease: 2.5,
236 intervalDays: 0,
237 reps: 0,
238 lapses: 0,
239 due: now,
240 seen: 0,
241 correct: 0,
242 })
243 }
244 return cards
245}
246hooks/auto.ts 46 lines1/**
2 * When interest cards are made without being asked: the chat touched the
3 * interest, or its period ran out. Pure: the hooks module owns the timers.
4 */
5
6/** When each interest last got cards, by any route: `{ redis: 1730000000000 }`. */
7export type AutoAt = Record<string, number>
8
9/** How long ago `topic` last got cards; never is forever. */
10const since = (autoAt: AutoAt, topic: string, now: number) =>
11 autoAt[topic] === undefined ? Infinity : now - autoAt[topic]
12
13/** A chat that keeps touching an interest makes cards for it at most this often. */
14export const CHAT_COOLDOWN_MS = 4 * 60 * 60 * 1000
15
16/** How often the period is checked while a session runs. */
17export const AUTO_CHECK_MS = 15 * 60 * 1000
18
19/** Saved interests the chat touched that may get cards now, in the chat's order. */
20export function chatMatches(topics: string[], interests: string[], autoAt: AutoAt, now: number): string[] {
21 const saved = new Set(interests)
22 return topics.filter(t => saved.has(t) && since(autoAt, t, now) >= CHAT_COOLDOWN_MS)
23}
24
25/**
26 * The one interest whose period ran out longest ago, or none. One per check,
27 * so many interests spread their model calls out instead of firing at once.
28 */
29export function periodDue(interests: string[], autoAt: AutoAt, now: number, periodMs: number): string | undefined {
30 if (periodMs <= 0) return undefined
31 return interests
32 .filter(t => since(autoAt, t, now) >= periodMs)
33 // Two topics that never had cards compare as Infinity - Infinity: NaN, read as a tie.
34 .sort((a, b) => since(autoAt, b, now) - since(autoAt, a, now) || 0)[0]
35}
36
37/**
38 * Stamps interests the record has never seen with `now`, so turning the
39 * feature on (or a reload of an older store) starts their period instead of
40 * firing them all at once.
41 */
42export function seedAutoAt(interests: string[], autoAt: AutoAt, now: number): AutoAt {
43 const missing = interests.filter(t => autoAt[t] === undefined)
44 return missing.length === 0 ? autoAt : { ...autoAt, ...Object.fromEntries(missing.map(t => [t, now])) }
45}
46hooks/gate.ts 79 lines1/**
2 * The learning gate: before the chat check writes cards, one cheap call asks
3 * whether the turns since the last check taught anything, or only executed
4 * (ran commands, renamed, committed, formatted). Pure: no `$` here.
5 */
6
7import { normTopic } from './srs'
8
9/** How much of the recent turns the gate reads, newest kept. */
10export const GATE_CHARS = 6000
11
12export type GateRow = {
13 role: 'user' | 'assistant'
14 text: string
15 toolUses?: { tool: string; input: Record<string, unknown> }[]
16}
17
18export type GateVerdict = { kind: 'learning' | 'execution'; topics: string[] }
19
20/** A tool use in a few words: `Bash(git push origin main)`, `Edit(src/app.ts)`. */
21function toolLine(t: { tool: string; input: Record<string, unknown> }): string {
22 const arg = t.input.command ?? t.input.file_path ?? t.input.path ?? t.input.pattern ?? t.input.url ?? ''
23 const short = String(arg).replace(/\s+/g, ' ').slice(0, 60)
24 return short === '' ? t.tool : `${t.tool}(${short})`
25}
26
27/** The turns as plain lines, tools named, cut to `max` characters with the newest kept. */
28export function gateDigest(rows: GateRow[], max = GATE_CHARS): string {
29 const lines: string[] = []
30 let size = 0
31 for (const row of [...rows].reverse()) {
32 const tools = (row.toolUses ?? []).map(toolLine)
33 const text = row.text.replace(/\s+/g, ' ').trim()
34 if (text === '' && tools.length === 0) continue
35 const line = `${row.role}: ${text}${tools.length > 0 ? ` [tools: ${tools.join(', ')}]` : ''}`
36 if (size + line.length > max) {
37 if (lines.length === 0) lines.unshift(line.slice(line.length - max))
38 break
39 }
40 lines.unshift(line)
41 size += line.length
42 }
43 return lines.join('\n')
44}
45
46export function gatePrompt(digest: string): string {
47 return `Classify the recent work in a coding chat for a study app.
48
49"learning": the chat explains or works out something a software engineer can reuse later: how a system works, why a fix works, a design trade-off, a language or API behavior, a debugging insight.
50"execution": the chat only does tasks: runs commands, renames or moves files, edits config, commits, pushes, installs, formats, or repeats known steps. No reusable idea is explained.
51If both happen, choose "learning".
52
53Also name up to 5 short engineering topics the work touched (for example "postgres", "react hooks", "git"), even for execution.
54
55Reply with ONLY JSON: {"kind": "learning" | "execution", "topics": ["..."]}
56
57<chat>
58${digest}
59</chat>`
60}
61
62/** The gate's reply; undefined when it cannot be read, so the caller fails open. */
63export function parseGate(text: string): GateVerdict | undefined {
64 const start = text.indexOf('{')
65 const end = text.lastIndexOf('}')
66 if (start < 0 || end <= start) return undefined
67 let raw: { kind?: unknown; topics?: unknown }
68 try {
69 raw = JSON.parse(text.slice(start, end + 1))
70 } catch {
71 return undefined
72 }
73 if (raw.kind !== 'learning' && raw.kind !== 'execution') return undefined
74 const topics = Array.isArray(raw.topics)
75 ? [...new Set(raw.topics.filter((t): t is string => typeof t === 'string').map(normTopic))].slice(0, 5)
76 : []
77 return { kind: raw.kind, topics }
78}
79hooks/insight.ts 55 lines1import type { Card } from '../types'
2
3/** Longest takeaway on its own, in characters. */
4export const INSIGHT_CHARS = 90
5/** The whole status line's budget: short enough to sit beside the engine's own notices. */
6export const STATUS_CHARS = 72
7/** Below this much room a takeaway says too little; leave it out. */
8const MIN_TAKEAWAY = 18
9
10/**
11 * Cards whose answer may be shown passively: reviewed at least once and not
12 * due, so seeing the answer reinforces it without spoiling a recall test.
13 */
14export function insightCards(deck: Card[], now: number): Card[] {
15 return deck.filter(c => c.reps > 0 && c.due > now)
16}
17
18/**
19 * The answer's first sentence within `max` characters: whole when it fits,
20 * else up to a clause break (, ; : —) that keeps most of the room, else cut on a word.
21 */
22export function takeaway(back: string, max = INSIGHT_CHARS): string {
23 const first = (back.match(/^.*?[.!?](\s|$)/)?.[0] ?? back).trim()
24 if (first.length <= max) return first
25 const room = first.slice(0, max - 1)
26 const clause = Math.max(...[',', ';', ':', ' —'].map(mark => room.lastIndexOf(mark)))
27 if (clause >= max * 0.6) return `${room.slice(0, clause).trim()}…`
28 return `${room.slice(0, Math.max(room.lastIndexOf(' '), max / 2)).trim()}…`
29}
30
31/**
32 * A random card to show, never the one shown last when another exists.
33 * `random` is a number in [0, 1), so tests can pin it.
34 */
35export function pickInsight(deck: Card[], now: number, lastId: string | undefined, random: number): Card | undefined {
36 const pool = insightCards(deck, now)
37 const fresh = pool.length > 1 ? pool.filter(c => c.id !== lastId) : pool
38 return fresh[Math.floor(random * fresh.length)]
39}
40
41export function insightText(card: Card, max = INSIGHT_CHARS): string {
42 const head = `💡 ${card.topic}: `
43 return `${head}${takeaway(card.back, max - head.length)}`
44}
45
46/**
47 * The status line: `prefix`, then the takeaway in whatever room is left of
48 * `max`; the prefix alone when too little is left.
49 */
50export function statusLine(prefix: string, card: Card | undefined, max = STATUS_CHARS): string {
51 const room = max - prefix.length - ' · '.length
52 if (card === undefined || room - `💡 ${card.topic}: `.length < MIN_TAKEAWAY) return prefix
53 return `${prefix} · ${insightText(card, room)}`
54}
55hooks/usage.ts 93 lines1import type { ChatSession, ReviewEvent } from '../types'
2
3/** Chats kept for the chats view and the time totals. */
4export const KEEP_CHATS = 30
5/** One grade or lesson counts at most this long, so a card left open overnight does not. */
6export const MAX_STUDY_MS = 5 * 60 * 1000
7
8/**
9 * Adds `ms` of chat time to this chat, split evenly over the topics it touched;
10 * the chat goes first in the list, which keeps the newest `KEEP_CHATS`.
11 */
12export function attributeChat(
13 chats: ChatSession[],
14 id: string,
15 label: string,
16 topics: string[],
17 ms: number,
18 now: number,
19): ChatSession[] {
20 if (topics.length === 0) return chats
21 const found = chats.find(c => c.id === id)
22 const chat: ChatSession = found
23 ? { ...found, topics: { ...found.topics } }
24 : { id, label, startedAt: now, lastAt: now, topics: {} }
25 chat.lastAt = now
26 const share = Math.max(0, ms) / topics.length
27 for (const topic of topics) chat.topics[topic] = (chat.topics[topic] ?? 0) + share
28 return [chat, ...chats.filter(c => c.id !== id)].slice(0, KEEP_CHATS)
29}
30
31export type TopicUsage = {
32 topic: string
33 /** Chat time on the topic, over the kept chats. */
34 chatMs: number
35 /** How many of the kept chats touched it. */
36 chats: number
37 /** Time grading its cards (reviews and quizzes). */
38 reviewMs: number
39 /** Time reading its lessons. */
40 learnMs: number
41}
42
43/** Where your time went, per topic: most total time first. */
44export function topicUsage(
45 chats: ChatSession[],
46 reviews: ReviewEvent[],
47 learnTime: Record<string, number>,
48): TopicUsage[] {
49 const by = new Map<string, TopicUsage>()
50 const at = (topic: string) => {
51 let u = by.get(topic)
52 if (u === undefined) {
53 u = { topic, chatMs: 0, chats: 0, reviewMs: 0, learnMs: 0 }
54 by.set(topic, u)
55 }
56 return u
57 }
58 for (const chat of chats) {
59 for (const [topic, ms] of Object.entries(chat.topics)) {
60 const u = at(topic)
61 u.chatMs += ms
62 u.chats += 1
63 }
64 }
65 for (const r of reviews) if (r.ms) at(r.topic).reviewMs += r.ms
66 for (const [topic, ms] of Object.entries(learnTime)) at(topic).learnMs += ms
67 const total = (u: TopicUsage) => u.chatMs + u.reviewMs + u.learnMs
68 return [...by.values()].filter(u => total(u) > 0).sort((a, b) => total(b) - total(a))
69}
70
71/** `<1m`, `15m`, `1h 20m`. */
72export function duration(ms: number): string {
73 const minutes = Math.round(ms / 60_000)
74 if (minutes < 1) return '<1m'
75 if (minutes < 60) return `${minutes}m`
76 const rest = minutes % 60
77 return rest === 0 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 60)}h ${rest}m`
78}
79
80/** How long something was on screen, capped so an idle card does not count. */
81export function studyMs(shownAt: number | undefined, now: number): number {
82 return shownAt === undefined ? 0 : Math.min(MAX_STUDY_MS, Math.max(0, now - shownAt))
83}
84
85/** A short name for a chat: its first prompt, cut on a word. */
86export function chatLabel(firstPrompt: string | undefined, folder: string): string {
87 const text = (firstPrompt ?? '').replace(/\s+/g, ' ').trim()
88 if (text === '') return folder
89 if (text.length <= 40) return text
90 const cut = text.slice(0, 39)
91 return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), 20)).trim()}…`
92}
93hooks/lessons.ts 189 lines1import type { Card, LearnFrom, Lesson } from '../types'
2import { isToLearn, normTopic, toCards } from './srs'
3
4/** The first review after learning: soon enough to catch what did not stick. */
5export const FIRST_REVIEW_MS = 10 * 60 * 1000
6
7/** Lessons one model call writes: one to show now, one to keep for the next Next. */
8export const LESSONS_PER_CALL = 2
9
10const CARD_PREFIX = 'card:'
11
12export const isCardLesson = (id: string) => id.startsWith(CARD_PREFIX)
13
14/** A card not learned yet, taught by showing its answer: no model call. */
15export function cardLesson(card: Card): Lesson {
16 return { id: `${CARD_PREFIX}${card.id}`, topic: card.topic, title: card.front, body: card.back, cards: [], createdAt: card.createdAt }
17}
18
19/** The lesson `id` names: a card lesson from the deck, else a generated one. */
20export function findLesson(id: string, deck: Card[], lessons: Lesson[]): Lesson | undefined {
21 if (isCardLesson(id)) {
22 const card = deck.find(c => c.id === id.slice(CARD_PREFIX.length))
23 return card && cardLesson(card)
24 }
25 return lessons.find(l => l.id === id)
26}
27
28/** The oldest card still to learn that no surface shows right now. */
29export function nextCardToLearn(deck: Card[], shown: ReadonlySet<string>): Card | undefined {
30 return deck
31 .filter(c => isToLearn(c) && !shown.has(`${CARD_PREFIX}${c.id}`))
32 .sort((a, b) => a.createdAt - b.createdAt)[0]
33}
34
35/**
36 * The deck once `lesson` is learned: a card lesson's card is marked learned; a
37 * generated lesson's cards join, marked learned. Either way the first review
38 * comes `FIRST_REVIEW_MS` later.
39 */
40export function learn(lesson: Lesson, deck: Card[], now: number): Card[] {
41 const due = now + FIRST_REVIEW_MS
42 if (isCardLesson(lesson.id)) {
43 const id = lesson.id.slice(CARD_PREFIX.length)
44 return deck.map(c => (c.id === id && isToLearn(c) ? { ...c, learnedAt: now, due } : c))
45 }
46 const fronts = new Set(deck.map(c => c.front.trim().toLowerCase()))
47 const fresh = lesson.cards
48 .filter(c => !fronts.has(c.front.trim().toLowerCase()))
49 .map(c => ({ ...c, learnedAt: now, due }))
50 return [...deck, ...fresh]
51}
52
53type RawLesson = { topic?: unknown; title?: unknown; body?: unknown; example?: unknown; terms?: unknown; cards?: unknown }
54
55/** Parses the model's lessons; drops malformed ones and lessons whose title repeats one already known. */
56export function parseLessons(text: string, deck: Card[], known: Lesson[], now: number): Lesson[] {
57 const start = text.indexOf('[')
58 const end = text.lastIndexOf(']')
59 if (start < 0 || end <= start) return []
60 let raw: unknown
61 try {
62 raw = JSON.parse(text.slice(start, end + 1))
63 } catch {
64 return []
65 }
66 if (!Array.isArray(raw)) return []
67
68 const titles = new Set(known.map(l => l.title.trim().toLowerCase()))
69 const lessons: Lesson[] = []
70 for (const [n, r] of (raw as RawLesson[]).entries()) {
71 if (typeof r.title !== 'string' || typeof r.body !== 'string' || typeof r.topic !== 'string') continue
72 if (titles.has(r.title.trim().toLowerCase())) continue
73 titles.add(r.title.trim().toLowerCase())
74 const topic = normTopic(r.topic)
75 const cards = Array.isArray(r.cards)
76 ? toCards(r.cards.map(c => ({ topic, ...(c as object) })), deck, 'interest', now).slice(0, 2)
77 : []
78 lessons.push({
79 id: `l-${now.toString(36)}-${n}-${Math.random().toString(36).slice(2, 6)}`,
80 topic,
81 title: r.title.trim(),
82 body: r.body.trim(),
83 example: typeof r.example === 'string' && r.example.trim() !== '' ? r.example.trim() : undefined,
84 terms: cleanTerms(r.terms),
85 cards,
86 createdAt: now,
87 })
88 }
89 return lessons.slice(0, LESSONS_PER_CALL)
90}
91
92/**
93 * Which topics the next lessons teach: the candidates in their order of need,
94 * those taught least recently first, `count` of them (repeating when short).
95 */
96export function lessonTopics(candidates: string[], recent: Lesson[], count = LESSONS_PER_CALL): string[] {
97 if (candidates.length === 0) return []
98 const lastTaught = (t: string) => {
99 for (let n = recent.length - 1; n >= 0; n--) if (recent[n]!.topic === t) return n
100 return -1
101 }
102 const order = [...candidates].sort((a, b) => lastTaught(a) - lastTaught(b))
103 return Array.from({ length: count }, (_, n) => order[n % order.length]!)
104}
105
106/** A lesson as markdown: the chat row's text where the drawing is not used. */
107export function lessonMarkdown(lesson: Lesson): string {
108 const parts = [`**${lesson.title}**`, lesson.body]
109 if (lesson.example) parts.push(`_Example:_ ${lesson.example}`)
110 return parts.join('\n\n')
111}
112
113/** The longest term Explain takes: a word or a short phrase, not a passage. */
114export const MAX_TERM = 40
115
116/** A term as typed, pasted or selected: trimmed, unquoted, one line; undefined when empty or too long. */
117export function cleanTerm(raw: string): string | undefined {
118 const t = raw
119 .replace(/\s+/g, ' ')
120 .trim()
121 .replace(/^["'`“”‘’(\[]+|["'`“”‘’)\].,;:?!]+$/g, '')
122 .trim()
123 return t === '' || t.length > MAX_TERM ? undefined : t
124}
125
126/** Up to 4 distinct terms from the model's list. */
127function cleanTerms(raw: unknown): string[] | undefined {
128 if (!Array.isArray(raw)) return undefined
129 const seen = new Set<string>()
130 const terms: string[] = []
131 for (const r of raw) {
132 const t = typeof r === 'string' ? cleanTerm(r) : undefined
133 if (t === undefined || seen.has(t.toLowerCase())) continue
134 seen.add(t.toLowerCase())
135 terms.push(t)
136 }
137 return terms.length === 0 ? undefined : terms.slice(0, 4)
138}
139
140/** Adds `term` under `topic`, once, case-insensitively; newest last. */
141export function addSubtopic(map: Record<string, string[]>, topic: string, term: string): Record<string, string[]> {
142 const list = map[topic] ?? []
143 if (list.some(t => t.toLowerCase() === term.toLowerCase())) return map
144 return { ...map, [topic]: [...list, term].slice(-20) }
145}
146
147/** The ask for a lesson that explains `term`, a word a learner met in a lesson on `topic`. */
148export function termPrompt(term: string, topic: string, context: string | undefined): string {
149 return `A learner reading about "${topic}" met the term "${term}" and does not know it.
150Write ONE short lesson that explains "${term}" as it is used in ${topic}:
151- "title": what ${term} is, in one short statement (spell out an abbreviation).
152- "body": 3-5 sentences: what it is, why it exists, and how it connects to ${topic}. Explain every other technical term you use.
153- "example": one concrete case.
154- "terms": up to 3 other terms in your lesson that the learner may not know.
155- "cards": 1 flashcard that tests the main idea.
156${context ? `\nThe lesson where the learner met it:\n${context}\n` : ''}
157Reply with ONLY a JSON array with one lesson:
158[{"topic": "${topic}", "title": "...", "body": "...", "example": "...", "terms": ["..."], "cards": [{"front": "...", "back": "...", "choices": ["...", "...", "...", "..."], "answer": 0}]}]`
159}
160
161/** The ask for a lesson that teaches the idea behind a flashcard or quiz question in depth. */
162export function teachPrompt(card: { topic: string; front: string; back: string; choices: string[] }): string {
163 return `A learner is practising this flashcard on "${card.topic}" and wants to understand the idea behind it.
164
165Question: ${card.front}
166Answer: ${card.back}${card.choices.length > 0 ? `\nQuiz choices: ${card.choices.join(' | ')}` : ''}
167
168Write ONE lesson that teaches the idea in depth:
169- "title": the idea as a short statement.
170- "body": 3-5 sentences: how it works, why it is true, and what goes wrong without it.${card.choices.length > 0 ? ' Say briefly why the wrong choices are wrong.' : ''}
171- "example": one concrete case: a command, a number, a query or a failure.
172- "terms": up to 3 terms in your lesson that the learner may not know.
173- "cards": 1 new flashcard that goes one step further. Do not repeat the question above.
174
175Reply with ONLY a JSON array with one lesson:
176[{"topic": "${card.topic}", "title": "...", "body": "...", "example": "...", "terms": ["..."], "cards": [{"front": "...", "back": "...", "choices": ["...", "...", "...", "..."], "answer": 0}]}]`
177}
178
179/** Where a lesson came from: its card's source for a card lesson, else its own (interest by default). */
180export function lessonSource(lesson: Lesson, deck: Card[]): 'interest' | 'chat' {
181 if (isCardLesson(lesson.id)) return deck.find(c => `${CARD_PREFIX}${c.id}` === lesson.id)?.source ?? 'interest'
182 return lesson.source ?? 'interest'
183}
184
185/** Whether something from `source` shows under the `from` filter. */
186export function fromMatches(source: 'interest' | 'chat', from: LearnFrom | undefined): boolean {
187 return from === undefined || from === 'all' || (from === 'chats' ? source === 'chat' : source === 'interest')
188}
189hooks/prompts.ts 121 lines1/**
2 * What makes a good card, as the model reads it and as the local filter
3 * enforces it. Pure: no `$` here.
4 */
5
6export type Level = 'intro' | 'mid' | 'senior' | 'staff'
7
8const LEVELS: Level[] = ['intro', 'mid', 'senior', 'staff']
9
10export const LEVEL_BRIEF: Record<Level, string> = {
11 intro: 'someone new to the topic: explain each term, one step at a time, with an everyday comparison',
12 mid: 'a mid-level engineer: solid on fundamentals, learning how things work under the hood',
13 senior:
14 'a senior engineer: knows the basics well; wants internals, trade-offs, failure modes and production gotchas',
15 staff:
16 'a staff engineer: wants system-level trade-offs, scaling limits, design decisions and their second-order effects',
17}
18
19/** What the topic's record says about how hard its next cards should be. */
20export type LevelSignal = {
21 cards: number
22 mastery: number
23 /** Recall, recent where known, 0..1, or null before any review. */
24 recall: number | null
25 /** Reviews behind `recall`. */
26 reviews: number
27 /** Times the learner asked for a simpler lesson on this topic. */
28 simplified?: number
29}
30
31/**
32 * Starts at the learner's chosen level (senior unless they picked another);
33 * one step down when recall is poor on enough reviews, one step up once the
34 * topic is mastered and recalled well. Each Simplify steps one further down.
35 */
36export function levelFor(s: LevelSignal | undefined, simplified = s?.simplified ?? 0, start: Level = 'senior'): Level {
37 const step =
38 s === undefined || s.recall === null || s.reviews < 5
39 ? 0
40 : s.recall < 0.6
41 ? -1
42 : s.mastery >= 0.7 && s.recall >= 0.85
43 ? 1
44 : 0
45 const at = LEVELS.indexOf(start) + step - simplified
46 return LEVELS[Math.max(0, Math.min(LEVELS.length - 1, at))]!
47}
48
49/** The ask for a simpler version of a lesson the learner found too hard. */
50export function simplifyPrompt(lesson: { topic: string; title: string; body: string; example?: string }, cards: { front: string; back: string }[]): string {
51 return `A learner found this lesson on "${lesson.topic}" too hard. Rewrite it to teach the SAME idea more simply:
52- Assume less background. Explain every technical term the first time you use it.
53- Use one everyday comparison, then tie it back to the real mechanism.
54- Use shorter sentences: 3-5 of them.
55- Keep it correct: do not leave out the part that makes the idea true.
56Also rewrite its flashcards so they test the simpler lesson.
57
58The lesson:
59Title: ${lesson.title}
60${lesson.body}${lesson.example ? `\nExample: ${lesson.example}` : ''}
61
62Its cards:
63${cards.map(c => `- Q: ${c.front}\n A: ${c.back}`).join('\n') || '(none)'}
64
65Reply with ONLY a JSON array with one lesson:
66[{"topic": "${lesson.topic}", "title": "...", "body": "...", "example": "...", "cards": [{"front": "...", "back": "...", "choices": ["...", "...", "...", "..."], "answer": 0}]}]`
67}
68
69/**
70 * ASD-STE100 Simplified Technical English, as the model can follow it without
71 * the dictionary: every text field of a lesson or card, not only the cards.
72 */
73export const STE_RULES = `Write ALL text ("title", "body", "example", "front", "back", "choices") in ASD-STE100 Simplified Technical English:
74Words
75- Use simple, common words. Use each word with one meaning only (for example, "test" is a check, never an exam).
76- Technical names are allowed: commands, APIs, types, settings, tools and units (lock_timeout, ACCESS EXCLUSIVE, 2s).
77- Use the same word for the same thing each time. Do not use synonyms for variety.
78- Do not use phrasal verbs ("set up", "look into", "fill up"). Use one verb ("configure", "examine", "fill").
79- Do not use "-ing" words as nouns or adjectives, except in technical names.
80- Do not use noun clusters of more than 3 words.
81- Write "to", not "in order to". Write "if", not "in case". Write "can" for ability and "must" for a requirement; do not use "may" or "might" for these.
82Sentences
83- Keep each sentence to 20 words or fewer for an instruction, 25 or fewer for a description.
84- Write one instruction or one idea in each sentence.
85- Use the active voice. Use the simple tenses: present, past and future.
86- Do not leave out "the", "a" or verbs to make text shorter.
87- Write instructions as commands ("Set lock_timeout before the ALTER.").
88- Put a condition before the action it controls ("If the lock is busy, the ALTER waits.").
89- Put a warning or caution before the step it is about.
90Paragraphs
91- Give each paragraph one topic. Keep a paragraph to 6 sentences or fewer.
92- Give numbers with their units, exactly ("10 minutes", "2 s", "8 GB").`
93
94export const CARD_RUBRIC = `What makes a good card (follow all):
95- One idea per card. The answer fits in 1-3 sentences. No lists of more than 3 items.
96- Be specific: name the mechanism, command, setting, data structure, number or failure mode.
97- Ask about why, how or what-happens-when. Do not ask for definitions or "when should you use X".
98- The back gives the answer first, then the reason (the mechanism), then one concrete detail or example.
99- The answer must be checkable: a reader can say if their recall was right or wrong.
100- Quiz choices: one correct, three plausible distractors of similar length; no "all of the above".
101
102Bad: "When should you use Redis instead of a relational database?" (generic, no mechanism, any answer is half right)
103Good: "Why can Redis lose up to one second of writes with appendfsync everysec?"
104 → "Redis calls fsync on the AOF once per second in a background thread. A crash before the next fsync loses the writes still in the OS buffer."
105Bad: "What is a B-tree index?"
106Good: "Why does a Postgres index on (a, b) not help a query that filters only on b?"`
107
108const WEAK_STEMS = [
109 /^what (is|are) (a |an |the )?[\w\s-]{1,30}\?$/i,
110 /^when should (you|i|we) use\b/i,
111 /^what are the (benefits|advantages|disadvantages|pros|cons)\b/i,
112 /^(define|explain|describe) [\w\s-]{1,30}\.?\??$/i,
113 /^why (is|should) [\w\s-]{1,20} (good|useful|important|popular)\b/i,
114]
115
116/** A question too generic to test real recall. */
117export function isWeakFront(front: string): boolean {
118 const q = front.trim()
119 return q.length < 20 || WEAK_STEMS.some(stem => stem.test(q))
120}
121