SLOPSHOPPER

meanwhile

A small card above the prompt teaches you one thing while Claude works, and turns your day into something worth sharing

newpanebandguardcommandtoast
v0.8.0MITupdated 2026-10-03njp-coder/meanwhile
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · meanwhile
│ ┃ meanwhile ✕ › fix the failing auth test and add an audit log call │ ┃ No lesson yet. │ ● meanwhile: 3 edits · 3 files · 3 commands · 42s │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /meanwhile │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · meanwhile
No lesson yet.
Pane · wrapped
╭──────────────────────────────────────────────────────╮ │ │ │ TODAY IN CLAUDE CODE Oct 9 │ │ │ │ shipped 3 edits · 3 files │ │ Claude working 1m │ │ streak 0 days │ │ │ │ learned while waiting │ │ — │ │ │ │ │ │ last 3 weeks │ │ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ │ │ │ │ c: copy for X l: lesson │ │ │ ╰──────────────────────────────────────────────────────╯
README

meanwhile

Learn one new thing a day while Claude thinks.

A Claude Code mod. Each morning it reads the Hacker News front page and the fastest-rising new GitHub repos, and Haiku picks one concept worth learning, preferring one that fits the stack in your current repo. A small quiet card above your prompt asks one question at a time and gives you about 40 seconds of Claude working to think before the answer appears.

╭ meanwhile · Speculative decoding ─────────────── 2/4 ╮
│ Why can an LLM API get faster without getting dumber? │
│ → A small model guesses ahead; the big one just checks.│
╰───────────────────────────────────────────────────────╯

When you've seen every answer, the card becomes your moment:

✓ You learned Speculative decoding today
🔥 4-day streak · /wrapped to share your day

While you work

  • Above the prompt: today's lesson card. Click Show answer when you're ready, Skip a question you don't care about, then Next. If you leave it alone, the answer appears after 40 seconds of Claude working.
  • After a turn that changed things: one dim line such as 6 edits · 4 files · 2 commands · 1m12s. It's drawn like a system notice and never sent to Claude.

When you ask

  • /meanwhile shows the whole lesson as plain text. Press n for a different topic or c to copy it as a post.
  • /wrapped opens your day as a share card: what you shipped (edits and files), Claude's working time, what you learned, your streak and a three-week strip. Press c to copy a post that leads with what you shipped and teaches your readers today's fact, then screenshot the card.

It makes one Haiku call per day, on your own plan, and the lesson is cached. Showing it costs no tokens. If the news can't be reached, it falls back to a built-in evergreen lesson marked "offline pick".

Install

Needs Claude Code 2.1.287 or later (claude update), with mods switched on for your account. Mods are still rolling out, so if nothing shows up, your account may not have them yet.

In a terminal:

claude plugin marketplace add njp-coder/meanwhile
claude plugin install meanwhile@meanwhile

Or inside Claude Code, type /plugin, add the marketplace njp-coder/meanwhile, and install meanwhile.

Then quit and reopen Claude Code (in the desktop app: ⌘Q) and start a new session. The first question card appears within about 20 seconds.

Commands: /meanwhile shows the whole lesson (n new topic, c copy as a post). /wrapped opens your day as a share card.

Cost: one small Haiku call a day on your own plan, to write the day's questions. Everything else costs no tokens.

What it reaches: the Hacker News and GitHub public APIs once a day, and your current folder's package.json / pyproject.toml / Cargo.toml / go.mod to pick a topic that fits your stack. The daily Haiku call sees only today's headlines and your dependency names, never your code.

What it does on your machine

meanwhile runs no commands and no tools of its own, and it never changes, blocks or rewrites anything Claude does.

Network: two fixed hosts, once a day

  • https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=30: the public Hacker News front page.
  • https://api.github.com/search/repositories?q=created:>DATE&sort=stars&order=desc&per_page=12: the most-starred public repos created in the last 7 days. DATE is today minus 7 days; that date is the only part of the address that changes.
  • It sends nothing to either host beyond those public requests.

What it sends, and where

  • One $.model.complete call a day to Claude Haiku, through your own Claude Code session and plan. It contains the headlines from the two hosts above and the dependency names it read locally (below). Never your source code, prompts or conversation.
  • Nothing is sent anywhere else. There is no server of its own.

