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

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
6 edits · 4 files · 2 commands · 1m12s. It's drawn like a system notice and never sent to Claude./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".
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.
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.What it sends, and where
$.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.What it reads locally
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.
claude plugin validate .
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test
See PRIVACY.md. meanwhile collects nothing for its developer.
hooks/register.js 691 lines1// 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