SLOPSHOPPER

capi

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-03danieldeusing/capi-cc-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · capi
│ ┃ capi ✕ › fix the failing auth test and add an audit log call │ ┃ 🦫 nível Turista 0/50 sequência 0 dias co │ ┃ Capi está sem cartas. Já já tem mais! ● capi: Capi bekam keine brauchbaren Karten: 0 of 0 cards usable (ask │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /capi │ ⎿ capi: 🦫 Turista · 0 expressions known (next level at 50) │ ⎿ capi: 🔥 streak 0 days · combo x0 (best 0) · 0 points │ ⎿ capi: 📚 learning 0 · due now 0 · new today 0 │ ⎿ capi: ☁️ dev: 0 of 0 iCloud files read · last batch: 0 of 0 card │ │ 🦫 nível Turista 0/50 sequência 0 dias combo 0 – × Capi está sem cartas. Já já tem mais! ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🦫 nível Turista 0/50 sequência 0 dias combo 0 – × Capi está sem cartas. Já já tem mais!
Pane · capi
🦫 nível Turista 0/50 sequência 0 dias combo 0 Capi está sem cartas. Já já tem mais!
README

Capi: learn a language while Claude works

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.

A 38-second tour: a card above the prompt, its translation, grammar notes and conjugation tables, then a right and a wrong answer

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.

Keys

keydoes
1–4answer 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 ×.

Install

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.

From seedr

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.

From GitHub

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.

From a clone

  1. Clone the repository wherever you keep code:
   git clone https://github.com/danieldeusing/capi-cc-mod.git ~/capi-cc-mod
  1. Load it in every session, Desktop included, by adding its path to ~/.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.

  1. Start a new session. Capi's band appears above the prompt and the first ten cards arrive within a minute or two.

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.

More than one Mac

Install on each Mac the same way. Both Macs read and write the same history in iCloud Drive, under capi/.

Configure

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.

keywhat it setsdefault
CAPI_LEARN_LANGUAGEthe language Capi teachesBrazilian Portuguese
CAPI_NATIVE_LANGUAGEyour language: explanations, translations and grammar notesGerman
CAPI_NATIVE_FLAGthe icon on the translation button🇩🇪
CAPI_LEARNERwho you are, finishing the sentence "native German speaker who …"; sets the level of the cardssomeone four years in Brazil, about A2/B1
CAPI_TOPICSwhat the facts on the cards are about, comma separatedBrazil, science and nature, technology, history, everyday life, food, health and fitness
CAPI_PERSONSthe rows of the conjugation table, separated by `\``eu\você\ele/ela\nós\vocês`
CAPI_TENSESits columns, separated by `\``presente\pretérito perfeito\pretérito imperfeito\futuro\subjuntivo presente`
CAPI_SHOWwhen the band is there: always, or working for only while Claude worksalways
CAPI_PLACEwhere the card is drawn: band above the prompt, or pane, a panel beside the conversation like the terminal and browser in Claude Code Desktopband
CAPI_PANE_OPENwith 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 showauto
CAPI_NEW_PER_DAYnew expressions a day at most; after that only reviews and bonus cards, and 0 means reviews only25

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.

How it learns

  • The learning unit is the expression, not the sentence. When an expression is due again, Capi writes a new fact around it.
  • Leitner boxes: due again after 1, 3, 7, 30 and 90 days. A miss comes back after 20 minutes in the other language format (the payback round).
  • Placement: during the first week, an expression you already know jumps straight to box 4.
  • At most 25 new expressions a day (CAPI_NEW_PER_DAY), across all sessions and both Macs. After that, only reviews and bonus cards.

Models and cost

Every model call uses claude-opus-5-5 on your own Claude plan, so cards count against your usage like any other request:

calleffortwhen
ten new cardshighwhen fewer than 4 cards are queued; cards leave the queue only when answered
🚩 re-checkxhighonce per flag
🇩🇪 for an older cardlowonce per card, when 🇩🇪 is first pressed on a card made without translations
📐 grammar notesmediumonce per card, when 📐 is first pressed
🔤 conjugationslowonce 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).

Data

  • $.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.

Develop

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.

License

MIT

Source 7 files
hooks/register.js 833 lines
1// 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}
833
hooks/lib/srs.js 139 lines
1// 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}
139
hooks/lib/log.js 36 lines
1// 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}
36
hooks/lib/cards.js 223 lines
1// 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}
223
hooks/lib/extras.js 84 lines
1// 📐 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}
84
hooks/lib/config.js 64 lines
1// 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()
64
hooks/lib/cells.js 13 lines
1// 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