SLOPSHOPPER

maxlearn

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

newpanerowscommandtoaststatus
v0.5.1MITupdated 2026-10-03yashverma2110/maxlearn
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · maxlearn
│ ┃ maxlearn ✕ › fix the failing auth test and add an audit log call │ ┃ e: learn r: review q: quiz p: insights m: mo │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ Welcome to maxlearn 👋 ⎿ Read 6 lines │ ┃ │ ⏺ Update(src/auth.ts) │ ┃ │ Step 1 of 2. Pick the topics you want to g ⎿ Added 2 lines, removed 1 line │ ┃ │ better at. You can change them later. ⏺ Bash(bun test) │ ┃ │ ⎿ 3 pass, 1 fail │ ┃ │ [ system design ] [ postgres ] [ redis ] [ │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ + : add your own, e.g. kafka ⏎ add │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ Nothing picked yet. │ ┃ │ › /study │ ┃ │ [ Next ] [ Skip ] ⎿ maxlearn: maxlearn opened. │ ┃ ╰─────────────────────────────────────────── │ ┃ ── Shortcuts ─────────────────────────────── │ ┃ i: ↑ │ ┃ j: ← k: ↓ l: → │ ┃ enter selects · esc closes │ ┃ u: undo h: keys x: close │ ┃ 0 due │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · maxlearn
e: learn r: review q: quiz p: insights m: more o: settings ╭─────────────────────────────────────────────────────────── │ Welcome to maxlearn 👋 │ │ Step 1 of 2. Pick the topics you want to get │ better at. You can change them later. │ │ [ system design ] [ postgres ] [ redis ] [ typescript ] [ │ │ + : add your own, e.g. kafka ⏎ add │ │ Nothing picked yet. │ │ [ Next ] [ Skip ] ╰─────────────────────────────────────────────────────────── ── Shortcuts ───────────────────────────────────────── i: ↑ j: ← k: ↓ l: → enter selects · esc closes u: undo h: keys x: close 0 due
README

maxlearn

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.

How it helps

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.

  1. Learn, then recall. New material arrives as a lesson (the idea, why it matters, a concrete example). You read it, press Next, and only then does its flashcard enter your review. You're never tested on something you haven't seen.
  2. Review at the right time. Each card comes back just before you'd forget it, scheduled by FSRS or SM-2.
  3. Grow where it counts. Cards and lessons are aimed at your level per topic, and at the topics you actually use or want to grow in.

Install

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.

Getting started

1. Open the pane

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.

2. Tell it what you want to learn

The first time, a welcome screen asks two things:

  1. Interests: pick topics from the suggestions, or type your own (for example kafka), then Next.
  2. Experience: New to this, Junior / mid, Senior or Staff+. Every topic starts at this level. Then Start learning.

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.

3. Learn

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.

  • Next (enter) adds the lesson's cards to your review. Their first review comes 10 minutes later.
  • Skip (n) moves on without adding anything.
  • Simplify (z) rewrites the lesson in simpler words. Later lessons on that topic start a level lower.
  • Don't know a word? Press one of the New words? chips under the lesson (like 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.

4. Review

When cards are due, the review tab (r) shows them one at a time.

  1. Read the question and recall the answer in your head.
  2. Press s to show the answer.
  3. Grade yourself honestly: 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).

5. Keep going

  • Just work as usual. Every few turns maxlearn checks your chat; if you worked something out, it makes cards from it. Pure command-running makes none.
  • A few minutes a day beats a long session once a week. The status line shows 📚 3 due · 🔥 5, your due cards and streak.
  • Check insights (p) now and then: what to improve, what you're strong in, your recent chats, and where your time goes.
  • Tune it in settings (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.

Features

Teaching from your chats

  • Every few turns, maxlearn reads the conversation and names the engineering topics it touched. Ideas worth keeping long-term become cards; anything specific to one codebase is skipped.
  • Learning gate: a cheap check first asks whether those turns explained anything reusable. Turns that only executed tasks (running commands, renaming, committing, formatting) still count toward your topics and time, but make no cards.
  • /study chat does this right away.

Teaching from your interests

  • /study <topic> adds an interest, such as /study redis or /study system design.
  • Interests get new material automatically: when a chat touches one (at most every 4h), and once per period (default 24h, configurable).

Personalised cards and lessons

  • Level adapts per topic: every topic starts at your chosen experience, steps down a level if your recall is poor, and up a level once you've mastered it. Simplify on a lesson steps that topic down too.
  • Quality rubric: one specific idea per card, about mechanisms, trade-offs and failure modes, never definitions. Generic questions are filtered out, and the model sees your existing cards so it goes deeper instead of repeating.
  • Plain language: cards follow ASD-STE100 Simplified Technical English (short sentences, active voice, one idea each).
  • Focus setting: daily work, balanced or my interests decides which topics come first in review, which weak topics are flagged and what gets suggested.

Learning screen and lessons in chat

  • learn tab: your unseen cards with their answers first (free), then written lessons. Each lesson carries 1–2 cards that test exactly what it taught.
  • /study learn: a lesson card right in the conversation. Next swaps the next lesson into the same row.
  • Low token cost: one model call writes two lessons, one for now and one for your next Next.
  • Explain a term: each lesson lists the terms you may not know. One press writes a short lesson on the term, in the context of its topic, and files it as a subtopic (postgres → DDL, MVCC). Insights → topics lists your subtopics, with + to go deeper.

Review and quiz

  • review: reveal the answer, then grade it again / hard / good / easy. Each button shows when the card will come back (good · 4d).
  • quiz: multiple choice; your answer grades the card.
  • Teach me (t): a lesson on the idea behind any flashcard or quiz question, then back to where you were.
  • Empty states make more: one chip per topic writes 3 flashcards, or 3 quiz questions that are ready immediately (a quiz is a fine first look: you see the answer right after you pick).
  • Corrections: u undoes a grade (and removes it from your stats); d drops a bad card for good.
  • Takeaways: the line under the input shows a short takeaway from a card you've already reviewed. It never shows a card that's due, so it can't give away a test.

Insights

ViewShows
overviewstreak, 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)
topicssubtopics 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
worktopics from the last 7 days of chat, how many days each came up, and +3 cards for topics with too few cards
chatswhich topics came up in which chats, and how long each chat spent on each

/study stats prints the same as text.

Settings

SettingOptions
Chat modelsession model (cached, cheapest), opus, sonnet, haiku
Interest / lesson modelopus, sonnet, haiku
Experiencenew to this, junior / mid, senior, staff+
Focusdaily work, balanced, my interests
SchedulerSM-2, FSRS (with target recall 85% / 90% / 95%)
Auto cardseach interest every off / 12h / 24h / 3 days / week; on chat touch on / off; cards from chat on / off
Takeawaysoff / every 30s / every 2m
Start overdeletes all cards, reviews, lessons, interests and history (asks to confirm), keeps settings, and opens the welcome screen

Spaced repetition: FSRS and SM-2

  • FSRS-4.5 (Free Spaced Repetition Scheduler) models each card's memory as stability and difficulty, then plans the next review for when your chance of recall drops to your target. Cards scheduled by SM-2 carry over when you switch.
  • SM-2 is the classic rule: each good grade multiplies the interval by the card's ease.

Keyboard first

KeysDo
i j k lmove the highlight, like ↑ ← ↓ →
enterpress the highlighted button
sreveal the answer
1 2 3 4grade again / hard / good / easy
a–dpick a quiz choice
n / u / dskip / undo / drop
t / z / wteach me / simplify / explain the selected word
e r q p m olearn, review, quiz, insights, more, settings
h / escshow all keys / close the pane

Commands

CommandDoes
/studyopen the pane
/study welcomethe welcome screen: interests and experience
/study <topic>add an interest and write its first cards
/study learna lesson in the chat
/study review · /study quizopen the pane with the keyboard on it
/study chatmake cards from this chat now
/study stats · /study tipsprogress / tips as text

What it uses

maxlearn runs with the same access as Claude Code, like every mod. Here is what it does with it:

It usesWhenTurn it off
Model calls on your accountEvery 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 Simplifyonly 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 transcriptRead to name topics and write cards; sent nowhere except the model calls abovesettings → Auto cards → Cards from chat → off
Local storageCards, reviews, lessons, settings and time per topic, in Claude Code's plugin storedelete the maxlearn_* file in ~/.claude/plugins/store/

It reads no files and runs no commands. Nothing leaves your machine except the model calls.

Develop

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.

License

MIT

Source 16 files
hooks/register.tsx 2796 lines
1import { 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 lines
1import 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}
257
hooks/keymap.ts 183 lines
1import 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}
183
hooks/layout.ts 29 lines
1import 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)}%`)
29
hooks/constants.ts 103 lines
1import 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]
103
hooks/srs.ts 246 lines
1import 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}
246
hooks/auto.ts 46 lines
1/**
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}
46
hooks/gate.ts 79 lines
1/**
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}
79
hooks/insight.ts 55 lines
1import 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}
55
hooks/usage.ts 93 lines
1import 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}
93
hooks/lessons.ts 189 lines
1import 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}
189
hooks/prompts.ts 121 lines
1/**
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