What it reads locally

  • In your current folder: package.json (dependency names), and whether pyproject.toml, Cargo.toml or go.mod exists. Used only to prefer a topic that fits your stack.

What each hook does with what it sees

  • session.start: registers /meanwhile and /wrapped and loads today's lesson.
  • turn.start / turn.complete: notes that Claude is working (so the card only moves on then) and adds the turn's duration to today's count. It does not read or keep the conversation text.
  • tool.call: lets every call through unchanged, then counts it. For Edit, Write, MultiEdit and NotebookEdit it keeps a short hash of the file path, only to count distinct files (never the path itself); for Bash it counts one command. It never reads command text or file contents.
  • command.run for /meanwhile and /wrapped: answers only those two commands, by opening their panes. Other commands are not touched.
  • ui.render: draws the card above the prompt and the two panes.

What it stores: today's lesson and daily counts, in Claude Code's own plugin store on your machine. See PRIVACY.md.

Develop

claude plugin validate .
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test

Privacy

See PRIVACY.md. meanwhile collects nothing for its developer.

Source 1 files
hooks/register.js 691 lines
1// meanwhile — learn one new thing a day while Claude thinks.
2//
3// Each morning the mod reads what's new (Hacker News front page, fresh GitHub
4// repos), asks Haiku to pick ONE teachable topic, and splits it into short
5// question-and-answer cards. While Claude works, one dim line under the
6// spinner asks a question, pauses, then shows the answer. /meanwhile shows the whole lesson, quietly.
7
8const LESSON_PANE = 'meanwhile-lesson'
9const WRAPPED_PANE = 'meanwhile-wrapped'
10
11// /wrapped only: it is a share card, so it may use colour. Nothing ambient does.
12const ACCENT = '#D97757'
13const COOL = '#6A9BCC'
14const GOOD = '#7FB069'
15const MUTED = '#8A8A8A'
16// Each card: the question alone for one step, then question → answer for two
17// Each card holds for a minute of Claude working: 40s to think, then the answer
18const STEP_MS = 5000
19const STEPS_PER_CARD = 12
20const ANSWER_FROM = 8
21
22
23// Module state (rebuilt from $.store on every load)
24let lesson = null // today's lesson object
25let generating = false
26let working = false
27let step = 0 // advances only while Claude works
28let seen = new Set() // card indices whose answer was shown today
29let celebrated = false // the "you learned it" card shows once, then the card is gone for the day
30let rotateTimer = null
31let pending = { tools: 0 } // counted between flushes
32let turnWork = { edits: 0, files: new Set(), commands: 0 } // this turn, for the recap line
33
34const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
35
36// Used only if the network or the model is unavailable. Evergreen, not "news".
37const OFFLINE = [
38  {
39    topic: 'Speculative decoding',
40    cards: [
41      { q: 'Why can an LLM API get faster without getting dumber?', a: 'A small model guesses ahead; the big one just checks.' },
42      { q: 'What does the small "draft" model do?', a: 'Cheaply guesses the next 4-8 tokens.' },
43      { q: 'How does the big model check them?', a: 'Scores all the guesses in ONE parallel pass.' },
44      { q: 'Does the output change?', a: 'No. Identical to the big model alone, 2-3x faster.' },
45    ],
46    example: 'draft: "the cat sat on" -> target accepts 3/4 -> 3 tokens for 1 pass',
47    tag: 'AI',
48  },
49  {
50    topic: 'CRDTs',
51    cards: [
52      { q: 'Two people edit the same doc offline. Who wins?', a: 'Nobody has to: CRDT edits merge in any order.' },
53      { q: 'What makes a CRDT merge safely?', a: 'Every edit commutes, so all copies end up identical.' },
54      { q: 'Do CRDTs need a server to decide?', a: 'No locks, no referee. The math guarantees agreement.' },
55      { q: 'Who uses them?', a: 'Figma, Linear and most local-first apps.' },
56    ],
57    example: 'G-Counter: each device keeps its own count; total = sum of maxes.',
58    tag: 'Systems',
59  },
60  {
61    topic: 'Bloom filters',
62    cards: [
63      { q: 'Can a set lookup be wrong and still useful?', a: 'Yes: a Bloom filter says "definitely not" or "maybe".' },
64      { q: 'How does it store items?', a: 'Hash each item k times, flip those k bits on.' },
65      { q: 'When is the answer certain?', a: 'Any of the k bits is 0 means definitely absent.' },
66      { q: 'Why do databases use them?', a: 'To skip disk reads for keys that are not there.' },
67    ],
68    example: '1% false positives costs ~9.6 bits per item, whatever the item size.',
69    tag: 'Algorithms',
70  },
71]
72
73export function register(on) {
74  on('session.start', async ($, e, next) => {
75    await $.command.register({
76      name: 'meanwhile',
77      description: "Open today's lesson (add 'new' for a different topic)",
78      immediate: true,
79    })
80    await $.command.register({
81      name: 'wrapped',
82      description: 'Your day in Claude Code, as a share card',
83      immediate: true,
84    })
85    await loadToday($)
86    // One rotation timer for the whole session; it only advances while Claude works
87    rotateTimer = $.clock.every(STEP_MS, () => {
88      if (!working || !lesson) return
89      step = (step + 1) % (lesson.cards.length * STEPS_PER_CARD)
90      if (step % STEPS_PER_CARD >= ANSWER_FROM) seen.add(Math.floor(step / STEPS_PER_CARD))
91      showCard($)
92    })
93    // Generate in the background so the session starts instantly
94    if (!lesson) $.clock.after(50, () => ensureLesson($, false))
95    showCard($)
96    return next(e)
97  })
98
99  on('turn.start', async ($, e, next) => {
100    working = true
101    if (!e.agentId) {
102      turnWork = { edits: 0, files: new Set(), commands: 0 }
103      // The proud card was on screen until this message; done for today
104      if (lesson && !celebrated && seen.size >= lesson.cards.length) {
105        celebrated = true
106        await markCelebrated($)
107        $.ui.invalidate('ui.render')
108      }
109    }
110    return next(e)
111  })
112
113  on('tool.call', async ($, e, next) => {
114    pending.tools += 1
115    const ran = await next(e)
116    if (ran.deny === undefined && !ran.isError) {
117      if (EDIT_TOOLS.has(e.tool)) {
118        turnWork.edits += 1
119        if (e.file_path || e.notebook_path) turnWork.files.add(fingerprint(e.file_path || e.notebook_path))
120      } else if (e.tool === 'Bash') {
121        turnWork.commands += 1
122      }
123    }
124    return ran
125  })
126
127  on('turn.complete', async ($, e, next) => {
128    // Subagent turns count as tool work, not as your turns
129    if (!e.agentId) {
130      working = false
131      await flushDay($, e)
132      recap($, e)
133      $.ui.invalidate('ui.render')
134    }
135    return next(e)
136  })
137
138  // A small quiet card above the prompt: the question, then its answer
139  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
140    if (!lesson || e.props.hasSurvey || e.props.maxRows < 4) return next(e)
141    const { Box, Text, Button } = $.ui.resolve(e)
142    const frame = (children) =>
143      Box({ flexDirection: 'column', borderStyle: 'round', borderColor: '#8A8A8A', paddingX: 1, width: Math.min(e.props.bodyColumns || 80, 96), children })
144
145    // Every answer seen today: the proud moment, with the way to share it
146    if (seen.size >= lesson.cards.length) {
147      if (celebrated) return next(e)
148      const streak = streakOf(await lastDays($, 21))
149      return frame([
150        Text({ children: ['✓ You learned ' + lesson.topic + ' today'] }),
151        Text({ dimColor: true, children: [(streak > 1 ? '🔥 ' + streak + '-day streak · ' : '') + '/wrapped to share your day'] }),
152      ])
153    }
154
155    const { q, a } = currentCard()
156    const idx = Math.floor(step / STEPS_PER_CARD) % lesson.cards.length
157    const total = lesson.cards.length
158    const reveal = () => {
159      step = idx * STEPS_PER_CARD + ANSWER_FROM
160      seen.add(idx)
161      showCard($)
162    }
163    const goNext = () => {
164      step = ((idx + 1) % total) * STEPS_PER_CARD
165      showCard($)
166    }
167    const buttons = a
168      ? [Button({ key: 'mw-next', label: idx + 1 < total ? 'Next' : 'Start over', plain: true, dimColor: true, onPress: goNext })]
169      : [
170          Button({ key: 'mw-show', label: 'Show answer', plain: true, dimColor: true, onPress: reveal }),
171          Button({ key: 'mw-skip', label: 'Skip', plain: true, dimColor: true, onPress: goNext }),
172        ]
173    return frame([
174      Box({
175        flexDirection: 'row',
176        justifyContent: 'space-between',
177        children: [
178          Text({ dimColor: true, children: ['meanwhile · ' + lesson.topic] }),
179          Text({ dimColor: true, children: [idx + 1 + '/' + total] }),
180        ],
181      }),
182      Text({ wrap: 'wrap', children: [q] }),
183      a ? Text({ dimColor: true, wrap: 'wrap', children: ['→ ' + a] }) : null,
184      Box({ flexDirection: 'row', columnGap: 3, children: buttons }),
185    ].filter(Boolean))
186  })
187
188  on('command.run', { command: 'meanwhile' }, async ($, e) => {
189    if ((e.args || '').trim() === 'new') {
190      $.clock.after(10, () => ensureLesson($, true))
191    } else if (!lesson && !generating) {
192      $.clock.after(10, () => ensureLesson($, false))
193    }
194    await $.ui.open({ id: LESSON_PANE, title: 'meanwhile', focus: true, closeOnEscape: true })
195    return {}
196  })
197
198  // /meanwhile: the whole lesson as plain reading text, no card
199  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
200    if (e.requestId !== LESSON_PANE) return next(e)
201    const { Box, Text, Button, Link } = $.ui.resolve(e)
202    const width = Math.max(30, Math.min(e.props.bodyColumns || 72, 72))
203
204    if (!lesson) {
205      return Text({ dimColor: true, children: [generating ? 'Reading today\'s front page…' : 'No lesson yet.'] })
206    }
207
208    // Reading it counts as learning it
209    lesson.cards.forEach((_, i) => seen.add(i))
210
211    return Box({
212      flexDirection: 'column',
213      width,
214      paddingX: 1,
215      rowGap: 1,
216      children: [
217        Text({ bold: true, children: [lesson.topic] }),
218        Box({
219          flexDirection: 'column',
220          rowGap: 1,
221          children: lesson.cards.map((c, i) =>
222            Box({
223              key: 'c' + i,
224              flexDirection: 'column',
225              children: [Text({ wrap: 'wrap', children: [c.q] }), Text({ dimColor: true, wrap: 'wrap', children: ['→ ' + c.a] })],
226            }),
227          ),
228        }),
229        lesson.example ? Text({ dimColor: true, wrap: 'wrap', children: ['e.g. ' + lesson.example] }) : null,
230        lesson.sourceUrl
231          ? Link({ href: lesson.sourceUrl, label: shortUrl(lesson.sourceUrl) })
232          : Text({ dimColor: true, children: [lesson.offline ? 'offline pick' : ''] }),
233        Box({
234          flexDirection: 'row',
235          columnGap: 3,
236          children: [
237            Button({
238              key: 'new',
239              label: 'new topic',
240              hotkey: 'n',
241              plain: true,
242              dimColor: true,
243              onPress: () => {
244                $.clock.after(10, () => ensureLesson($, true))
245              },
246            }),
247            Button({
248              key: 'copy',
249              label: 'copy',
250              hotkey: 'c',
251              plain: true,
252              dimColor: true,
253              onPress: () => {
254                $.ui.copy({ text: lessonPost(lesson) })
255                $.ui.toast('Copied')
256              },
257            }),
258          ],
259        }),
260      ].filter(Boolean),
261    })
262  })
263
264  on('command.run', { command: 'wrapped' }, async ($) => {
265    await flushDay($, null)
266    await $.ui.open({ id: WRAPPED_PANE, title: 'wrapped', focus: true, closeOnEscape: true })
267    return {}
268  })
269
270  // /wrapped — the share card
271  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
272    if (e.requestId !== WRAPPED_PANE) return next(e)
273    const { Box, Text, Button, Raster } = $.ui.resolve(e)
274    const width = Math.max(30, Math.min(e.props.bodyColumns || 60, 60))
275    const days = await lastDays($, 21)
276    const today = days[days.length - 1]
277    const streak = streakOf(days)
278    const learned = lesson && seen.size >= lesson.cards.length
279
280    const stat = (label, value, color) =>
281      Box({
282        flexDirection: 'row',
283        justifyContent: 'space-between',
284        children: [
285          Text({ color: MUTED, children: [label] }),
286          Text({ color: color || undefined, bold: true, children: [value] }),
287        ],
288      })
289
290    const heat =
291      e.surface === 'terminal'
292        ? Raster({ key: 'heat', columns: days.length * 2, rows: 1, cells: heatCells(days) })
293        : Text({ children: [days.map((d) => (d.learned ? '■' : d.turns ? '▪' : '·')).join(' ')] })
294
295    return Box({
296      flexDirection: 'column',
297      width,
298      borderStyle: 'round',
299      borderColor: ACCENT,
300      paddingX: 2,
301      paddingY: 1,
302      rowGap: 1,
303      children: [
304        Box({
305          flexDirection: 'row',
306          justifyContent: 'space-between',
307          children: [
308            Text({ color: ACCENT, bold: true, children: ['TODAY IN CLAUDE CODE'] }),
309            Text({ color: MUTED, children: [prettyDate(today.date)] }),
310          ],
311        }),
312        Box({
313          flexDirection: 'column',
314          children: [
315            stat('shipped', today.edits + (today.edits === 1 ? ' edit' : ' edits') + ' · ' + today.files + (today.files === 1 ? ' file' : ' files')),
316            stat('Claude working', fmtDuration(today.workMs)),
317            stat('streak', streak + (streak === 1 ? ' day' : ' days') + (streak >= 3 ? ' 🔥' : ''), ACCENT),
318          ],
319        }),
320        Box({
321          flexDirection: 'column',
322          children: [
323            Text({ color: MUTED, children: ['learned while waiting'] }),
324            Text({
325              bold: true,
326              color: learned ? GOOD : undefined,
327              wrap: 'wrap',
328              children: [lesson ? (learned ? '✓ ' : '… ') + lesson.topic : '—'],
329            }),
330            lesson ? Text({ color: COOL, italic: true, wrap: 'wrap', children: [lesson.cards[0].q + '  →  ' + lesson.cards[0].a] }) : Text({ children: [''] }),
331          ],
332        }),
333        Box({
334          flexDirection: 'column',
335          children: [Text({ color: MUTED, children: ['last 3 weeks'] }), heat],
336        }),
337        Box({
338          flexDirection: 'row',
339          columnGap: 3,
340          children: [
341            Button({
342              key: 'copy-wrapped',
343              label: 'copy for X',
344              hotkey: 'c',
345              plain: true,
346              autoFocus: true,
347              onPress: () => {
348                $.ui.copy({ text: wrappedPost(today, streak, lesson, learned) })
349                $.ui.toast('Copied. Screenshot this card and paste the text on X.')
350              },
351            }),
352            Button({
353              key: 'open-lesson',
354              label: 'lesson',
355              hotkey: 'l',
356              plain: true,
357              onPress: async () => {
358                await $.ui.close({ id: WRAPPED_PANE })
359                await $.ui.open({ id: LESSON_PANE, title: 'meanwhile', focus: true, closeOnEscape: true })
360              },
361            }),
362          ],
363        }),
364      ],
365    })
366  })
367}
368
369// ── Lesson ──────────────────────────────────────────────────────────────────
370
371async function loadToday($) {
372  const key = dayKey(await $.clock.now())
373  const saved = await $.store.get('lesson:' + key)
374  // Lessons saved before cards existed are regenerated
375  if (saved && Array.isArray(saved.cards) && saved.cards.length) {
376    lesson = saved
377    step = 0
378  } else {
379    lesson = null
380  }
381  const day = await $.store.get('day:' + key)
382  const sameTopic = !!(day && lesson && day.topic === lesson.topic)
383  seen = new Set(sameTopic && Array.isArray(day.seen) ? day.seen : [])
384  celebrated = sameTopic && !!day.celebrated
385}
386
387async function ensureLesson($, force) {
388  if (generating) return
389  if (lesson && !force) return
390  generating = true
391  $.ui.invalidate('ui.render')
392  const now = await $.clock.now()
393  const key = dayKey(now)
394  const history = (await $.store.get('topics')) || []
395  try {
396    const [news, context] = await Promise.all([fetchNews($, now), projectContext($)])
397    let picked = null
398    if (news.length) picked = await pickLesson($, news, context, history, lesson && force ? lesson.topic : null)
399    if (!picked) picked = offlineLesson(now, history)
400    picked.date = key
401    lesson = picked
402    step = 0
403    seen = new Set()
404    celebrated = false
405    showCard($)
406    await $.store.set('lesson:' + key, lesson)
407    await $.store.set('topics', [lesson.topic, ...history.filter((t) => t !== lesson.topic)].slice(0, 60))
408  } catch (err) {
409    if (!lesson) {
410      lesson = offlineLesson(now, history)
411      lesson.date = key
412    }
413  } finally {
414    generating = false
415    $.ui.invalidate('ui.render')
416  }
417}
418
419async function fetchNews($, now) {
420  const items = []
421  try {
422    const r = await $.http.fetch('https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=30')
423    if (r.ok) {
424      const data = JSON.parse(r.text)
425      for (const h of data.hits || []) {
426        if (!h.title) continue
427        items.push({ src: 'HN', title: h.title, url: h.url || 'https://news.ycombinator.com/item?id=' + h.objectID, score: h.points || 0 })
428      }
429    }
430  } catch {}
431  try {
432    const since = new Date(now - 7 * 864e5).toISOString().slice(0, 10)
433    const r = await $.http.fetch(
434      'https://api.github.com/search/repositories?q=created:%3E' + since + '&sort=stars&order=desc&per_page=12',
435      { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'meanwhile-mod' } },
436    )
437    if (r.ok) {
438      const data = JSON.parse(r.text)
439      for (const repo of data.items || []) {
440        items.push({
441          src: 'GitHub',
442          title: repo.full_name + ': ' + (repo.description || '').slice(0, 140),
443          url: repo.html_url,
444          score: repo.stargazers_count || 0,
445        })
446      }
447    }
448  } catch {}
449  return items
450}
451
452// What the user is building, so a relevant topic can win
453async function projectContext($) {
454  const out = []
455  try {
456    if (await $.fs.exists('package.json')) {
457      const pkg = JSON.parse(await $.fs.read('package.json'))
458      out.push('JS deps: ' + Object.keys({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }).slice(0, 40).join(', '))
459    }
460  } catch {}
461  try {
462    if (await $.fs.exists('pyproject.toml')) out.push('Python project (pyproject.toml)')
463    if (await $.fs.exists('Cargo.toml')) out.push('Rust project (Cargo.toml)')
464    if (await $.fs.exists('go.mod')) out.push('Go project (go.mod)')
465  } catch {}
466  return out.join('\n')
467}
468
469const SYSTEM = `You pick ONE thing a working developer should learn today and teach it as 4 to 6 question-and-answer cards, glanced at under a loading spinner.
470Rules:
471- Choose from the provided headlines. Prefer a concrete technique or mechanism a headline is about (e.g. "speculative decoding", "WebGPU compute shaders") over company news, drama, funding, politics, or vague paper titles.
472- If a headline fits the user's stack, prefer it. Never repeat a topic from the "already learned" list.
473- Card 1's question is the hook: a puzzle the reader already half-wonders about, not "What is X?". Later cards each answer one more "how" or "why".
474- Every card stands alone: someone who sees only that one card still learns a whole fact.
475- q under 60 characters, a under 65 characters. Plain words, concrete nouns and numbers. No jargon the card doesn't explain. No hype.
476- Be accurate. If unsure about a fact, leave it out.
477Reply with JSON only:
478{"topic":"2-5 words","cards":[{"q":"...","a":"..."}],"example":"one concrete line, code or numbers, under 100 chars","tag":"one word","sourceUrl":"the headline url you used"}`
479
480async function pickLesson($, news, context, history, avoid) {
481  const headlines = news
482    .slice(0, 42)
483    .map((n, i) => i + 1 + '. [' + n.src + '] ' + n.title + ' <' + n.url + '>')
484    .join('\n')
485  const prompt =
486    'Headlines today:\n' + headlines +
487    '\n\nUser stack:\n' + (context || 'unknown') +
488    '\n\nAlready learned (do not repeat): ' + [avoid, ...history].filter(Boolean).slice(0, 30).join('; ')
489  const r = await $.model.complete({ model: 'haiku', system: SYSTEM, prompt, maxTokens: 900, timeoutMs: 45000 })
490  const text = typeof r === 'string' ? r : r && r.isAnswered ? r.text : ''
491  return parseLesson(text)
492}
493
494function parseLesson(text) {
495  if (!text) return null
496  const start = text.indexOf('{')
497  const end = text.lastIndexOf('}')
498  if (start < 0 || end <= start) return null
499  try {
500    const j = JSON.parse(text.slice(start, end + 1))
501    const cards = (Array.isArray(j.cards) ? j.cards : [])
502      .map((c) => ({ q: String((c && c.q) || '').trim(), a: String((c && c.a) || '').trim() }))
503      .filter((c) => c.q && c.a)
504      .slice(0, 6)
505    if (!j.topic || cards.length < 3) return null
506    return {
507      topic: String(j.topic).trim(),
508      cards,
509      example: String(j.example || '').trim(),
510      tag: String(j.tag || '').replace(/[^\w-]/g, ''),
511      sourceUrl: /^https?:\/\//.test(j.sourceUrl || '') ? j.sourceUrl : '',
512    }
513  } catch {
514    return null
515  }
516}
517
518function offlineLesson(now, history) {
519  const fresh = OFFLINE.filter((l) => !history.includes(l.topic))
520  const pool = fresh.length ? fresh : OFFLINE
521  const l = pool[Math.floor(now / 864e5) % pool.length]
522  return { ...l, cards: l.cards.map((c) => ({ ...c })), sourceUrl: '', offline: true }
523}
524
525// ── Progress ────────────────────────────────────────────────────────────────
526
527// Read-then-write so parallel sessions add up instead of overwriting
528async function flushDay($, turn) {
529  const key = 'day:' + dayKey(await $.clock.now())
530  const day = (await $.store.get(key)) || { turns: 0, tools: 0, workMs: 0, seen: [] }
531  day.tools += pending.tools
532  pending.tools = 0
533  if (turn) {
534    day.turns += 1
535    day.workMs += Math.max(0, turn.durationMs || 0)
536    day.edits = (day.edits || 0) + turnWork.edits
537    day.files = [...new Set([...(day.files || []), ...turnWork.files])].slice(0, 1000)
538  }
539  // Progress belongs to one topic: a new topic starts from zero
540  if (lesson && day.topic && day.topic !== lesson.topic) {
541    day.seen = []
542    day.learned = false
543    day.celebrated = false
544  }
545  const merged = new Set([...(day.seen || []), ...seen])
546  day.seen = [...merged]
547  if (lesson) {
548    day.topic = lesson.topic
549    day.learned = day.seen.length >= lesson.cards.length
550  }
551  await $.store.set(key, day)
552}
553
554async function markCelebrated($) {
555  const key = 'day:' + dayKey(await $.clock.now())
556  const day = (await $.store.get(key)) || {}
557  day.seen = [...new Set([...(day.topic === lesson.topic ? day.seen || [] : []), ...seen])]
558  day.topic = lesson.topic
559  day.learned = true
560  day.celebrated = true
561  await $.store.set(key, day)
562}
563
564async function lastDays($, n) {
565  const now = await $.clock.now()
566  const out = []
567  for (let i = n - 1; i >= 0; i--) {
568    const date = dayKey(now - i * 864e5)
569    const d = (await $.store.get('day:' + date)) || {}
570    out.push({ date, turns: d.turns || 0, tools: d.tools || 0, workMs: d.workMs || 0, edits: d.edits || 0, files: (d.files || []).length, learned: !!d.learned })
571  }
572  // Today's in-memory progress beats the last flush
573  const t = out[out.length - 1]
574  t.tools += pending.tools
575  if (lesson && seen.size >= lesson.cards.length) t.learned = true
576  return out
577}
578
579// Consecutive days with a lesson finished; today counts once it's done
580function streakOf(days) {
581  let s = 0
582  for (let i = days.length - 1; i >= 0; i--) {
583    if (days[i].learned) s++
584    else if (i === days.length - 1) continue
585    else break
586  }
587  return s
588}
589
590// ── Ambient lines ───────────────────────────────────────────────────────────
591
592// One quiet line in the transcript after a turn that changed something.
593// Drawn like a system notice, never sent to the model.
594function recap($, turn) {
595  if (turn.isAborted) return
596  const w = turnWork
597  if (!w.edits && !w.commands) return
598  const parts = []
599  if (w.edits) parts.push(w.edits + (w.edits === 1 ? ' edit' : ' edits'))
600  if (w.files.size) parts.push(w.files.size + (w.files.size === 1 ? ' file' : ' files'))
601  if (w.commands) parts.push(w.commands + (w.commands === 1 ? ' command' : ' commands'))
602  parts.push(fmtSeconds(turn.durationMs))
603  $.ui.log(parts.join(' · '))
604}
605
606// Today's card sits just above the prompt. It stays put between turns and
607// only moves on while Claude works, so there is time to think.
608function showCard($) {
609  $.ui.invalidate('ui.render')
610}
611
612function currentCard() {
613  const card = lesson.cards[Math.floor(step / STEPS_PER_CARD) % lesson.cards.length]
614  return { q: card.q, a: step % STEPS_PER_CARD >= ANSWER_FROM ? card.a : null }
615}
616
617// ── Formatting ──────────────────────────────────────────────────────────────
618
619function dayKey(ms) {
620  const d = new Date(ms)
621  return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
622}
623
624function prettyDate(key) {
625  const [y, m, d] = (key || '').split('-').map(Number)
626  if (!y) return ''
627  return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][m - 1] + ' ' + d
628}
629
630function fmtDuration(ms) {
631  const m = Math.round((ms || 0) / 60000)
632  if (m < 60) return m + 'm'
633  return Math.floor(m / 60) + 'h ' + String(m % 60).padStart(2, '0') + 'm'
634}
635
636// Files are only counted, so keep a short hash, never the path (paths can hold a username)
637function fingerprint(path) {
638  let h = 2166136261
639  for (let i = 0; i < path.length; i++) h = Math.imul(h ^ path.charCodeAt(i), 16777619)
640  return (h >>> 0).toString(36)
641}
642
643function fmtSeconds(ms) {
644  const s = Math.round((ms || 0) / 1000)
645  return s < 60 ? s + 's' : Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's'
646}
647
648function shortUrl(u) {
649  try {
650    const x = new URL(u)
651    return x.hostname.replace(/^www\./, '') + (x.pathname.length > 1 ? x.pathname.slice(0, 32) : '')
652  } catch {
653    return u
654  }
655}
656
657function lessonPost(l) {
658  return [
659    'TIL while Claude was thinking: ' + l.topic,
660    '',
661    ...l.cards.flatMap((c) => [c.q, '→ ' + c.a, '']),
662    'My Claude Code spinner teaches me one thing a day now. (meanwhile mod)',
663  ].join('\n')
664}
665
666const DEFAULT_COLOR = 0x01000000
667function heatCells(days) {
668  const numbers = []
669  for (const d of days) {
670    const color = d.learned ? 0xd97757 : d.turns ? 0x6a9bcc : 0x4a4a4a
671    numbers.push('■'.codePointAt(0), color, DEFAULT_COLOR, ' '.codePointAt(0), DEFAULT_COLOR, DEFAULT_COLOR)
672  }
673  return new Uint8Array(Uint32Array.from(numbers).buffer).toBase64()
674}
675
676function wrappedPost(today, streak, l, learned) {
677  const card = l && l.cards && l.cards[0]
678  return [
679    'Shipped ' + today.edits + ' edits across ' + today.files + ' files with Claude Code today (' + fmtDuration(today.workMs) + ').',
680    '',
681    l ? (learned ? 'While it worked, I learned ' : "While it worked, I've been learning ") + l.topic + ':' : '',
682    card ? card.q : '',
683    card ? '→ ' + card.a : '',
684    '',
685    streak ? '🔥 ' + streak + '-day learning streak' : '',
686    '(meanwhile, a Claude Code mod)',
687  ]
688    .filter((x, i, a) => x !== '' || a[i - 1] !== '')
689    .join('\n')
690}
691