Capi the capybara teaches you a language above the prompt, one real-world fact per card

A Claude Code mod. Capi the capybara shows a quiz card in the band above your prompt, all the time or only while Claude works (CAPI_SHOW, see Configure). Every card teaches one true real-world fact and one expression in the language you learn (Brazilian Portuguese unless you configure another), with explanations in your own language. Answer with the digit keys (type the digit into the empty prompt) or by clicking.

Capi remembers what you know. Expressions you miss come back sooner, expressions you know come back later, and your history syncs between your Macs through iCloud.
| key | does |
|---|---|
1–4 | answer the quiz, then sim/não: did you know the expression? |
6 | 📐 grammar notes: contractions and the case they do the job of, word order, mood, colloquial forms |
7 | 🔤 every verb in the sentence, one tab per verb: present, perfect, imperfect, future, subjunctive, all persons |
0 | 🇩🇪 the card in your own language (the flag is yours to set) |
8 | 🔊 hear it (macOS voice Luciana) |
9, then 9 again | 🚩 the fact is wrong: it is never used again, and Capi re-checks it |
Click – at the right end of the header to fold the band to that one line, and □ to open it again. The band remembers, also in new sessions.
Click × next to it to hide Capi in this session. Only this session: a new session shows the band again, and /capi show brings it back right away.
With CAPI_PLACE=pane the card sits in a pane beside the conversation (above the prompt where the window is too narrow to dock one). The pane takes the keys once it has focus: click it, or ctrl+x tab. Its own close mark hides Capi for the session, as × does the band, and /capi show opens it again. A pane opened unasked waits on a narrow window; /capi show opens it at any width.
/capi shows level, streak, points, what is due, and how the last sync and card generation went. It answers instantly, also while Claude works. /capi skip drops the current card, and /capi show brings back a band you closed with ×.
You need Claude Code 2.1.287 or later with mods turned on. To check, run claude plugin test in an empty directory: it must not say "turned off".
Install it from seedr or from GitHub. Both take the plugin from this repository. To work on Capi itself, use a clone instead.
npx @danieldeusing/seedr add capi --type plugin --agents claude
This installs Capi as a Claude Code plugin and turns it on, for every project. Add --scope project to turn it on in the current project only. Start a new session: the band appears above the prompt and the first ten cards arrive within a minute or two.
claude plugin marketplace add danieldeusing/capi-cc-mod
claude plugin install capi@capi
Start a new session, as above.
Both ways put the plugin in ~/.claude/plugins/cache/capi/capi/<version>/, and that is the folder your .env goes in (see Configure). A new version installs into a new folder, so keep a copy of your .env.
git clone https://github.com/danieldeusing/capi-cc-mod.git ~/capi-cc-mod
~/.claude/settings.json (keep any other keys you already have there): { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/capi-cc-mod" } }
Several mod folders go in the same value, separated by :. To try it in one session only, start Claude Code with claude --plugin-dir ~/capi-cc-mod.
A session that was already open before step 2 does not see the mod. /reload-plugins picks up changes to a mod that is already loaded, not a new folder, so open a new session.
Install on each Mac the same way. Both Macs read and write the same history in iCloud Drive, under capi/.
Copy .env.example to .env in the plugin folder (the install folder above, or your clone) and change what you like. Git ignores .env. A missing file or key keeps the default shown in .env.example. Capi reads the file when a session starts, so open a new session (or run /reload-plugins) after a change.
| key | what it sets | default | |||||
|---|---|---|---|---|---|---|---|
CAPI_LEARN_LANGUAGE | the language Capi teaches | Brazilian Portuguese | |||||
CAPI_NATIVE_LANGUAGE | your language: explanations, translations and grammar notes | German | |||||
CAPI_NATIVE_FLAG | the icon on the translation button | 🇩🇪 | |||||
CAPI_LEARNER | who you are, finishing the sentence "native German speaker who …"; sets the level of the cards | someone four years in Brazil, about A2/B1 | |||||
CAPI_TOPICS | what the facts on the cards are about, comma separated | Brazil, science and nature, technology, history, everyday life, food, health and fitness | |||||
CAPI_PERSONS | the rows of the conjugation table, separated by `\ | ` | `eu\ | você\ | ele/ela\ | nós\ | vocês` |
CAPI_TENSES | its columns, separated by `\ | ` | `presente\ | pretérito perfeito\ | pretérito imperfeito\ | futuro\ | subjuntivo presente` |
CAPI_SHOW | when the band is there: always, or working for only while Claude works | always | |||||
CAPI_PLACE | where the card is drawn: band above the prompt, or pane, a panel beside the conversation like the terminal and browser in Claude Code Desktop | band | |||||
CAPI_PANE_OPEN | with CAPI_PLACE=pane: auto opens the pane by itself when a session starts (with CAPI_SHOW=working, when a turn starts), manual only with /capi show | auto | |||||
CAPI_NEW_PER_DAY | new expressions a day at most; after that only reviews and bonus cards, and 0 means reviews only | 25 |
An English speaker learning Portuguese, with cards about football and music:
CAPI_NATIVE_LANGUAGE=English
CAPI_NATIVE_FLAG=🇬🇧
CAPI_LEARNER=has just moved to São Paulo and knows a few hundred words
CAPI_TOPICS=football, Brazilian music, food, travel in Brazil
The language settings only change what the model writes. The band's own words (categoria, pergunta, nível, Kanntest du …?) stay Portuguese and German whatever the settings say. The persons and tenses only make sense for a language that has them. The rest is untested beyond Portuguese.
CAPI_NEW_PER_DAY), across all sessions and both Macs. After that, only reviews and bonus cards.Every model call uses claude-opus-5-5 on your own Claude plan, so cards count against your usage like any other request:
| call | effort | when |
|---|---|---|
| ten new cards | high | when fewer than 4 cards are queued; cards leave the queue only when answered |
| 🚩 re-check | xhigh | once per flag |
| 🇩🇪 for an older card | low | once per card, when 🇩🇪 is first pressed on a card made without translations |
| 📐 grammar notes | medium | once per card, when 📐 is first pressed |
| 🔤 conjugations | low | once per card, when 🔤 is first pressed |
A visible band costs nothing by itself: only answering pulls new cards. Change the models at the top of hooks/register.js (GENERATE, CHECK, and the call of each panel).
$.store (~/.claude/plugins/store/): the shared card queue, whether the band is folded, and the answers not yet folded into the history file~/Library/Mobile Documents/com~apple~CloudDocs/capi/<Mac>-<YYYY-MM>.jsonl: each Mac's history, one file per month. Every Mac reads every file and writes only its own, through a temporary file and a rename, and never with fewer entries than it wrote before. Without iCloud Drive, Capi still works and keeps history on that Mac only.node --test test/*.test.mjs # the learning logic, no Claude Code needed
claude plugin test # the wiring, in Claude Code's own test kit
claude plugin validate . --strict # what Claude Code reads from the mod
video/ is the Remotion source of the tour above: npm install, then npm run render && npm run gif in that folder.
See CONTRIBUTING.md before opening a pull request.
Tested with Claude Code 2.1.287.
hooks/register.js 833 lines1// Capi teaches Brazilian Portuguese in the band above the prompt while Claude works.
2//
3// Where things live:
4// $.store 'log:<day>:<session>' this session's answers that day (one writer per key)
5// $.store 'queue' / 'current' the cards every session on this Mac shares
6// $.store 'machine' this Mac's name, fixed on first use
7// iCloud capi/<Mac>-<YYYY-MM>.jsonl each Mac's history per month; every Mac reads all
8
9import { replay, dayOf } from './lib/srs.js'
10import { parseJsonl, toJsonl, merge, monthFile } from './lib/log.js'
11import { buildRequest, parseCards, activityHint, germanLines, headerParts, translationRequest, parseTranslation, ITEM_FORMATS } from './lib/cards.js'
12import { grammarRequest, parseGrammar, grammarLines, conjugationRequest, parseConjugation, conjugationTables, fitsTable } from './lib/extras.js'
13import { configFrom, parseEnv, DEFAULT_CONFIG } from './lib/config.js'
14import { cells } from './lib/cells.js'
15
16// The one horizontal space between things side by side: pairs, buttons, tabs.
17const GAP = 2
18// The facts have to be TRUE, so batches go to Opus at high effort. They run in
19// the background while cards are still queued, so the latency costs nothing.
20const GENERATE = { model: 'claude-opus-5-5', effort: 'high', maxTokens: 32000, timeoutMs: 300_000 }
21// A 🚩 asks whether one claim really is wrong: rare, and worth the most care.
22const CHECK = { model: 'claude-opus-5-5', effort: 'xhigh', maxTokens: 2000, timeoutMs: 300_000 }
23// The panels a card can open, top right, each generated on its first opening
24// and then kept on the shared card. Translating and conjugating are plain jobs;
25// explaining grammar takes some judgement.
26const EXTRAS = {
27 gram: {
28 icon: '📐', name: 'gramática', hotkey: '6',
29 call: { model: 'claude-opus-5-5', effort: 'medium', maxTokens: 2500, timeoutMs: 120_000 },
30 has: (c) => Array.isArray(c.grammarDe), request: (c) => grammarRequest(c, cfg), parse: (t) => parseGrammar(t),
31 lines: (c) => grammarLines(c),
32 },
33 conj: {
34 icon: '🔤', name: 'conjugação', hotkey: '7',
35 call: { model: 'claude-opus-5-5', effort: 'low', maxTokens: 3000, timeoutMs: 120_000 },
36 // A table made for other persons or tenses than the config's is made again.
37 has: (c) => Array.isArray(c.verbs) && c.verbs.some((v) => fitsTable(v, cfg)),
38 request: (c) => conjugationRequest(c, cfg), parse: (t) => parseConjugation(t, cfg),
39 tables: (c) => conjugationTables(c, cfg),
40 },
41 de: {
42 get icon() { return cfg.nativeFlag },
43 name: 'tradução', hotkey: '0',
44 call: { model: 'claude-opus-5-5', effort: 'low', maxTokens: 1500, timeoutMs: 60_000 },
45 has: (c) => Boolean(c.questionDe || c.explainDe), request: (c) => translationRequest(c, cfg), parse: (t) => parseTranslation(t),
46 lines: (c, stage, quizOk) => germanLines(c, stage, quizOk),
47 },
48}
49const BATCH = 10
50const REFILL_BELOW = 4
51const POLL_MS = 3000
52const SYNC_DELAY_MS = 5000
53// Covers one model call. A failed refill keeps the lock, so it is also the backoff:
54// a reply that cost tokens but gave no cards waits the full time, while an API
55// error or a call cut short (both free) is retried after a minute.
56const REFILL_LOCK_MS = 6 * 60_000
57const RETRY_FREE_MS = 60_000
58// A press this soon after the card changed is the second half of a double press.
59const PRESS_GUARD_MS = 800
60const FLAG_CONFIRM_MS = 10_000
61const VOICE = 'Luciana'
62const ICLOUD = 'Library/Mobile Documents/com~apple~CloudDocs/capi'
63// CAPI_PLACE=pane: the card in a pane of this id, docked beside the conversation where the surface docks one.
64const PANE = 'capi'
65const openPane = ($) => $.ui.open({ id: PANE, title: 'Capi' })
66
67
68let home = ''
69// The learner's languages, topics and conjugation table, from the plugin's .env.
70let cfg = DEFAULT_CONFIG
71let machine = ''
72let sessionId = 'session'
73let entries = []
74let state = replay([], 0)
75let current = null
76let activity = []
77let refilling = false
78let recording = Promise.resolve()
79let syncTimer = null
80let armed = null
81// The one extra panel open in this session ({ id, kind }), and the one being fetched.
82let open = null
83// The verb tab chosen in the conjugation panel ({ id, index }); the first by default.
84let verbTab = null
85// The band folded to its header line. Kept in $.store, so a new session opens it the same way.
86let minimized = false
87// The band closed with ×, or the pane with its close mark: this session only, so a new
88// session or /capi show brings it back.
89let closed = false
90let loading = null
91let syncReport = 'not synced yet'
92let lastBatch = 'none yet'
93let failure = ''
94let effortRefused = false
95
96export function register(on) {
97 on('session.start', async ($, e, next) => {
98 home = (await $.env.get('HOME')) ?? ''
99 sessionId = await $.session.id()
100 cfg = await loadConfig($)
101 machine = await machineName($)
102 current = (await $.store.get('current')) ?? null
103 minimized = (await $.store.get('minimized')) === true
104 $.clock.every(POLL_MS, () => poll($))
105 // Not awaited: a slow or offline iCloud must not hold up the first prompt.
106 sync($).catch((err) => (syncReport = 'sync failed: ' + (err?.message ?? err)))
107 await $.command.register({
108 name: 'capi',
109 description: 'Capi: your progress (skip: next card, show: bring the band back)',
110 argumentHint: '[skip|show]',
111 immediate: true,
112 })
113 // Not awaited: an unasked pane waits undrawn on a narrow window until /capi show.
114 if (cfg.place === 'pane' && cfg.paneOpen === 'auto' && cfg.show === 'always') void openPane($)
115 return next(e)
116 })
117
118 on('command.run', { command: 'capi' }, async ($, e) => {
119 if (e.args.trim() === 'skip') {
120 await advance($)
121 return { text: 'Capi: card skipped' }
122 }
123 if (e.args.trim() === 'show') {
124 closed = false
125 if (cfg.place === 'pane') await openPane($)
126 $.ui.invalidate('ui.render')
127 return { text: cfg.place === 'pane' ? 'Capi: pane open' : 'Capi: back above the prompt' }
128 }
129 await sync($).catch((err) => (syncReport = 'sync failed: ' + (err?.message ?? err)))
130 return { text: statsText(await $.clock.now()) }
131 })
132
133 on('turn.start', async ($, e, next) => {
134 if (!current) current = (await $.store.get('current')) ?? null
135 if (!current) await advance($)
136 else refillIfLow($)
137 if (cfg.place === 'pane' && cfg.paneOpen === 'auto' && !closed && !e.agentId) void openPane($)
138 $.ui.invalidate('ui.render')
139 return next(e)
140 })
141
142 // CAPI_SHOW=working: the pane goes when the main turn ends (a subagent's turn carries an agentId).
143 on('turn.complete', async ($, e, next) => {
144 if (cfg.place === 'pane' && cfg.show === 'working' && !e.agentId) await $.ui.close({ id: PANE })
145 return next(e)
146 })
147
148 // The person closing the pane hides Capi for the session, as × does the band.
149 on('ui.close', async ($, e, next) => {
150 if (e.id === PANE && e.origin.kind === 'person') closed = true
151 return next(e)
152 })
153
154 on('tool.call', async ($, e, next) => {
155 const hint = activityHint(e.tool, e.command)
156 if (hint) activity = [hint, ...activity.filter((a) => a !== hint)].slice(0, 6)
157 return next(e)
158 })
159
160 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
161 // Working or not, unless CAPI_SHOW=working: a card waits until it is answered,
162 // and only answering pulls new cards, so a visible band never costs a model call.
163 if (cfg.place === 'pane' || e.props.hasSurvey || closed || (cfg.show === 'working' && !e.props.isWorking)) return next(e)
164 return draw($, e, e.props.maxRows ?? 99, {
165 close: () => {
166 closed = true
167 $.ui.invalidate('ui.render')
168 $.ui.toast('Capi is hidden in this session. /capi show brings it back.')
169 },
170 size: async () => {
171 minimized = !minimized
172 $.ui.invalidate('ui.render')
173 await $.store.set('minimized', minimized)
174 },
175 })
176 })
177
178 // The pane has the engine's own close mark and scrolls itself: no window controls, no row cap.
179 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => draw($, e, Infinity, {}))
180}
181
182// The card for the band or the pane; `window` holds the band's – and × (close, size).
183function draw($, e, maxRows, window) {
184 const { Box, Text, Button } = $.ui.resolve(e)
185 const cols = (e.props.bodyColumns ?? 80) - ICON_W
186 const parts = view(Box, Text, Button, cols, {
187 pick: (id, i) => pick($, id, i),
188 grade: (id, ok) => grade($, id, ok),
189 next: (id) => nextCard($, id),
190 speak: () => speak($),
191 flag: (id) => flag($, id),
192 extra: (id, kind) => toggleExtra($, id, kind),
193 verb: (id, index) => {
194 verbTab = { id, index }
195 $.ui.invalidate('ui.render')
196 },
197 ...window,
198 })
199 const body = parts.map((p) => (p.flush ? p : indent(Box, Text, p, e.surface)))
200 return Box({ flexDirection: 'column', children: fit(body, maxRows, cols) })
201}
202
203// The plugin's own .env (see .env.example). No file, or no such key, keeps the default.
204async function loadConfig($) {
205 try {
206 return configFrom(parseEnv(await $.fs.read($.plugin.root + '/.env')))
207 } catch {
208 return DEFAULT_CONFIG
209 }
210}
211
212async function machineName($) {
213 const saved = await $.store.get('machine')
214 if (saved) return saved
215 let name = ''
216 for (const argv of [['scutil', '--get', 'LocalHostName'], ['hostname', '-s']]) {
217 try {
218 const r = await $.process.run(argv)
219 if (r.exitCode === 0 && /^[\w.-]+$/.test(r.stdout.trim())) name = r.stdout.trim()
220 } catch {
221 // try the next one
222 }
223 if (name) break
224 }
225 name ||= 'mac-' + Math.random().toString(36).slice(2, 8)
226 await $.store.set('machine', name)
227 return name
228}
229
230// ---- drawing ---------------------------------------------------------------
231
232// Parts of the card: { make(cut) } for text that may be cut to one line,
233// { node } otherwise. `drop` marks what goes first when the band is short.
234function view(Box, Text, Button, cols, act) {
235 const s = state
236 const text = (t, props = {}, drop = 0) => ({
237 text: t,
238 drop,
239 make: (cut) => Text({ ...props, wrap: cut ? 'truncate-end' : 'wrap', children: [t] }),
240 })
241 const node = (n) => ({ node: n })
242 // An icon in a column of its own, so the header and the question start their
243 // text at the same place whatever the icon's drawn width.
244 const icon = (glyph) => Box({ width: ICON_W, flexShrink: 0, children: [Text({ children: [glyph] })] })
245 // One line of label and value pairs, labels dim, set apart by space alone:
246 // the card (categoria, tipo, pergunta), then the learner. 📐 🔤 🇩🇪
247 // sit at its right end and never shrink; the values give way first.
248 const { card: cardPairs, learner } = headerParts(current?.card, s)
249 const pairs = [...cardPairs, ...learner]
250 // Too wide for one line beside the toggle: the pairs wrap onto more lines, unless the band is short.
251 const pairsWidth = pairs.reduce((n, [k, v]) => n + cells(k) + 1 + cells(v), 0) + GAP * (pairs.length - 1)
252 const headRows = Math.max(1, Math.ceil(pairsWidth / Math.max(cols - GAP - 1, 20)))
253 const pair = ([k, v], cut) =>
254 Box({ flexDirection: 'row', columnGap: 1, flexShrink: 1, children: [Text({ dimColor: true, children: [k] }), Text({ wrap: cut ? 'truncate-end' : 'wrap', children: [v] })] })
255 const head = {
256 flush: true,
257 text: '',
258 rows: headRows,
259 drop: 0,
260 make: (cut, side) =>
261 Box({
262 flexDirection: 'row',
263 justifyContent: 'space-between',
264 columnGap: GAP,
265 children: [
266 Box({
267 flexDirection: 'row',
268 flexShrink: 1,
269 children: [icon('🦫'), Box({ flexDirection: 'row', flexWrap: cut ? 'nowrap' : 'wrap', columnGap: GAP, flexShrink: 1, children: pairs.map((p) => pair(p, cut)) })],
270 }),
271 ...(side ? [Box({ flexShrink: 0, children: [side] })] : []),
272 ],
273 }),
274 }
275 // The standard window controls, grey text like the hotkey digits: – minimize, □ restore, × close.
276 const control = (key, label, onPress) => Button({ key, label, plain: true, dimColor: true, onPress })
277 const controls = Box({
278 flexDirection: 'row',
279 columnGap: 1, // a pair, closer than the GAP between unrelated things
280 children: [
281 control('size', minimized ? '□' : '–', act.size),
282 control('close', '×', act.close),
283 ],
284 })
285 // The header line: the pairs, and the window controls at its right end (the band's only).
286 const top = act.close ? { ...head, make: (cut) => head.make(cut, controls) } : head
287 const folded = minimized && Boolean(act.size)
288 // Folded, or with no card yet: the header line with 🔼/🔽 alone at its right.
289 if (folded || !current) {
290 if (folded) return [top]
291 const msg = refilling
292 ? 'Capi está preparando cartas… ☕'
293 : failure
294 ? `Capi tropeçou (${failure}). Já já tenta de novo.`
295 : 'Capi está sem cartas. Já já tem mais!'
296 return [top, text(msg)]
297 }
298 const { card, stage } = current
299 const id = card.id
300 // Answers on the left; 🔊 and 🚩 on the right, in line with them.
301 // Answers on the left; 🔊 and 🚩 on the right. Short of room, 🔊 and 🚩 drop their words;
302 // shorter still, the answers wrap onto more lines.
303 const row = (children) => {
304 const compact = widthOf(children) + GAP + widthOf(tools(false)) > cols
305 const right = tools(compact)
306 const rows = Math.max(1, Math.ceil(widthOf(children) / Math.max(cols - GAP - widthOf(right), 10)))
307 return {
308 icon: '👉',
309 buttons: true,
310 rows,
311 ...node(
312 Box({
313 flexDirection: 'row',
314 justifyContent: 'space-between',
315 alignItems: 'flex-start',
316 columnGap: GAP,
317 flexGrow: 1, // the whole width after the icon column, so 🔊 🚩 reach the right edge
318 children: [
319 Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: GAP, flexShrink: 1, children }),
320 Box({ flexDirection: 'row', columnGap: GAP, flexShrink: 0, children: right }),
321 ],
322 }),
323 ),
324 }
325 }
326 // A blank line between the blocks of the card. Always kept: when the band is
327 // short the note goes and texts are cut instead.
328 const gap = { text: '', drop: 0, flush: true, make: () => Text({ children: [' '] }) }
329 const flagLabel = current.flagged ? '🚩 marcado' : armed?.id === id ? '🚩 de novo = confirmar' : '🚩 tá errado?'
330 // compact: the icon alone, keeping the hotkey digit
331 const tools = (compact) => [
332 Button({ key: 'speak', label: compact ? '🔊' : '🔊 ouvir', hotkey: '8', plain: true, onPress: act.speak }),
333 Button({ key: 'flag', label: compact ? '🚩' : flagLabel, hotkey: '9', plain: true, dimColor: Boolean(current.flagged), onPress: () => act.flag(id) }),
334 ]
335 const extraButtons = (compact) =>
336 Object.entries(EXTRAS).map(([kind, x]) => {
337 const isOpen = open?.id === id && open.kind === kind
338 const busy = loading?.id === id && loading.kind === kind
339 const label = busy ? `${x.icon} …` : compact ? x.icon : isOpen ? `${x.icon} fechar` : `${x.icon} ${x.name}`
340 return Button({ key: kind, label, hotkey: x.hotkey, plain: true, onPress: () => act.extra(id, kind) })
341 })
342 // 📐 🔤 🇩🇪 sit at the right end of the card's first line: the question, or the verdict once
343 // answered. Short of room they drop their words; shorter still, the line's text wraps.
344 const panelButtons = (line) => {
345 const compact = cells(line) + GAP + widthOf(extraButtons(false)) > cols
346 return Box({ flexDirection: 'row', columnGap: GAP, children: extraButtons(compact) })
347 }
348 const question = { ...text(card.question, { bold: true }), icon: '❓', side: panelButtons(card.question) }
349 // Asked for, so never dropped to save rows; cut to one line at worst.
350 const x = open?.id === id ? EXTRAS[open.kind] : null
351 const extra = !x
352 ? []
353 : x.tables
354 ? tables(Box, Text, Button, x.tables(card), verbTab?.id === id ? verbTab.index : 0, (i) => act.verb(id, i))
355 : x.lines(card, stage, current.quizOk).map((l) => text(l, { italic: true, dimColor: true }))
356 // the panel's icon, once, in the icon column; the verb tabs are buttons and pad themselves
357 if (extra.length) extra[0] = { ...extra[0], icon: x.icon, buttons: Boolean(x.tables) }
358 // An open panel stands apart from the card with a blank line on either side.
359 const panel = extra.length ? [gap, ...extra, gap] : []
360 const next = Button({ key: 'next', label: 'próxima', hotkey: '1', plain: true, onPress: () => act.next(id) })
361 const note = { ...text(card.note, { dimColor: true }, 1), icon: '📚' }
362
363 if (card.format === 'bonus') {
364 return [top, gap, question, text(card.explain), ...panel, note, gap, row([next])]
365 }
366 if (stage === 'quiz') {
367 const options = card.options.map((o, i) =>
368 Button({ key: 'opt-' + i, label: o, hotkey: String(i + 1), plain: true, onPress: () => act.pick(id, i) }),
369 )
370 return [top, gap, question, ...(extra.length ? [gap, ...extra] : []), gap, row(options)]
371 }
372 const verdict = current.quizOk
373 ? `Certo! +${current.gain} · ${card.capiRight}`
374 : `Errou! Era «${card.options[card.answer]}» · ${card.capiWrong}`
375 const ask = current.graded
376 ? [next]
377 : [
378 Text({ children: [`Kanntest du «${card.item}»?`] }),
379 Button({ key: 'yes', label: 'sim', hotkey: '1', plain: true, onPress: () => act.grade(id, true) }),
380 Button({ key: 'no', label: 'não', hotkey: '2', plain: true, onPress: () => act.grade(id, false) }),
381 ]
382 return [
383 top,
384 gap,
385 { ...text(verdict, { color: current.quizOk ? 'green' : 'red' }), icon: current.quizOk ? '✅' : '❌', side: panelButtons(verdict) },
386 text(`${card.explain} (Fonte: ${card.source})`),
387 ...panel,
388 note,
389 gap,
390 row(ask),
391 ]
392}
393
394// Conjugation as the morning briefs draw it, one verb at a time: the verbs as
395// tabs down the left, the chosen verb's table beside them, the tenses over one
396// row per person. Boxes of fixed width keep the columns aligned in the Desktop
397// app's proportional font too.
398function tables(Box, Text, Button, list, selected, choose) {
399 if (!list.length) return []
400 const index = Math.min(Math.max(selected, 0), list.length - 1)
401 const t = list[index]
402 // Every tab drawn alike (a dimmed Desktop button loses its padding, and the
403 // tabs would no longer start in one column); ▸ marks the chosen verb.
404 // As wide as the longest verb with its ▸ and the button's frame, so the
405 // table does not move when another verb is chosen.
406 const tabs = Box({
407 flexDirection: 'column',
408 width: Math.max(...list.map((v) => v.verb.length)) + 4,
409 flexShrink: 0,
410 children: list.map((v, i) =>
411 Button({ key: 'verb-' + i, label: (i === index ? '▸ ' : '') + v.verb, plain: true, onPress: () => choose(i) }),
412 ),
413 })
414 const table = Box({
415 flexDirection: 'column',
416 children: [t.header, ...t.rows].map((cells, i) =>
417 Box({
418 flexDirection: 'row',
419 children: cells.map((c, j) =>
420 Box({ width: t.widths[j] + GAP, children: [Text({ dimColor: (i === 0) !== (j === 0), italic: i === 0 && j > 0, wrap: 'truncate-end', children: [c] })] }),
421 ),
422 }),
423 ),
424 })
425 return [{ node: Box({ flexDirection: 'row', columnGap: GAP, children: [tabs, table] }), rows: Math.max(list.length, t.rows.length + 1) }]
426}
427
428// The cells a row of elements takes: Text by its words, a Button by its label
429// plus the hotkey digit and its mark, and GAP between them.
430function widthOf(list) {
431 const one = (el) =>
432 el?.type === 'Button' ? cells(el.props?.label) + (el.props?.hotkey ? 3 : 0) : cells((el?.children ?? []).filter((c) => typeof c === 'string').join(''))
433 return list.reduce((n, el) => n + one(el), 0) + GAP * Math.max(0, list.length - 1)
434}
435
436// Everything under the header starts where the header's text does: after the
437// icon column. Each block names itself there: 🦫 the header, ❓ the question,
438// 📐 🔤 🇩🇪 an open panel (on its first line), ✅ ❌ the verdict, 📚 the note,
439// 👉 the answers. Parts marked flush draw the column themselves or are blank.
440const ICON_W = 2 + GAP
441function indent(Box, Text, p, surface) {
442 // A Desktop button (one not dimmed) draws its own padding, about a cell; a
443 // row that starts with buttons takes that cell from the icon column.
444 const width = ICON_W - (p.buttons && surface !== 'terminal' ? 1 : 0)
445 const column = Box({ width, flexShrink: 0, children: p.icon ? [Text({ children: [p.icon] })] : [] })
446 // p.side sits at the right end of the block's first line, and never shrinks
447 const body = (n) =>
448 p.side
449 ? Box({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', columnGap: GAP, flexGrow: 1, children: [Box({ flexShrink: 1, children: [n] }), Box({ flexShrink: 0, children: [p.side] })] })
450 : n
451 const shift = (n) => Box({ flexDirection: 'row', alignItems: 'flex-start', children: [column, Box({ flexDirection: 'row', flexGrow: 1, flexShrink: 1, children: [body(n)] })] })
452 return p.node ? { ...p, node: shift(p.node) } : { ...p, make: (cut) => shift(p.make(cut)) }
453}
454
455// A tree taller than the band scrolls, and then the digit hotkeys stop working.
456// So: drop the note, then cut every text to one line. Blank lines, the header
457// and an open panel stay.
458function fit(parts, maxRows, cols) {
459 const rows = (p, cut) => (p.make ? (cut ? 1 : (p.rows ?? Math.max(1, Math.ceil(p.text.length / Math.max(cols, 20))))) : (p.rows ?? 1))
460 const height = (list, cut) => list.reduce((n, p) => n + rows(p, cut), 0)
461 let keep = parts
462 for (const level of [1, 2]) if (height(keep, false) > maxRows) keep = keep.filter((p) => p.drop !== level)
463 const cut = height(keep, false) > maxRows
464 return keep.map((p) => (p.make ? p.make(cut) : p.node))
465}
466
467// Opens or closes one extra panel; opening one closes the others. A panel the
468// card has no content for yet is generated on that first opening, and the
469// result is kept on the shared card, so no session pays for it twice.
470async function toggleExtra($, id, kind) {
471 if (!current || current.card.id !== id) return
472 const x = EXTRAS[kind]
473 open = open?.id === id && open.kind === kind ? null : { id, kind }
474 $.ui.invalidate('ui.render')
475 const card = current.card
476 if (open?.kind !== kind || (loading?.id === id && loading.kind === kind) || x.has(card)) return
477 loading = { id, kind }
478 $.ui.invalidate('ui.render')
479 const r = await complete($, x.call, x.request(card))
480 loading = null
481 const fields = r.ok ? x.parse(r.text) : {}
482 if (Object.keys(fields).length === 0) {
483 if (open?.id === id && open.kind === kind) open = null
484 $.ui.toast(`Capi não conseguiu (${x.name}: ${r.ok ? 'resposta sem conteúdo' : r.reason})`)
485 } else {
486 const shared = (await $.store.get('current')) ?? null
487 if (shared?.card.id === id) {
488 current = { ...shared, card: { ...shared.card, ...fields } }
489 await $.store.set('current', current)
490 } else if (current?.card.id === id) {
491 current = { ...current, card: { ...current.card, ...fields } }
492 }
493 }
494 $.ui.invalidate('ui.render')
495}
496
497// ---- answering -------------------------------------------------------------
498
499// True when the press belongs to the card and stage that are current here and
500// in the store. Otherwise another session moved on, and this one follows.
501async function stillMine($, id, stage) {
502 const shared = (await $.store.get('current')) ?? null
503 const now = await $.clock.now()
504 const same = (c) => c && c.card.id === id && c.stage === stage
505 if (!same(current) || !same(shared)) {
506 current = shared
507 $.ui.invalidate('ui.render')
508 return false
509 }
510 return now - (current.at ?? 0) >= PRESS_GUARD_MS
511}
512
513async function pick($, id, i) {
514 if (!(await stillMine($, id, 'quiz'))) return
515 const card = current.card
516 const quizOk = i === card.answer
517 const gain = quizOk ? 10 + 2 * Math.min(state.combo, 5) : 0
518 const graded = ITEM_FORMATS.has(card.format)
519 const now = await $.clock.now()
520 current = { ...current, stage: 'reveal', at: now, quizOk, gain, graded }
521 await $.store.set('current', current)
522 $.ui.invalidate('ui.render')
523 if (graded) await record($, answerEntry(now, card, quizOk, quizOk ? 'ok' : 'miss'))
524}
525
526async function grade($, id, ok) {
527 if (!current || current.graded || !(await stillMine($, id, 'reveal'))) return
528 await record($, answerEntry(await $.clock.now(), current.card, current.quizOk, ok ? 'ok' : 'miss'))
529 await advance($)
530}
531
532// "próxima": after a graded reveal, and on a bonus card.
533async function nextCard($, id) {
534 const stage = current?.stage ?? 'quiz'
535 if (!(await stillMine($, id, stage))) return
536 if (current.card.format === 'bonus') await record($, answerEntry(await $.clock.now(), current.card, null, null))
537 await advance($)
538}
539
540function answerEntry(t, card, quiz, result) {
541 return {
542 type: 'answer',
543 id: `${t.toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
544 t,
545 machine,
546 cardId: card.id,
547 item: card.item,
548 de: card.de,
549 kind: card.kind,
550 format: card.format,
551 quiz,
552 grade: result,
553 }
554}
555
556async function advance($) {
557 const queue = (await $.store.get('queue')) ?? []
558 // ponytail: two sessions advancing at the same instant can skip one card; harmless.
559 const card = queue.shift() ?? null
560 await $.store.set('queue', queue)
561 current = card ? { card, stage: 'quiz', at: await $.clock.now() } : null
562 await $.store.set('current', current)
563 $.ui.invalidate('ui.render')
564 refillIfLow($)
565}
566
567async function speak($) {
568 if (!current) return
569 const card = current.card
570 const text = current.stage === 'quiz' ? card.question : `${card.item}. ${card.explain}`
571 try {
572 await $.audio.speak(text, { voice: VOICE })
573 } catch {
574 $.ui.toast(`Capi braucht die Stimme ${VOICE}: Systemeinstellungen → Bedienungshilfen → Gesprochene Inhalte → Stimmen`)
575 }
576}
577
578// The first 9 arms the flag, a second 9 within 10 s confirms it: a stray key
579// must not ban a fact and buy a re-check. A flagged fact is never used again,
580// and Capi says in the transcript whether it really was wrong.
581async function flag($, id) {
582 if (!current || current.flagged || !(await stillMine($, id, current.stage))) return
583 const now = await $.clock.now()
584 if (armed?.id !== id || now - armed.t > FLAG_CONFIRM_MS) {
585 armed = { id, t: now }
586 $.ui.invalidate('ui.render')
587 return
588 }
589 armed = null
590 const card = current.card
591 const fact = `${card.question} → ${card.explain}`
592 current = { ...current, flagged: true }
593 await $.store.set('current', current)
594 $.ui.invalidate('ui.render')
595 await record($, { type: 'flag', id: `${now.toString(36)}-f${Math.random().toString(36).slice(2, 8)}`, t: now, machine, cardId: id, fact })
596 const r = await complete($, CHECK, {
597 system:
598 'You fact-check one quiz card. Answer in German, in at most three sentences: is the fact correct, wrong or doubtful, and what is true. Name the kind of source you rely on.',
599 prompt: `Card: ${fact}\nClaimed source: ${card.source}`,
600 })
601 $.ui.log(r.ok ? `🚩 Capi hat nachgeprüft: ${r.text.trim()}` : `🚩 Fakt gesperrt; die Nachprüfung kam nicht zurück (${r.reason}).`)
602}
603
604// ---- history ---------------------------------------------------------------
605
606// One at a time: two overlapping get-then-set on this session's key would drop one.
607function record($, entry) {
608 recording = recording.then(() => append($, entry)).catch((err) => (syncReport = 'recording failed: ' + (err?.message ?? err)))
609 return recording
610}
611
612async function append($, entry) {
613 const key = `log:${dayOf(entry.t)}:${sessionId}`
614 const mine = (await $.store.get(key)) ?? []
615 await $.store.set(key, [...mine, entry])
616 entries = merge(entries, [entry])
617 state = replay(entries, entry.t)
618 $.ui.invalidate('ui.render')
619 if (syncTimer) syncTimer.cancel()
620 syncTimer = $.clock.after(SYNC_DELAY_MS, () => sync($))
621}
622
623// Reads every session's answers on this Mac and every Mac's iCloud files, then
624// rewrites the months this Mac still has store entries for. A past day's store
625// key is dropped only once a file, read back, holds every one of its entries.
626//
627// Never writes a month file it could not read, never one with fewer entries
628// than it wrote before, and never in place: a reader could see half a file.
629async function sync($) {
630 const now = await $.clock.now()
631 const dir = home + '/' + ICLOUD
632 const keys = (await $.store.keys()).filter((k) => k.startsWith('log:'))
633 const local = []
634 for (const k of keys) {
635 const v = await $.store.get(k)
636 if (Array.isArray(v)) local.push(...v)
637 }
638
639 let listing = null
640 try {
641 listing = await $.fs.list(dir)
642 } catch {
643 listing = (await $.fs.exists(dir)) ? null : []
644 }
645 const files = new Map()
646 const evicted = new Set()
647 for (const f of listing ?? []) {
648 if (f.kind === 'file' && f.name.endsWith('.jsonl')) files.set(f.name, f.size)
649 // iCloud keeps a file it has not downloaded yet as ".<name>.icloud"
650 const m = /^\.(.+\.jsonl)\.icloud$/.exec(f.name)
651 if (m) evicted.add(m[1])
652 }
653 for (const name of evicted) {
654 try {
655 await $.process.run(['brctl', 'download', `${dir}/.${name}.icloud`])
656 } catch {
657 // stays unread and counted below; the next sync asks again
658 }
659 }
660
661 const readable = new Set()
662 const remote = []
663 let bad = 0
664 for (const [name, size] of files) {
665 try {
666 const text = await $.fs.read(`${dir}/${name}`)
667 // a file iCloud has not materialised can read as empty
668 if (size > 0 && !text) continue
669 const r = parseJsonl(text)
670 remote.push(...r.entries)
671 bad += r.bad
672 readable.add(name)
673 } catch {
674 // unreadable: counted below, and never written
675 }
676 }
677 entries = merge(entries, local, remote)
678 state = replay(entries, now)
679 if (listing === null) {
680 syncReport = 'iCloud folder not readable, nothing written'
681 return
682 }
683 const total = files.size + evicted.size
684 syncReport = `${readable.size} of ${total} iCloud file${total === 1 ? '' : 's'} read` + (bad ? `, ${bad} damaged lines skipped` : '')
685
686 const months = new Set(local.filter((e) => e.machine === machine).map((e) => monthFile(machine, e.t)))
687 const safe = new Set()
688 if (months.size) await $.process.run(['mkdir', '-p', dir])
689 for (const name of months) {
690 const path = `${dir}/${name}`
691 if (evicted.has(name) || (files.has(name) && !readable.has(name)) || (!files.has(name) && (await $.fs.exists(path)))) {
692 syncReport += ` · ${name} unreadable, not written`
693 continue
694 }
695 const mine = entries.filter((e) => e.machine === machine && monthFile(machine, e.t) === name)
696 const before = (await $.store.get('written:' + name)) ?? 0
697 if (mine.length < before) {
698 syncReport += ` · ${name}: ${mine.length} entries but ${before} written before, not written`
699 continue
700 }
701 try {
702 await $.fs.write(path + '.tmp', toJsonl(mine))
703 const mv = await $.process.run(['mv', '-f', path + '.tmp', path])
704 if (mv.exitCode !== 0) throw new Error(mv.stderr.trim() || 'mv failed')
705 const back = parseJsonl(await $.fs.read(path)).entries
706 await $.store.set('written:' + name, back.length)
707 for (const e of back) safe.add(e.id)
708 } catch (err) {
709 syncReport += ` · writing ${name} failed: ${err?.message ?? err}`
710 }
711 }
712
713 const today = dayOf(now)
714 for (const k of keys) {
715 if (k.split(':')[1] >= today) continue
716 const v = (await $.store.get(k)) ?? []
717 if (v.every((e) => safe.has(e.id))) await $.store.delete(k)
718 }
719}
720
721// ---- cards -----------------------------------------------------------------
722
723function refillIfLow($) {
724 refill($).catch((err) => {
725 refilling = false
726 lastBatch = 'failed: ' + (err?.message ?? err)
727 })
728}
729
730async function refill($) {
731 if (refilling) return
732 const queue = (await $.store.get('queue')) ?? []
733 if (queue.length >= REFILL_BELOW) return
734 const now = await $.clock.now()
735 const lock = (await $.store.get('refill')) ?? null
736 if (lock?.t && now - lock.t < (lock.wait ?? REFILL_LOCK_MS)) return
737 // ponytail: set-then-read narrows two sessions starting at once to a tiny window; no compare-and-set exists.
738 const mine = { t: now, by: `${sessionId}:${Math.random().toString(36).slice(2, 8)}` }
739 await $.store.set('refill', mine)
740 if ((await $.store.get('refill'))?.by !== mine.by) return
741 refilling = true
742 $.ui.invalidate('ui.render')
743 let added = 0
744 try {
745 await sync($)
746 const req = buildRequest({ now, state, queue, activity, total: BATCH, cfg })
747 // The lock covers the model call itself, not the sync before it.
748 await $.store.set('refill', { ...mine, t: await $.clock.now() })
749 const r = await complete($, GENERATE, req)
750 if (!r.ok) {
751 const free = /^(api-error|aborted)/.test(r.reason)
752 const wait = free ? RETRY_FREE_MS : REFILL_LOCK_MS
753 await $.store.set('refill', { ...mine, t: await $.clock.now(), wait })
754 failure = r.reason
755 lastBatch = `failed: ${r.reason}`
756 $.ui.log(`Capi bekam keine Karten (${r.reason}); nächster Versuch in ${wait / 60_000} Minute${wait === 60_000 ? '' : 'n'}`)
757 return
758 }
759 failure = ''
760 const { cards, dropped } = parseCards(r.text, now)
761 lastBatch = `${cards.length} of ${cards.length + dropped} cards usable (asked for ${req.count})`
762 if (cards.length === 0) $.ui.log(`Capi bekam keine brauchbaren Karten: ${lastBatch}`)
763 const fresh = (await $.store.get('queue')) ?? []
764 await $.store.set('queue', [...fresh, ...cards])
765 added = cards.length
766 } finally {
767 refilling = false
768 // Only a batch that delivered frees the lock; a failure keeps it as the backoff.
769 if (added > 0) await $.store.set('refill', null)
770 $.ui.invalidate('ui.render')
771 }
772 if (added > 0 && !current) await advance($)
773}
774
775// One model call. Resolves to { ok, text } or { ok: false, reason }; never rejects.
776async function complete($, opts, req) {
777 const base = { model: opts.model, system: req.system, prompt: req.prompt, maxTokens: opts.maxTokens, timeoutMs: opts.timeoutMs }
778 // An API error says its HTTP status and kind; the bare reason alone hides both.
779 const answer = (r) =>
780 typeof r === 'string'
781 ? { ok: true, text: r }
782 : r?.isAnswered
783 ? { ok: true, text: r.text }
784 : { ok: false, reason: [r?.reason ?? 'no answer', r?.status, r?.error].filter((x) => x != null).join(' ') }
785 if (!effortRefused) {
786 try {
787 return answer(await $.model.complete({ ...base, effort: opts.effort }))
788 } catch (err) {
789 // Only when the same call without `effort` goes through was `effort` the problem.
790 try {
791 const r = answer(await $.model.complete(base))
792 effortRefused = true
793 $.ui.log(`Capi: effort "${opts.effort}" was refused (${err?.message ?? err}); using the model's default effort`)
794 return r
795 } catch {
796 return { ok: false, reason: String(err?.message ?? err) }
797 }
798 }
799 }
800 try {
801 return answer(await $.model.complete(base))
802 } catch (err) {
803 return { ok: false, reason: String(err?.message ?? err) }
804 }
805}
806
807// Follows the card another session moved to, and keeps cards coming.
808async function poll($) {
809 const shared = (await $.store.get('current')) ?? null
810 const key = (c) =>
811 c ? `${c.card.id}:${c.stage}:${c.flagged ? 1 : 0}:${Object.values(EXTRAS).map((x) => (x.has(c.card) ? 1 : 0)).join('')}` : ''
812 if (key(shared) !== key(current)) {
813 current = shared
814 $.ui.invalidate('ui.render')
815 }
816 if (current) return
817 const queue = (await $.store.get('queue')) ?? []
818 if (queue.length) await advance($)
819 else refillIfLow($)
820}
821
822function statsText(now) {
823 const s = state
824 const due = [...s.items.values()].filter((it) => it.due <= now).length
825 const next = s.level.next === null ? 'top level' : `next level at ${s.level.next}`
826 return [
827 `🦫 ${s.level.name} · ${s.known} expressions known (${next})`,
828 `🔥 streak ${s.streak} day${s.streak === 1 ? '' : 's'} · combo x${s.combo} (best ${s.bestCombo}) · ${s.points} points`,
829 `📚 learning ${s.learning} · due now ${due} · new today ${s.todayNew}`,
830 `☁️ ${machine}: ${syncReport} · last batch: ${lastBatch}`,
831 ].join('\n')
832}
833hooks/lib/srs.js 139 lines1// Spaced repetition over the answer log. The log is the truth; this module only
2// replays it, so two sessions or two Macs can never disagree about a score.
3
4const MINUTE = 60_000
5const DAY = 86_400_000
6
7// Leitner boxes: days until an item in that box is due again.
8export const INTERVAL_DAYS = [0, 1, 3, 7, 30, 90]
9export const MAX_BOX = 5
10export const KNOWN_BOX = 3
11// The first week is placement: an item already known jumps straight to box 4.
12export const PLACEMENT_DAYS = 7
13export const PLACEMENT_BOX = 4
14// A miss comes back the same day, in another format (the payback round).
15export const PAYBACK_MINUTES = 20
16
17export const LEVELS = [
18 [0, 'Turista'],
19 [50, 'Gringo'],
20 [150, 'Gringo esperto'],
21 [300, 'Quase brasileiro'],
22 [600, 'Brasileiro de coração'],
23]
24
25// YYYY-MM-DD in local time, built by hand: the hooks runtime may have no locale data.
26export function dayOf(t) {
27 const d = new Date(t)
28 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
29}
30
31export function norm(item) {
32 return String(item ?? '')
33 .toLowerCase()
34 .replace(/[«»"“”'.,!?;:]/g, '')
35 .replace(/\s+/g, ' ')
36 .trim()
37}
38
39export function levelFor(known) {
40 let i = 0
41 while (i + 1 < LEVELS.length && known >= LEVELS[i + 1][0]) i++
42 return { name: LEVELS[i][1], next: LEVELS[i + 1]?.[0] ?? null }
43}
44
45// Replays every log entry, oldest first. Answer entries for the same card count
46// once, so a card answered in two sessions at the same moment is one answer.
47export function replay(entries, now) {
48 const items = new Map()
49 const answeredCards = new Set()
50 const flagged = []
51 const days = new Set()
52 let firstT = null
53 let combo = 0
54 let bestCombo = 0
55 let points = 0
56
57 for (const e of entries) {
58 if (e.type === 'flag') {
59 flagged.push(e.fact)
60 continue
61 }
62 if (e.type !== 'answer' || answeredCards.has(e.cardId)) continue
63 answeredCards.add(e.cardId)
64 firstT ??= e.t
65 days.add(dayOf(e.t))
66
67 if (e.quiz === true) {
68 combo += 1
69 points += 10 + 2 * Math.min(combo - 1, 5)
70 bestCombo = Math.max(bestCombo, combo)
71 } else if (e.quiz === false) {
72 combo = 0
73 }
74
75 if (!e.grade || !norm(e.item)) continue
76 const key = norm(e.item)
77 let it = items.get(key)
78 const isNew = !it
79 if (isNew) {
80 it = { item: e.item, de: e.de, kind: e.kind, box: 1, due: e.t, firstT: e.t, misses: 0 }
81 items.set(key, it)
82 }
83 it.lastFormat = e.format
84 if (e.grade === 'ok') {
85 const placing = isNew && e.t - firstT < PLACEMENT_DAYS * DAY
86 it.box = placing ? PLACEMENT_BOX : Math.min(it.box + 1, MAX_BOX)
87 it.due = e.t + INTERVAL_DAYS[it.box] * DAY
88 it.lastMiss = false
89 } else {
90 it.box = 1
91 it.misses += 1
92 it.due = e.t + PAYBACK_MINUTES * MINUTE
93 it.lastMiss = true
94 }
95 }
96
97 const today = dayOf(now)
98 let todayNew = 0
99 let known = 0
100 for (const it of items.values()) {
101 if (dayOf(it.firstT) === today) todayNew += 1
102 if (it.box >= KNOWN_BOX) known += 1
103 }
104 return {
105 items,
106 flagged,
107 known,
108 learning: items.size - known,
109 todayNew,
110 streak: streakOf(days, now),
111 combo,
112 bestCombo,
113 points,
114 level: levelFor(known),
115 }
116}
117
118// Days in a row with at least one answer, ending today, or yesterday when
119// today has none yet.
120function streakOf(days, now) {
121 let t = days.has(dayOf(now)) ? now : now - DAY
122 let n = 0
123 while (days.has(dayOf(t))) {
124 n += 1
125 t -= DAY
126 }
127 return n
128}
129
130export function dueItems(state, now) {
131 return [...state.items.values()].filter((it) => it.due <= now).sort((a, b) => a.due - b.due)
132}
133
134// New items still allowed today (perDay is CAPI_NEW_PER_DAY), counting the ones
135// already waiting in the queue.
136export function newAllowance(state, queuedNew, perDay) {
137 return Math.max(0, perDay - state.todayNew - queuedNew)
138}
139hooks/lib/log.js 36 lines1// The answer log as JSON lines: one file per Mac and month in iCloud Drive,
2// merged on read. A month stays far below the 4 MiB a mod may read or write.
3
4import { dayOf } from './srs.js'
5
6export function monthFile(machine, t) {
7 return `${machine}-${dayOf(t).slice(0, 7)}.jsonl`
8}
9
10export function parseJsonl(text) {
11 const entries = []
12 let bad = 0
13 for (const line of String(text ?? '').split('\n')) {
14 if (!line.trim()) continue
15 try {
16 const e = JSON.parse(line)
17 if (e && typeof e.id === 'string' && typeof e.t === 'number' && typeof e.type === 'string') entries.push(e)
18 else bad += 1
19 } catch {
20 bad += 1
21 }
22 }
23 return { entries, bad }
24}
25
26export function toJsonl(entries) {
27 return entries.map((e) => JSON.stringify(e) + '\n').join('')
28}
29
30// Union of every list, one entry per id, oldest first.
31export function merge(...lists) {
32 const byId = new Map()
33 for (const list of lists) for (const e of list) byId.set(e.id, e)
34 return [...byId.values()].sort((a, b) => a.t - b.t || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
35}
36hooks/lib/cards.js 223 lines1// Card batches: the request Capi sends to the model, and the check of what comes back.
2
3import { dueItems, newAllowance, norm } from './srs.js'
4import { DEFAULT_CONFIG } from './config.js'
5
6// Formats whose answer tests the language item itself; the others test the fact,
7// and the learner says afterwards whether they knew the item.
8export const ITEM_FORMATS = new Set(['cloze', 'meaning'])
9const QUIZ_FORMATS = new Set(['tf', 'mc', 'number', 'cloze', 'meaning'])
10const KINDS = new Set(['spoken', 'grammar', 'vocab'])
11const AVOID_LIMIT = 400
12const WEEKDAYS = ['domingo', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado']
13const MONTHS = ['janeiro', 'fevereiro', 'março', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro']
14const WRAPPERS = new Set(['sudo', 'env', 'export', 'time', 'nohup', 'exec'])
15
16// What Claude is doing, safe to put in a prompt: a tool's name, or a shell
17// command's program and subcommand. Assignments, paths and arguments, which
18// is where a secret would be, never get through.
19export function activityHint(tool, command) {
20 if (tool !== 'Bash') return /^[\w-]+$/.test(tool ?? '') ? tool : ''
21 const words = String(command ?? '').trim().split(/\s+/).filter((w) => !w.includes('='))
22 while (WRAPPERS.has(words[0])) words.shift()
23 const program = (words[0] ?? '').split('/').pop()
24 if (!/^[\w.-]+$/.test(program)) return ''
25 return /^[a-z][a-z0-9-]*$/.test(words[1] ?? '') ? `${program} ${words[1]}` : program
26}
27
28// The card prompt, built from the learner's config (lib/config.js). Fields named
29// …De hold the learner's own language, whatever it is; the names stayed.
30export function systemPrompt(cfg = DEFAULT_CONFIG) {
31 const { learn, native } = cfg
32 return `You write quiz cards for "Capi", a cheeky capybara who teaches ${learn} inside a coding tool while the developer waits.
33
34The learner: native ${native} speaker who ${cfg.learner}. Explanations meant for the learner are in ${native}.
35
36Every card teaches TWO things at once: one true, interesting real-world fact AND one ${learn} language item. A sentence with nothing to learn is a failure.
37
38Truth rules, the most important part:
39- Only facts you would find in mainstream reference works: encyclopedias, official statistics, textbooks. When you are not sure, pick another fact.
40- Round numbers and say "about" or "more than" in the card's language; no fake precision. No superlatives ("the biggest in the world") unless they are textbook knowledge.
41- No popular myths (the Great Wall visible from space, using 10% of the brain, goldfish memory). Debunking a myth makes a great card.
42- "source" names a real kind of source: "IBGE", "NASA", "OMS", "Britannica", "Embrapa".
43
44Language rules:
45- ${learn} as it is spoken today. Spoken items use real colloquial forms (for Brazilian Portuguese: tô, tá, cê, né, a gente, pra, bora, rolar, dar um jeito).
46- "item" is the expression being taught, written the way a learner would look it up: the infinitive for a verb phrase (e.g. "ficar de fora"), the pattern for grammar (e.g. "quando + futuro do subjuntivo").
47- The card's sentence uses the item naturally.
48
49Formats:
50- "tf": the question is a statement making exactly ONE checkable claim, options are the ${learn} words for true and false (["verdade","mentira"] in Portuguese), answer 0 or 1. About a third are false; a false one is clearly false and "explain" says what is true.
51- "mc": the question asks about the fact; 3 or 4 options.
52- "number": the question asks for a number; 3 or 4 plausible numbers as options.
53- "cloze": the sentence with the item replaced by "___"; 3 or 4 candidate fillers as options, exactly one is the item.
54- "meaning": the sentence, then which ${native} meaning the item has; 3 or 4 ${native} options.
55- "bonus": no quiz. A proverb from where ${learn} is spoken, an idiom with its story, or a word with a surprising origin. kind "bonus", options [], answer -1, item may be "".
56
57Translations, so the learner can check what they read: "questionDe" is the question in ${native}, "explainDe" the explanation in ${native}, "capiRightDe" and "capiWrongDe" Capi's lines in ${native}. "optionsDe" is the options in ${native}, same order, for "mc" cards only; [] for every other format. Translate EVERYTHING into ${native}, the expression being taught included, so the learner sees what it means (for German, write "Schau mal: …" for "Saca só: …"; never leave it in ${learn}). Two exceptions, so a translation never gives the answer away: in a "meaning" card keep the item in ${learn} inside «», because its meaning is the answer, and in a "cloze" card keep the ___.
58
59Tone: playful, warm, a bit cheeky. "capiRight" and "capiWrong" are Capi's ${learn} one-liners of at most 12 words, funny and never mean.
60
61Reply with ONLY a JSON array. Each card:
62{"kind":"spoken|grammar|vocab|bonus","topic":"short ${learn} category of the fact, 1 to 3 words, commas only, e.g. Comida, botânica or História do Brasil","format":"tf|mc|number|cloze|meaning|bonus","item":"...","de":"${native} meaning of the item","question":"...","options":["..."],"answer":0,"explain":"one or two short ${learn} sentences with the true fact","note":"in ${native}: «item» = meaning, plus one usage hint","source":"...","capiRight":"...","capiWrong":"...","questionDe":"...","optionsDe":[],"explainDe":"...","capiRightDe":"...","capiWrongDe":"..."}`
63}
64
65// The batch to ask for. queue: cards already waiting; activity: what Claude is
66// busy with right now. Once today's new items are used up and nothing is due,
67// the batch is bonus cards only. Cards leave the queue only when answered, so
68// generation never runs ahead of the learner.
69export function buildRequest({ now, state, queue, activity, total, cfg = DEFAULT_CONFIG }) {
70 const queued = new Set(queue.map((c) => norm(c.item)))
71 const due = dueItems(state, now)
72 .filter((it) => !queued.has(norm(it.item)))
73 .slice(0, total - 1)
74 const queuedNew = queue.filter((c) => c.kind !== 'bonus' && !state.items.has(norm(c.item))).length
75 const fresh = Math.min(newAllowance(state, queuedNew, cfg.newPerDay), total - due.length - 1)
76 // Bonus-only batches are full size: a call costs about the same for 3 cards or 10.
77 const bonus = due.length + fresh === 0 ? total : 1
78
79 const d = new Date(now)
80 const date = `${WEEKDAYS[d.getDay()]}, ${d.getDate()} de ${MONTHS[d.getMonth()]} de ${d.getFullYear()}`
81 const known = [...state.items.values()].map((it) => it.item).slice(-AVOID_LIMIT)
82 const lines = [`Today is ${date}. Write ${due.length + fresh + bonus} cards.`, '']
83
84 if (due.length) {
85 lines.push(
86 `Review: one card for each item below. The learner has met these before, so write a NEW fact and use the format given.`,
87 )
88 for (const it of due) lines.push(`- "${it.item}" (${it.de}) → format ${formatFor(it)}`)
89 lines.push('')
90 }
91 if (fresh) {
92 lines.push(
93 `New: ${fresh} cards with items the learner has not met. About 60% spoken everyday ${cfg.learn}, 20% grammar, 20% vocabulary.`,
94 )
95 if (known.length) lines.push(`Items already known, do not use: ${JSON.stringify(known)}`)
96 lines.push('')
97 }
98 lines.push(`Bonus: ${bonus} "bonus" card${bonus > 1 ? 's' : ''}.`, '')
99 lines.push(
100 `Spread the facts across: ${cfg.topics}.`,
101 `Calendar: when you are certain of a well-known event on this calendar date (where ${cfg.learn} is spoken first, then world history), or of something seasonal there right now, base one card on it. When you are not certain, skip this.`,
102 )
103 if (activity.length) {
104 lines.push(
105 `Right now Claude, the coding assistant, is busy with: ${activity.join(', ')}. Base one card on that moment (in Portuguese: "Enquanto o Claude…") with a ${cfg.learn} tech or work expression, and still a real fact.`,
106 )
107 }
108 if (state.flagged.length) {
109 lines.push(`Never use these facts, the learner flagged them as wrong: ${JSON.stringify(state.flagged.slice(-50))}`)
110 }
111 return { system: systemPrompt(cfg), prompt: lines.join('\n'), count: due.length + fresh + bonus }
112}
113
114// A missed item comes back in the other language format, so the answer cannot
115// be remembered by position. The model picks the format for everything else.
116function formatFor(it) {
117 if (!it.lastMiss) return 'any quiz format'
118 return it.lastFormat === 'cloze' ? 'meaning' : 'cloze'
119}
120
121// The usable cards in a model reply, and how many were dropped.
122export function parseCards(text, now) {
123 const s = String(text ?? '')
124 const start = s.indexOf('[')
125 const end = s.lastIndexOf(']')
126 let raw = []
127 try {
128 raw = start >= 0 && end > start ? JSON.parse(s.slice(start, end + 1)) : []
129 } catch {
130 raw = []
131 }
132 if (!Array.isArray(raw)) raw = []
133 const cards = raw.filter(isCard).map((c, i) => ({ ...c, id: `${now.toString(36)}-${i}-${Math.random().toString(36).slice(2, 8)}` }))
134 return { cards, dropped: raw.length - cards.length }
135}
136
137function isText(v) {
138 return typeof v === 'string' && v.trim() !== ''
139}
140
141function isCard(c) {
142 if (!c || typeof c !== 'object' || !isText(c.question) || !isText(c.explain)) return false
143 if (c.format === 'bonus') return c.kind === 'bonus' && isText(c.note)
144 if (!QUIZ_FORMATS.has(c.format) || !KINDS.has(c.kind)) return false
145 if (![c.item, c.de, c.note, c.source, c.capiRight, c.capiWrong].every(isText)) return false
146 if (!Array.isArray(c.options) || !c.options.every(isText)) return false
147 const n = c.options.length
148 if (c.format === 'tf' ? n !== 2 : n < 3 || n > 4) return false
149 return Number.isInteger(c.answer) && c.answer >= 0 && c.answer < n
150}
151
152// The German lines the translation toggle shows for a card at its stage. A card
153// made before translations existed has none, and then there is no toggle.
154export function germanLines(card, stage, quizOk) {
155 const lines = []
156 if (card.format === 'bonus' || stage === 'quiz') lines.push(card.questionDe)
157 if (stage === 'quiz' && card.format === 'mc' && Array.isArray(card.optionsDe) && card.optionsDe.length === card.options.length) {
158 lines.push(card.optionsDe.map((o, i) => `${i + 1}: ${o}`).join(' · '))
159 }
160 if (stage === 'reveal' && card.format !== 'bonus') lines.push(quizOk ? card.capiRightDe : card.capiWrongDe)
161 if (card.format === 'bonus' || stage === 'reveal') lines.push(card.explainDe)
162 return lines.filter(isText)
163}
164
165// For a card made before translations existed: one small request when the
166// learner opens 🇩🇪 on it, under the same rules the card batches follow.
167export function translateSystem(cfg = DEFAULT_CONFIG) {
168 const { learn, native } = cfg
169 return `Translate one ${learn} quiz card into ${native} for a learner. Reply with ONLY a JSON object:
170{"questionDe":"...","optionsDe":[],"explainDe":"...","capiRightDe":"...","capiWrongDe":"..."}
171"optionsDe" holds the options in ${native}, same order, for an "mc" card only; [] otherwise. Translate EVERYTHING into ${native}, the expression being taught included, so the learner sees what it means (for German, write "Schau mal: …" for "Saca só: …"; never leave it in ${learn}). Two exceptions, so a translation never gives the answer away: in a "meaning" card keep the item in ${learn} inside «», because its meaning is the answer, and in a "cloze" card keep the ___. Leave out a field the card does not have.`
172}
173
174export function translationRequest(card, cfg = DEFAULT_CONFIG) {
175 const { format, item, question, options, explain, capiRight, capiWrong } = card
176 return { system: translateSystem(cfg), prompt: JSON.stringify({ format, item, question, options, explain, capiRight, capiWrong }) }
177}
178
179// The translated fields of a reply, only those that are well formed.
180export function parseTranslation(text) {
181 const s = String(text ?? '')
182 let raw = null
183 try {
184 raw = JSON.parse(s.slice(s.indexOf('{'), s.lastIndexOf('}') + 1))
185 } catch {
186 return {}
187 }
188 const out = {}
189 for (const k of ['questionDe', 'explainDe', 'capiRightDe', 'capiWrongDe']) if (isText(raw?.[k])) out[k] = raw[k]
190 if (Array.isArray(raw?.optionsDe) && raw.optionsDe.every(isText)) out.optionsDe = raw.optionsDe
191 return out
192}
193
194const KIND_LABELS = { spoken: 'Fala', grammar: 'Gramática', vocab: 'Vocabulário', bonus: 'Bônus' }
195const QUESTION_LABELS = {
196 tf: 'Verdade ou mentira',
197 mc: 'Múltipla escolha',
198 number: 'Chute o número',
199 cloze: 'Complete a frase',
200 meaning: 'O que significa',
201 bonus: 'Curiosidade',
202}
203
204// The header as label and value pairs, in two lines: what the card is, and
205// where the learner stands. Values carry no separators of their own: a topic's
206// dots, pipes or dashes become commas.
207export function headerParts(card, state) {
208 const { level, known, streak, combo } = state
209 const learner = [
210 ['nível', level.next === null ? level.name : `${level.name} ${known}/${level.next}`],
211 ['sequência', `${streak} dia${streak === 1 ? '' : 's'}`],
212 ['combo', String(combo)],
213 ]
214 if (!card) return { card: [], learner }
215 const topic = isText(card.topic) ? card.topic.trim().replace(/\s*[·|•–—]\s*|\s+-\s+/g, ', ') : ''
216 const pairs = [
217 ['categoria', topic],
218 ['tipo', KIND_LABELS[card.kind] ?? ''],
219 ['pergunta', QUESTION_LABELS[card.format] ?? ''],
220 ]
221 return { card: pairs.filter(([, v]) => v), learner }
222}
223hooks/lib/extras.js 84 lines1// 📐 grammar notes and 🔤 conjugations for a card's sentence, asked for when
2// the learner opens them and then kept on the card.
3
4import { DEFAULT_CONFIG } from './config.js'
5
6const isText = (v) => typeof v === 'string' && v.trim() !== ''
7
8const NO_GIVEAWAY = `If the sentence contains ___, it is a gap the learner must fill: never fill it, never name or hint at the missing word. If the card is a "meaning" card, never explain what the item in «» means.`
9
10export function grammarSystem(cfg = DEFAULT_CONFIG) {
11 return `You explain the grammar of one ${cfg.learn} sentence to a native ${cfg.native} speaker who ${cfg.learner}. Reply with ONLY a JSON object: {"grammarDe":["...", "..."]}
122 to 5 notes in ${cfg.native}, each one short line (at most 140 characters), about what is actually in this sentence: prepositions and contractions and the ${cfg.native} case they do the job of (for German, e.g. «do caju» = de + o, wie ein Genitiv), pronoun placement, word order, tense and mood choices (subjunctive!), colloquial forms. Compare with ${cfg.native} where it helps. ${NO_GIVEAWAY}`
13}
14
15export function conjugationSystem(cfg = DEFAULT_CONFIG) {
16 const persons = cfg.persons.join(', ')
17 const tenses = Object.fromEntries(cfg.tenses.map((t) => [t, cfg.persons.map(() => '…')]))
18 return `You conjugate the verbs of one ${cfg.learn} sentence for a ${cfg.native}-speaking learner. Reply with ONLY a JSON object:
19{"verbs":[{"infinitive":"...","tenses":${JSON.stringify(tenses)}}]}
20Every verb in the sentence, auxiliaries included, at most 4, in the order they appear. Each tense lists exactly ${cfg.persons.length} forms, one per person, in this order: ${persons}. Use the forms these persons take in everyday ${cfg.learn}. ${NO_GIVEAWAY}`
21}
22
23function cardJson(card) {
24 const { format, item, question } = card
25 return JSON.stringify({ format, item: format === 'cloze' ? undefined : item, sentence: question })
26}
27
28export function grammarRequest(card, cfg = DEFAULT_CONFIG) {
29 return { system: grammarSystem(cfg), prompt: cardJson(card) }
30}
31
32export function conjugationRequest(card, cfg = DEFAULT_CONFIG) {
33 return { system: conjugationSystem(cfg), prompt: cardJson(card) }
34}
35
36function jsonObject(text) {
37 const s = String(text ?? '')
38 try {
39 return JSON.parse(s.slice(s.indexOf('{'), s.lastIndexOf('}') + 1))
40 } catch {
41 return null
42 }
43}
44
45export function parseGrammar(text) {
46 const notes = jsonObject(text)?.grammarDe
47 if (!Array.isArray(notes)) return {}
48 const ok = notes.filter(isText).slice(0, 5)
49 return ok.length ? { grammarDe: ok } : {}
50}
51
52// A verb is kept only when every configured tense has one form per person.
53export function fitsTable(v, cfg = DEFAULT_CONFIG) {
54 return (
55 isText(v?.infinitive) &&
56 cfg.tenses.every((t) => Array.isArray(v.tenses?.[t]) && v.tenses[t].length === cfg.persons.length && v.tenses[t].every(isText))
57 )
58}
59
60export function parseConjugation(text, cfg = DEFAULT_CONFIG) {
61 const verbs = jsonObject(text)?.verbs
62 if (!Array.isArray(verbs)) return {}
63 const ok = verbs.filter((v) => fitsTable(v, cfg)).slice(0, 4)
64 return ok.length ? { verbs: ok } : {}
65}
66
67export function grammarLines(card) {
68 return card.grammarDe ?? []
69}
70
71// One table per verb, the way the morning briefs draw it: the tenses across,
72// one row per person. The verb itself is its tab.
73export function conjugationTables(card, cfg = DEFAULT_CONFIG) {
74 return (card.verbs ?? [])
75 .filter((v) => fitsTable(v, cfg))
76 .map((v) => {
77 const header = ['', ...cfg.tenses]
78 const rows = cfg.persons.map((p, i) => [p, ...cfg.tenses.map((t) => v.tenses[t][i])])
79 // each column as wide as its longest cell
80 const widths = header.map((_, j) => Math.max(...[header, ...rows].map((r) => r[j].length)))
81 return { verb: v.infinitive, header, rows, widths }
82 })
83}
84hooks/lib/config.js 64 lines1// What a learner sets in the plugin's .env (see .env.example). Every value has
2// a default, so a missing file or key changes nothing.
3
4export const DEFAULTS = {
5 CAPI_LEARN_LANGUAGE: 'Brazilian Portuguese',
6 CAPI_NATIVE_LANGUAGE: 'German',
7 CAPI_NATIVE_FLAG: '🇩🇪',
8 CAPI_LEARNER:
9 'has lived in Brazil for four years and gets by in everyday Portuguese (about A2/B1), with real gaps, above all in grammar',
10 CAPI_TOPICS:
11 'Brazil, science and nature, technology, history (Brazil and the world), everyday life, food, health and fitness',
12 // The conjugation table, as the morning briefs draw it: rows are persons,
13 // columns are tenses, both separated by |.
14 CAPI_PERSONS: 'eu|você|ele/ela|nós|vocês',
15 CAPI_TENSES: 'presente|pretérito perfeito|pretérito imperfeito|futuro|subjuntivo presente',
16 // When the band is there: always, or only while Claude works.
17 CAPI_SHOW: 'always',
18 // New expressions a day at most, across all sessions and Macs; 0 means reviews only.
19 CAPI_NEW_PER_DAY: '25',
20 // Where the card is drawn: the band above the prompt, or a pane beside the conversation.
21 CAPI_PLACE: 'band',
22 // The pane opens by itself (auto: when a session starts, or a turn with CAPI_SHOW=working), or only by /capi show.
23 CAPI_PANE_OPEN: 'auto',
24}
25
26// KEY=value lines; blank lines and # comments skipped, one pair of quotes around
27// a value removed. Nothing else is interpreted: no expansion, no escapes.
28export function parseEnv(text) {
29 const env = {}
30 for (const raw of String(text ?? '').split('\n')) {
31 const line = raw.trim()
32 if (!line || line.startsWith('#')) continue
33 const eq = line.indexOf('=')
34 if (eq < 1) continue
35 let value = line.slice(eq + 1).trim()
36 if (value.length > 1 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) value = value.slice(1, -1)
37 env[line.slice(0, eq).trim()] = value
38 }
39 return env
40}
41
42const list = (v) => v.split('|').map((x) => x.trim()).filter(Boolean)
43// a whole number of 0 or more, else the default
44const count = (v) => (/^\d+$/.test(v) ? Number(v) : Number(DEFAULTS.CAPI_NEW_PER_DAY))
45
46export function configFrom(env = {}) {
47 const get = (k) => (typeof env[k] === 'string' && env[k].trim() ? env[k].trim() : DEFAULTS[k])
48 return {
49 learn: get('CAPI_LEARN_LANGUAGE'),
50 native: get('CAPI_NATIVE_LANGUAGE'),
51 nativeFlag: get('CAPI_NATIVE_FLAG'),
52 learner: get('CAPI_LEARNER'),
53 topics: get('CAPI_TOPICS'),
54 persons: list(get('CAPI_PERSONS')),
55 tenses: list(get('CAPI_TENSES')),
56 show: get('CAPI_SHOW').toLowerCase() === 'working' ? 'working' : 'always',
57 newPerDay: count(get('CAPI_NEW_PER_DAY')),
58 place: get('CAPI_PLACE').toLowerCase() === 'pane' ? 'pane' : 'band',
59 paneOpen: get('CAPI_PANE_OPEN').toLowerCase() === 'manual' ? 'manual' : 'auto',
60 }
61}
62
63export const DEFAULT_CONFIG = configFrom()
64hooks/lib/cells.js 13 lines1// The cells a string takes on screen: an emoji two, a variation selector or
2// joiner none. ponytail: no full East Asian width table; the band's text is
3// Latin, emoji and flags.
4export function cells(str) {
5 let n = 0
6 for (const ch of String(str ?? '')) {
7 const c = ch.codePointAt(0)
8 if (c === 0xfe0f || c === 0x200d || (c >= 0x300 && c < 0x370)) continue
9 n += /\p{Extended_Pictographic}/u.test(ch) ? 2 : 1
10 }
11 return n
12}
13