pi-ebb 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -2
  3. package/dist/index.js +1171 -0
  4. package/package.json +36 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rustam Giliaziev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,30 @@
1
- # Temporary Holding Version
1
+ # pi-ebb
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Spaced repetition in the background of [pi](https://pi.dev). It teaches you words, phrases or any other facts while you work.
4
+
5
+ ```text
6
+ el corazón │ alt+r Forgot alt+g Got it alt+s Skip
7
+ ```
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pi install npm:pi-ebb
13
+ ```
14
+
15
+ ## Use
16
+
17
+ - `/ebb install <file or URL>` adds a deck file, for example about 2000 words of Castilian Spanish with Russian translations: `/ebb install https://r13v.github.io/ebb/decks/es-ru.json`.
18
+ - `/ebb add <topic>` writes up to 9 new notes with the model of the session. Press `1` to `9` to tick a note, `a` to add the ticked notes, `x` or Escape to cancel.
19
+ - `/ebb decks`, `/ebb stats`, `/ebb pause [1h|30m|2d|off]`.
20
+
21
+ The card shows above the editor. The shortcuts never take a key the editor types:
22
+
23
+ - `alt+r`: Forgot, and Next after the answer.
24
+ - `alt+g`: Got it.
25
+ - `alt+s`: Skip.
26
+ - `alt+h`: hide or show the card.
27
+
28
+ The scheduler is FSRS-6. The data is in `$XDG_DATA_HOME/ebb/` (by default `~/.local/share/ebb/`), shared with the [Claude Code mod](https://github.com/r13v/ebb).
29
+
30
+ The full guide, the deck file format and the scheduling rules are in the [repository](https://github.com/r13v/ebb#readme).
package/dist/index.js ADDED
@@ -0,0 +1,1171 @@
1
+ import { homedir } from "node:os";
2
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ // core/decks.ts
6
+ var text = (v) => typeof v === "string" && v.trim() ? v.trim() : void 0;
7
+ /** The deck in a deck file, or why it is not one. */
8
+ var parseDeckFile = (source) => {
9
+ let v;
10
+ try {
11
+ v = JSON.parse(source);
12
+ } catch {
13
+ return { error: "the file is not JSON" };
14
+ }
15
+ const id = text(v.id)?.toLowerCase();
16
+ const name = text(v.name);
17
+ if (!id || !/^[a-z0-9-]+$/.test(id)) return { error: "the deck needs an \"id\" of lowercase letters, digits and hyphens" };
18
+ if (!name) return { error: "the deck needs a \"name\"" };
19
+ if (!Array.isArray(v.notes)) return { error: "the deck needs a \"notes\" list" };
20
+ const notes = [];
21
+ for (const n of v.notes) {
22
+ const front = text(n?.front);
23
+ const back = text(n?.back);
24
+ if (!front || !back) continue;
25
+ const tags = Array.isArray(n.tags) ? n.tags.filter((t) => typeof t === "string") : [];
26
+ notes.push({
27
+ front,
28
+ back,
29
+ hint: text(n.hint),
30
+ tags
31
+ });
32
+ }
33
+ if (!notes.length) return { error: "the deck has no notes with \"front\" and \"back\"" };
34
+ const learn = text(v.learn);
35
+ const native = text(v.native);
36
+ return {
37
+ deck: {
38
+ id,
39
+ name,
40
+ learn,
41
+ native,
42
+ guidance: text(v.guidance),
43
+ kinds: learn && native ? ["recognition", "production"] : ["recognition"]
44
+ },
45
+ notes
46
+ };
47
+ };
48
+ // core/generate.ts
49
+ var describeDeck = (deck) => deck.learn && deck.native ? `The deck teaches ${deck.learn} to a ${deck.native} speaker.
50
+ - front: the ${deck.learn} word or phrase.
51
+ - back: a short ${deck.native} translation.
52
+ - hint: optional, one short note in ${deck.native} on a trap. Omit it when there is none.${deck.guidance ? `\n${deck.guidance}` : ""}` : `Deck: ${deck.name}. front: a short question or cue; back: the short answer.${deck.guidance ? `\n${deck.guidance}` : ""}`;
53
+ var generationPrompt = (deck, topic, existing) => ({
54
+ system: `You write spaced-repetition notes. ${describeDeck(deck)}
55
+ One fact per note. Prefer frequent, useful items. Reply with JSON only, no prose and no code fence:
56
+ {"notes":[{"front":"...","back":"...","hint":"...","tags":["..."]}]}`,
57
+ prompt: `Topic or list: ${topic}
58
+ Write up to 9 notes. Skip these, the deck has them: ${existing.slice(0, 300).join(", ") || "none"}`
59
+ });
60
+ /** The notes of a model reply, or why there are none. */
61
+ var parseNotes = (text) => {
62
+ const start = text.indexOf("{");
63
+ const end = text.lastIndexOf("}");
64
+ if (start < 0 || end <= start) return { error: "the reply has no JSON" };
65
+ let value;
66
+ try {
67
+ value = JSON.parse(text.slice(start, end + 1));
68
+ } catch {
69
+ return { error: "the reply has broken JSON" };
70
+ }
71
+ const list = value.notes;
72
+ if (!Array.isArray(list)) return { error: "the JSON has no notes list" };
73
+ const notes = [];
74
+ for (const item of list) {
75
+ if (typeof item !== "object" || item === null) continue;
76
+ const { front, back, hint, tags } = item;
77
+ if (typeof front !== "string" || typeof back !== "string" || !front.trim() || !back.trim()) continue;
78
+ notes.push({
79
+ front: front.trim(),
80
+ back: back.trim(),
81
+ hint: typeof hint === "string" && hint.trim() ? hint.trim() : void 0,
82
+ tags: Array.isArray(tags) ? tags.filter((t) => typeof t === "string") : []
83
+ });
84
+ }
85
+ return notes.length ? { notes } : { error: "the reply has no notes" };
86
+ };
87
+ /** Back-translation of a language deck: the model sees only the backs and writes the fronts. */
88
+ var backTranslationPrompt = (deck, notes) => ({
89
+ system: `Translate each item into ${deck.learn}.${deck.guidance ? ` ${deck.guidance}` : ""} Reply with JSON only: {"fronts":["..."]}, same order and length.`,
90
+ prompt: JSON.stringify(notes.map((n) => n.back))
91
+ });
92
+ var normalize = (s) => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[¿?¡!.,;:()"«»]/g, "").replace(/\s+/g, " ").trim();
93
+ /** Flags the notes whose back-translation does not land on the front; undefined when the reply has no fronts. */
94
+ var flagMismatches = (notes, reply) => {
95
+ let fronts;
96
+ try {
97
+ fronts = JSON.parse(reply.slice(reply.indexOf("{"), reply.lastIndexOf("}") + 1)).fronts;
98
+ } catch {
99
+ return;
100
+ }
101
+ if (!Array.isArray(fronts)) return void 0;
102
+ return notes.map((note, i) => {
103
+ const back = fronts[i];
104
+ if (typeof back !== "string") return {
105
+ ...note,
106
+ isChecked: true
107
+ };
108
+ const a = normalize(note.front);
109
+ const b = normalize(back);
110
+ const words = new Set(a.split(/[ /]/));
111
+ return a === b || a.includes(b) || b.includes(a) || b.split(/[ /]/).some((w) => w.length > 3 && words.has(w)) ? {
112
+ ...note,
113
+ isChecked: true
114
+ } : {
115
+ ...note,
116
+ isChecked: false,
117
+ flag: `back-translation: ${back}`
118
+ };
119
+ });
120
+ };
121
+ var deckPrompt = (request, recent) => ({
122
+ system: `A person asks a flashcard app for notes. Decide the deck they need. Reply with JSON only:
123
+ {"id":"short-lowercase-slug","name":"Short deck name","learn":"the language the person learns, or null when the notes are not about a language","native":"the language of the answers, or null"}
124
+ The language of the answers is the language the person writes in, in the request or in their recent messages. When the request is about a language but you cannot tell the person's own language, "native" is null.`,
125
+ prompt: `Request: ${request}\nRecent messages: ${JSON.stringify(recent.slice(-3))}`
126
+ });
127
+ var parseDeckReply = (text) => {
128
+ try {
129
+ const v = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
130
+ const id = typeof v.id === "string" ? v.id.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "") : "";
131
+ if (!id) return void 0;
132
+ const text_ = (x) => typeof x === "string" && x.trim() ? x.trim() : null;
133
+ return {
134
+ id,
135
+ name: text_(v.name) ?? id,
136
+ learn: text_(v.learn),
137
+ native: text_(v.native)
138
+ };
139
+ } catch {
140
+ return;
141
+ }
142
+ };
143
+ // core/fsrs.ts
144
+ var DEFAULT_WEIGHTS = [
145
+ .212,
146
+ 1.2931,
147
+ 2.3065,
148
+ 8.2956,
149
+ 6.4133,
150
+ .8334,
151
+ 3.0194,
152
+ .001,
153
+ 1.8722,
154
+ .1666,
155
+ .796,
156
+ 1.4835,
157
+ .0614,
158
+ .2629,
159
+ 1.6483,
160
+ .6014,
161
+ 1.8729,
162
+ .5425,
163
+ .0912,
164
+ .0658,
165
+ .1542
166
+ ];
167
+ var MINUTE = 6e4;
168
+ var DAY$1 = 864e5;
169
+ var S_MIN = .001;
170
+ var newSchedule = (now) => ({
171
+ state: 0,
172
+ step: null,
173
+ stability: null,
174
+ difficulty: null,
175
+ due: now,
176
+ lastReview: null
177
+ });
178
+ var clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
179
+ function scheduler(options = {}) {
180
+ const w = options.weights ?? DEFAULT_WEIGHTS;
181
+ const retention = options.desiredRetention ?? .9;
182
+ const learning = options.learningSteps ?? [1 * MINUTE, 10 * MINUTE];
183
+ const relearning = options.relearningSteps ?? [10 * MINUTE];
184
+ const maxIvl = options.maximumInterval ?? 36500;
185
+ const decay = -w[20];
186
+ const factor = .9 ** (1 / decay) - 1;
187
+ const retrievability = (s, now) => {
188
+ if (s.lastReview === null || s.stability === null) return 0;
189
+ const days = Math.max(0, Math.floor((now - s.lastReview) / DAY$1));
190
+ return (1 + factor * days / s.stability) ** decay;
191
+ };
192
+ const initialStability = (r) => Math.max(w[r - 1], S_MIN);
193
+ const rawInitialDifficulty = (r) => w[4] - Math.exp(w[5] * (r - 1)) + 1;
194
+ const initialDifficulty = (r) => clamp(rawInitialDifficulty(r), 1, 10);
195
+ const nextDifficulty = (d, r) => {
196
+ const delta = -(w[6] * (r - 3));
197
+ const damped = d + (10 - d) * delta / 9;
198
+ return clamp(w[7] * rawInitialDifficulty(4) + (1 - w[7]) * damped, 1, 10);
199
+ };
200
+ const shortTermStability = (s, r) => {
201
+ let inc = Math.exp(w[17] * (r - 3 + w[18])) * s ** -w[19];
202
+ if (r >= 3) inc = Math.max(inc, 1);
203
+ return Math.max(s * inc, S_MIN);
204
+ };
205
+ const nextStability = (d, s, rr, r) => {
206
+ if (r === 1) {
207
+ const longTerm = w[11] * d ** -w[12] * ((s + 1) ** w[13] - 1) * Math.exp((1 - rr) * w[14]);
208
+ const shortTerm = s / Math.exp(w[17] * w[18]);
209
+ return Math.max(Math.min(longTerm, shortTerm), S_MIN);
210
+ }
211
+ const hard = r === 2 ? w[15] : 1;
212
+ const easy = r === 4 ? w[16] : 1;
213
+ const grown = s * (1 + Math.exp(w[8]) * (11 - d) * s ** -w[9] * (Math.exp((1 - rr) * w[10]) - 1) * hard * easy);
214
+ return Math.max(grown, S_MIN);
215
+ };
216
+ const interval = (s) => {
217
+ return clamp(Math.round(s / factor * (retention ** (1 / decay) - 1)), 1, maxIvl);
218
+ };
219
+ const fuzz = (days) => {
220
+ if (!options.random || days < 2.5) return days;
221
+ const ranges = [
222
+ [
223
+ 2.5,
224
+ 7,
225
+ .15
226
+ ],
227
+ [
228
+ 7,
229
+ 20,
230
+ .1
231
+ ],
232
+ [
233
+ 20,
234
+ Infinity,
235
+ .05
236
+ ]
237
+ ];
238
+ let delta = 1;
239
+ for (const [lo, hi, f] of ranges) delta += f * Math.max(Math.min(days, hi) - lo, 0);
240
+ const lo = Math.max(2, Math.round(days - delta));
241
+ const hi = Math.min(Math.round(days + delta), maxIvl);
242
+ return Math.min(Math.round(options.random() * (hi - lo + 1) + lo), maxIvl);
243
+ };
244
+ const stepDelay = (steps, step, r) => {
245
+ if (r === 2 && step === 0) return steps.length === 1 ? steps[0] * 1.5 : (steps[0] + steps[1]) / 2;
246
+ return steps[step];
247
+ };
248
+ /** The schedule after grading `s` with `r` at `now`. */
249
+ const review = (s, r, now) => {
250
+ let stability;
251
+ let difficulty;
252
+ if (s.stability === null || s.difficulty === null) {
253
+ stability = initialStability(r);
254
+ difficulty = initialDifficulty(r);
255
+ } else {
256
+ stability = s.lastReview !== null && now - s.lastReview < DAY$1 ? shortTermStability(s.stability, r) : nextStability(s.difficulty, s.stability, retrievability(s, now), r);
257
+ difficulty = nextDifficulty(s.difficulty, r);
258
+ }
259
+ let state = s.state === 0 ? 1 : s.state;
260
+ let step = s.state === 0 ? 0 : s.step;
261
+ let delay;
262
+ const graduate = () => {
263
+ state = 2;
264
+ step = null;
265
+ return interval(stability) * DAY$1;
266
+ };
267
+ if (state === 2) {
268
+ if (r === 1 && relearning.length > 0) {
269
+ state = 3;
270
+ step = 0;
271
+ delay = relearning[0];
272
+ } else delay = graduate();
273
+ } else {
274
+ const steps = state === 1 ? learning : relearning;
275
+ const at = step ?? 0;
276
+ if (steps.length === 0 || at >= steps.length && r > 1) delay = graduate();
277
+ else if (r === 1) {
278
+ step = 0;
279
+ delay = steps[0];
280
+ } else if (r === 2) delay = stepDelay(steps, at, r);
281
+ else if (r === 3) {
282
+ if (at + 1 >= steps.length) delay = graduate();
283
+ else {
284
+ step = at + 1;
285
+ delay = steps[at + 1];
286
+ }
287
+ } else delay = graduate();
288
+ }
289
+ if (state === 2 && options.random) delay = fuzz(delay / DAY$1) * DAY$1;
290
+ return {
291
+ state,
292
+ step,
293
+ stability,
294
+ difficulty,
295
+ due: now + delay,
296
+ lastReview: now
297
+ };
298
+ };
299
+ return { review };
300
+ }
301
+ // core/model.ts
302
+ var DAY = 864e5;
303
+ var emptyData = () => ({
304
+ version: 1,
305
+ decks: {},
306
+ notes: {},
307
+ cards: {},
308
+ meta: {}
309
+ });
310
+ /** New recognition cards a deck brings each day, as in Anki. */
311
+ var NEW_PER_DAY = 20;
312
+ var OLD_NEW_PER_DAY = 10;
313
+ /**
314
+ * Reads data that earlier builds wrote. The first build kept one daily counter for all decks;
315
+ * it was the Spanish deck's. A deck still on the old default of 10 new cards a day gets the new one.
316
+ */
317
+ var upgrade = (data) => {
318
+ for (const deck of Object.values(data.decks)) if (deck.newPerDay === OLD_NEW_PER_DAY) deck.newPerDay = NEW_PER_DAY;
319
+ const old = data.meta;
320
+ if (old.newDay !== void 0) {
321
+ data.meta.newByDeck ??= { es: {
322
+ day: old.newDay,
323
+ count: old.newToday ?? 0
324
+ } };
325
+ delete old.newDay;
326
+ delete old.newToday;
327
+ }
328
+ return data;
329
+ };
330
+ /** FNV-1a, hex: a stable id for a note from its deck and front. */
331
+ var noteId = (deck, front) => {
332
+ let h = 2166136261;
333
+ for (const ch of `${deck}\u0000${front.trim().toLowerCase()}`) {
334
+ h ^= ch.codePointAt(0);
335
+ h = Math.imul(h, 16777619) >>> 0;
336
+ }
337
+ return h.toString(16).padStart(8, "0");
338
+ };
339
+ var face = (data, card) => {
340
+ const note = data.notes[card.note];
341
+ if (!note) return void 0;
342
+ return card.kind === "recognition" ? {
343
+ card,
344
+ note,
345
+ ask: note.front,
346
+ answer: note.back,
347
+ hint: note.hint
348
+ } : {
349
+ card,
350
+ note,
351
+ ask: note.back,
352
+ answer: note.front,
353
+ hint: note.hint
354
+ };
355
+ };
356
+ var ensureDeck = (data, deck) => {
357
+ data.decks[deck.id] ??= {
358
+ newPerDay: NEW_PER_DAY,
359
+ kinds: ["recognition", "production"],
360
+ productionUnlockDays: 21,
361
+ ...deck
362
+ };
363
+ return data.decks[deck.id];
364
+ };
365
+ /**
366
+ * Adds notes not yet in the deck, each with its recognition card. Returns how many were new.
367
+ * With `merge`, a note whose front exists already adds its meanings, hint and tags to that note
368
+ * (one word in two word lists, such as `mejor` the adjective and the adverb).
369
+ */
370
+ var addNotes = (data, deckId, notes, source, now, merge = false) => {
371
+ const deck = data.decks[deckId];
372
+ if (!deck) throw new Error(`no deck ${deckId}`);
373
+ let order = Object.values(data.notes).filter((n) => n.deck === deckId).length;
374
+ let added = 0;
375
+ for (const input of notes) {
376
+ const front = input.front.trim();
377
+ const back = input.back.trim();
378
+ if (!front || !back) continue;
379
+ const key = front.toLowerCase();
380
+ let id = noteId(deckId, front);
381
+ for (let n = 1; data.notes[id] && data.notes[id].front.toLowerCase() !== key; n++) id = `${noteId(deckId, front)}-${n}`;
382
+ const existing = data.notes[id];
383
+ if (existing) {
384
+ if (merge) {
385
+ const backs = existing.back.split("; ");
386
+ for (const part of back.split("; ")) if (!backs.includes(part)) backs.push(part);
387
+ existing.back = backs.join("; ");
388
+ existing.hint ??= input.hint?.trim() || void 0;
389
+ existing.tags = [.../* @__PURE__ */ new Set([...existing.tags, ...input.tags ?? []])];
390
+ }
391
+ continue;
392
+ }
393
+ data.notes[id] = {
394
+ id,
395
+ deck: deckId,
396
+ front,
397
+ back,
398
+ hint: input.hint?.trim() || void 0,
399
+ tags: input.tags ?? [],
400
+ order: order++,
401
+ source,
402
+ createdAt: now
403
+ };
404
+ const first = deck.kinds.includes("recognition") ? "recognition" : deck.kinds[0];
405
+ data.cards[`${id}:${first[0]}`] = {
406
+ ...newSchedule(now),
407
+ id: `${id}:${first[0]}`,
408
+ note: id,
409
+ kind: first,
410
+ reps: 0,
411
+ lapses: 0
412
+ };
413
+ added++;
414
+ }
415
+ return added;
416
+ };
417
+ var isNew = (c) => c.state === 0;
418
+ var localDay = (now) => new Date(now).toDateString();
419
+ var newLeftToday = (data, deck, now) => {
420
+ const today = data.meta.newByDeck?.[deck.id];
421
+ return today?.day === localDay(now) ? Math.max(0, deck.newPerDay - today.count) : deck.newPerDay;
422
+ };
423
+ var counts = (data, now) => {
424
+ let due = 0;
425
+ let fresh = 0;
426
+ for (const c of Object.values(data.cards)) if (isNew(c)) fresh++;
427
+ else if (c.due <= now) due++;
428
+ return {
429
+ due,
430
+ new: fresh
431
+ };
432
+ };
433
+ var byOrder = (data) => (a, b) => (data.notes[a.note]?.order ?? 0) - (data.notes[b.note]?.order ?? 0);
434
+ /**
435
+ * The next card for the band, or none only when every card is skipped.
436
+ * By schedule first: due cards, new recognition cards within the daily limit, new production
437
+ * cards. Then ahead of schedule, so the band never runs dry: new cards past the limit, in deck
438
+ * order, then the card that comes due soonest. A learning card waits for its step (1 or 10
439
+ * minutes) like any other: shown early, it would come straight back after its grade.
440
+ * A production card is of a word the person knows already, so it takes none of the daily limit:
441
+ * otherwise a mature deck would fill the limit with them and stop new words.
442
+ * With `isNewTurn`, a new card within the limit goes before the due ones: the band alternates
443
+ * them, as Anki mixes new cards with reviews, so a run of due cards never stops new words.
444
+ */
445
+ var nextCard = (data, now, skip = /* @__PURE__ */ new Set(), isNewTurn = false) => {
446
+ const cards = Object.values(data.cards).filter((c) => !skip.has(c.id) && data.notes[c.note]);
447
+ const fresh = cards.filter(isNew).sort(byOrder(data));
448
+ const freshToday = fresh.find((c) => c.kind === "recognition" && newLeftToday(data, data.decks[data.notes[c.note].deck], now) > 0);
449
+ if (isNewTurn && freshToday) return freshToday;
450
+ const due = cards.filter((c) => !isNew(c) && c.due <= now).sort((a, b) => a.due - b.due);
451
+ if (due[0]) return due[0];
452
+ if (freshToday) return freshToday;
453
+ const production = fresh.find((c) => c.kind === "production");
454
+ if (production) return production;
455
+ const soonest = cards.filter((c) => !isNew(c)).sort((a, b) => a.due - b.due);
456
+ return fresh[0] ?? soonest[0];
457
+ };
458
+ /** Grades a card: the new schedule, its log row, and a production card if this unlocked one. */
459
+ var grade = (data, cardId, rating, now, random, durationMs) => {
460
+ const before = data.cards[cardId];
461
+ if (!before) throw new Error(`no card ${cardId}`);
462
+ const note = data.notes[before.note];
463
+ const deck = data.decks[note.deck];
464
+ const next = scheduler({ random }).review(before, rating, now);
465
+ const card = {
466
+ ...before,
467
+ ...next,
468
+ reps: before.reps + 1,
469
+ lapses: before.lapses + (rating === 1 && before.state === 2 ? 1 : 0)
470
+ };
471
+ data.cards[cardId] = card;
472
+ if (before.state === 0 && before.kind === "recognition") {
473
+ const day = localDay(now);
474
+ data.meta.newByDeck ??= {};
475
+ const byDeck = data.meta.newByDeck;
476
+ const today = byDeck[deck.id];
477
+ byDeck[deck.id] = {
478
+ day,
479
+ count: today?.day === day ? today.count + 1 : 1
480
+ };
481
+ }
482
+ const log = {
483
+ ts: now,
484
+ card: cardId,
485
+ rating,
486
+ state: before.state,
487
+ stability: before.stability,
488
+ difficulty: before.difficulty,
489
+ scheduledMs: card.due - now,
490
+ durationMs
491
+ };
492
+ let unlocked;
493
+ const prodId = `${note.id}:p`;
494
+ if (card.kind === "recognition" && deck.kinds.includes("production") && !data.cards[prodId] && (card.stability ?? 0) >= deck.productionUnlockDays) {
495
+ unlocked = {
496
+ ...newSchedule(now),
497
+ id: prodId,
498
+ note: note.id,
499
+ kind: "production",
500
+ reps: 0,
501
+ lapses: 0
502
+ };
503
+ data.cards[prodId] = unlocked;
504
+ }
505
+ return {
506
+ card,
507
+ log,
508
+ unlocked
509
+ };
510
+ };
511
+ /** `1h`, `30m`, `2d` → ms; undefined when it does not parse. */
512
+ var parseDuration = (text) => {
513
+ const m = /^(\d+(?:\.\d+)?)\s*(m|min|h|d)$/i.exec(text.trim());
514
+ if (!m) return void 0;
515
+ const unit = {
516
+ m: 6e4,
517
+ min: 6e4,
518
+ h: 36e5,
519
+ d: DAY
520
+ }[m[2].toLowerCase()];
521
+ return Number(m[1]) * unit;
522
+ };
523
+ var monthKey = (now) => {
524
+ const d = new Date(now);
525
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
526
+ };
527
+ // core/ebb.ts
528
+ /** Where both hosts keep the data: `$XDG_DATA_HOME/ebb`, by default `~/.local/share/ebb`. */
529
+ var dataDir = (home, xdgDataHome) => `${xdgDataHome || `${home}/.local/share`}/ebb`;
530
+ var HELP = `/ebb decks — your decks
531
+ /ebb install <file or URL> — add a deck file
532
+ /ebb add [@deck] <topic or word list> — write new notes with the model; a new @deck is made on first use
533
+ /ebb pause [1h|30m|2d|off] — no card above the prompt for a while
534
+ /ebb stats — notes, due cards and new cards`;
535
+ var LOCK_STALE_MS = 1e4;
536
+ var RECENT_PROMPTS = 3;
537
+ /** The deck a new deck becomes: a language deck asks both ways, a deck of facts one way. */
538
+ var deckFrom = (d) => d.learn ? {
539
+ ...d,
540
+ kinds: ["recognition", "production"]
541
+ } : {
542
+ id: d.id,
543
+ name: d.name,
544
+ kinds: ["recognition"]
545
+ };
546
+ /** One session of a host: its caches, its own log file, the card in its band and the cards it skipped. */
547
+ var createSession = () => {
548
+ const sessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
549
+ const dataPath = (host) => `${host.dir}/data.json`;
550
+ let cache;
551
+ /** The data as on disk, read again only when another session changed the file. */
552
+ const load = async (host) => {
553
+ const path = dataPath(host);
554
+ if (!await host.exists(path)) {
555
+ cache = {
556
+ data: emptyData(),
557
+ mtimeMs: 0
558
+ };
559
+ return cache.data;
560
+ }
561
+ const mtimeMs = await host.mtimeMs(path);
562
+ if (cache && cache.mtimeMs === mtimeMs) return cache.data;
563
+ const data = upgrade(JSON.parse(await host.read(path)));
564
+ cache = {
565
+ data,
566
+ mtimeMs
567
+ };
568
+ return data;
569
+ };
570
+ /**
571
+ * Runs `work` under the lock of the data directory, so two sessions never lose each other's writes.
572
+ * `mkdir` takes the lock atomically and an owner file inside names who holds it.
573
+ * A lock older than 10 s is taken as left by a dead session: it is renamed away first,
574
+ * so only one waiter removes it.
575
+ */
576
+ const withLock = async (host, work) => {
577
+ const lock = `${host.dir}/lock`;
578
+ const token = `${sessionId}-${Math.random().toString(36).slice(2, 8)}`;
579
+ await host.mkdirp(host.dir);
580
+ for (let waited = 0;; waited += 100) {
581
+ if (await host.mkdir(lock)) break;
582
+ const heldSince = await host.mtimeMs(lock).catch(() => void 0);
583
+ if (heldSince !== void 0 && Date.now() - heldSince > LOCK_STALE_MS) {
584
+ const aside = `${lock}.stale-${token}`;
585
+ if (await host.rename(lock, aside)) await host.remove(aside);
586
+ continue;
587
+ }
588
+ if (waited > 12e3) throw new Error("данные заняты другой сессией, попробуйте ещё раз");
589
+ await host.sleep(100);
590
+ }
591
+ await host.write(`${lock}/owner`, token);
592
+ try {
593
+ return await work();
594
+ } finally {
595
+ if (await host.read(`${lock}/owner`).catch(() => void 0) === token) await host.remove(lock);
596
+ }
597
+ };
598
+ /** Replaces a file by an atomic rename, so a reader never sees half of it. */
599
+ const replaceFile = async (host, path, text) => {
600
+ await host.write(`${path}.tmp`, text);
601
+ if (!await host.rename(`${path}.tmp`, path)) throw new Error(`не удалось сохранить ${path}`);
602
+ };
603
+ /** Changes `data.json` under the lock, on a copy read again inside it. The only writer of the file. */
604
+ const mutate = (host, change) => withLock(host, async () => {
605
+ try {
606
+ cache = void 0;
607
+ const data = await load(host);
608
+ const result = change(data);
609
+ const path = dataPath(host);
610
+ await replaceFile(host, path, JSON.stringify(data));
611
+ cache = {
612
+ data,
613
+ mtimeMs: await host.mtimeMs(path)
614
+ };
615
+ return result;
616
+ } catch (error) {
617
+ cache = void 0;
618
+ throw error;
619
+ }
620
+ });
621
+ let log;
622
+ let logWrite = Promise.resolve();
623
+ /** Appends a review to this session's own log for the month. */
624
+ const appendLog = async (host, review) => {
625
+ const path = `${host.dir}/log/${monthKey(review.ts)}/${sessionId}.json`;
626
+ if (log?.key !== path) log = {
627
+ key: path,
628
+ value: { reviews: [] }
629
+ };
630
+ log.value.reviews.push(review);
631
+ const text = JSON.stringify(log.value);
632
+ logWrite = logWrite.catch(() => void 0).then(() => host.write(path, text));
633
+ await logWrite;
634
+ };
635
+ let band = {
636
+ cardId: null,
637
+ isRevealed: false,
638
+ shownAt: 0
639
+ };
640
+ const setBand = async (host, next) => {
641
+ band = next;
642
+ await host.showBand(band);
643
+ };
644
+ const skipped = /* @__PURE__ */ new Set();
645
+ let chosenWith = false;
646
+ let wasNew = false;
647
+ /**
648
+ * Puts the next card in the band, with its answer hidden. `previous`, the card just graded or
649
+ * skipped, never comes straight back while another card is left. `isRepick` chooses again for
650
+ * the same turn, so a card that came due can take the band's place without moving the turns.
651
+ */
652
+ const showNextCard = async (host, previous, isRepick = false) => {
653
+ try {
654
+ const data = await load(host);
655
+ const now = Date.now();
656
+ const avoid = previous ? [previous] : [];
657
+ const isNewTurn = isRepick ? chosenWith : !wasNew;
658
+ let card = nextCard(data, now, /* @__PURE__ */ new Set([...skipped, ...avoid]), isNewTurn);
659
+ if (!card) {
660
+ skipped.clear();
661
+ card = nextCard(data, now, new Set(avoid), isNewTurn) ?? nextCard(data, now);
662
+ }
663
+ chosenWith = isNewTurn;
664
+ wasNew = card?.state === 0;
665
+ await setBand(host, {
666
+ cardId: card?.id ?? null,
667
+ isRevealed: false,
668
+ shownAt: now
669
+ });
670
+ return;
671
+ } catch (error) {
672
+ await setBand(host, {
673
+ cardId: null,
674
+ isRevealed: false,
675
+ shownAt: 0
676
+ });
677
+ return String(error);
678
+ }
679
+ };
680
+ const skip = async (host) => {
681
+ if (band.cardId) skipped.add(band.cardId);
682
+ await showNextCard(host, band.cardId ?? void 0);
683
+ };
684
+ /** After Forgot showed the answer: the next card. */
685
+ const next = (host) => showNextCard(host, band.cardId ?? void 0);
686
+ let isGrading = false;
687
+ /** Grades the card in the band; a forgotten card shows its answer until `next`. Throws when the grade was not saved. */
688
+ const gradeCard = async (host, rating) => {
689
+ const graded = band;
690
+ if (isGrading || !graded.cardId || graded.isRevealed) return;
691
+ isGrading = true;
692
+ const cardId = graded.cardId;
693
+ try {
694
+ const result = await mutate(host, (data) => grade(data, cardId, rating, Date.now(), Math.random, Date.now() - graded.shownAt));
695
+ await appendLog(host, result.log);
696
+ skipped.delete(cardId);
697
+ if (rating === 1) await setBand(host, {
698
+ ...band,
699
+ isRevealed: true
700
+ });
701
+ else await showNextCard(host, cardId);
702
+ } finally {
703
+ isGrading = false;
704
+ }
705
+ };
706
+ /** At the end of a turn a card may have come due; an open answer stays as it is. */
707
+ const endTurn = async (host) => {
708
+ if (!band.isRevealed) await showNextCard(host, void 0, true);
709
+ };
710
+ /** What the band draws now; `shown` is the band as the host keeps it. A reader on every render: it never throws. */
711
+ const bandView = async (host, shown = band) => {
712
+ let data;
713
+ try {
714
+ data = await load(host);
715
+ } catch (error) {
716
+ return {
717
+ kind: "error",
718
+ error: String(error)
719
+ };
720
+ }
721
+ if (!Object.keys(data.decks).length) return { kind: "no-deck" };
722
+ const card = shown.cardId ? data.cards[shown.cardId] : void 0;
723
+ const f = card && face(data, card);
724
+ if ((data.meta.pausedUntil ?? 0) > Date.now() || !f) return { kind: "none" };
725
+ return {
726
+ kind: "card",
727
+ face: f,
728
+ isRevealed: shown.isRevealed
729
+ };
730
+ };
731
+ /**
732
+ * `/ebb install <path or URL>`: adds a deck file. Installing it again adds the notes that are
733
+ * new and the meanings that are new; cards keep their schedule.
734
+ */
735
+ const installDeck = async (host, where) => {
736
+ if (!where) return "Name a deck file or URL: /ebb install ~/decks/spanish.json";
737
+ let source;
738
+ if (/^https?:\/\//.test(where)) {
739
+ const r = await host.fetch(where);
740
+ if (!r.ok) return `Could not download the deck: HTTP ${r.status}`;
741
+ source = r.text;
742
+ } else {
743
+ const path = where.startsWith("~/") ? `${host.home}${where.slice(1)}` : where;
744
+ if (!await host.exists(path)) return `No file ${path}`;
745
+ source = await host.read(path);
746
+ }
747
+ const file = parseDeckFile(source);
748
+ if ("error" in file) return `Not a deck: ${file.error}`;
749
+ const added = await mutate(host, (d) => {
750
+ ensureDeck(d, file.deck);
751
+ return addNotes(d, file.deck.id, file.notes, `install: ${where}`, Date.now(), true);
752
+ });
753
+ await showNextCard(host);
754
+ return `Installed ${file.deck.name}: ${added} new notes. Its cards show above the prompt.`;
755
+ };
756
+ const listDecks = async (host) => {
757
+ const data = await load(host);
758
+ const now = Date.now();
759
+ const rows = Object.values(data.decks).map((d) => {
760
+ const cards = Object.values(data.cards).filter((c) => data.notes[c.note]?.deck === d.id);
761
+ const due = cards.filter((c) => c.state !== 0 && c.due <= now).length;
762
+ const fresh = cards.filter((c) => c.state === 0).length;
763
+ return `${d.id} — ${d.name}: ${cards.length} cards, ${due} due, ${fresh} new`;
764
+ });
765
+ return [
766
+ ...rows.length ? rows : ["No deck yet."],
767
+ "",
768
+ "Make one: /ebb add <topic>. Install one: /ebb install <file or URL>."
769
+ ].join("\n");
770
+ };
771
+ const stats = async (host) => {
772
+ const data = await load(host);
773
+ const c = counts(data, Date.now());
774
+ const paused = (data.meta.pausedUntil ?? 0) > Date.now() ? " · paused" : "";
775
+ return `Notes: ${Object.keys(data.notes).length} · due: ${c.due} · new: ${c.new}${paused}`;
776
+ };
777
+ const pause = async (host, tail) => {
778
+ if (tail === "off") {
779
+ await mutate(host, (data) => void delete data.meta.pausedUntil);
780
+ await host.showBand(band);
781
+ return "Cards are back above the prompt.";
782
+ }
783
+ const ms = tail ? parseDuration(tail) : 36e5;
784
+ if (!ms) return "Use /ebb pause 1h, 30m, 2d or off";
785
+ const until = Date.now() + ms;
786
+ await mutate(host, (data) => {
787
+ data.meta.pausedUntil = until;
788
+ });
789
+ await host.showBand(band);
790
+ return `Paused until ${new Date(until).toLocaleTimeString(void 0, {
791
+ hour: "2-digit",
792
+ minute: "2-digit"
793
+ })}.`;
794
+ };
795
+ /** `/ebb <args>`: the text to show. `add` belongs to the host, which draws its list. */
796
+ const runCommand = async (host, args, add) => {
797
+ const [sub = "", ...rest] = args.trim().split(/\s+/);
798
+ const tail = rest.join(" ");
799
+ switch (sub) {
800
+ case "decks": return listDecks(host);
801
+ case "install": return installDeck(host, tail);
802
+ case "add": return tail ? add(tail) : "Name a topic: /ebb add ordering at a bar";
803
+ case "pause": return pause(host, tail);
804
+ case "stats": return stats(host);
805
+ default: return `${await stats(host)}\n\n${HELP}`;
806
+ }
807
+ };
808
+ const recentPrompts = [];
809
+ const notePrompt = (text) => {
810
+ if (text.trimStart().startsWith("/")) return;
811
+ recentPrompts.push(text.slice(0, 300));
812
+ if (recentPrompts.length > RECENT_PROMPTS) recentPrompts.shift();
813
+ };
814
+ /**
815
+ * The start of `/ebb add [@deck] <topic>`: the view to show and the deck, when it exists. Text
816
+ * when the request cannot go on. A new deck is saved only with its first notes (`commitAdd`).
817
+ */
818
+ const planAdd = async (host, args) => {
819
+ const match = /^@([\w-]+)\s+(.+)$/u.exec(args);
820
+ const named = match?.[1];
821
+ const topic = match ? match[2] : args;
822
+ const data = await load(host);
823
+ const decks = Object.values(data.decks);
824
+ if (!named && decks.length > 1) return { text: `Name the deck: /ebb add @<deck> ${topic}. Decks: ${decks.map((d) => d.id).join(", ")}.` };
825
+ const onlyDeck = decks.length === 1 ? decks[0] : void 0;
826
+ const deck = named ? data.decks[named] : onlyDeck;
827
+ return {
828
+ view: {
829
+ deck: deck?.id ?? named ?? "",
830
+ topic,
831
+ candidates: [],
832
+ status: "Reading the request…"
833
+ },
834
+ deck,
835
+ named
836
+ };
837
+ };
838
+ /** A new deck from the request and the person's recent prompts; `native` stays empty when unclear. */
839
+ const askDeck = async (host, args, named) => {
840
+ const r = await host.complete({
841
+ ...deckPrompt(args, recentPrompts),
842
+ maxTokens: 300,
843
+ timeoutMs: 6e4
844
+ });
845
+ const reply = "text" in r ? parseDeckReply(r.text) : void 0;
846
+ return {
847
+ id: named ?? reply?.id ?? "notes",
848
+ name: reply?.name ?? named ?? "Notes",
849
+ learn: reply?.learn ?? void 0,
850
+ native: reply?.native ?? void 0
851
+ };
852
+ };
853
+ /**
854
+ * Up to 9 new notes on the topic. In a language deck a second call translates them back; a note
855
+ * that does not match is flagged and unticked. Notes the deck has are marked and unticked.
856
+ */
857
+ const draftNotes = async (host, deck, topic, onStatus) => {
858
+ const data = await load(host);
859
+ await onStatus("Writing notes…");
860
+ const existing = Object.values(data.notes).filter((n) => n.deck === deck.id).map((n) => n.front);
861
+ const asDeck = {
862
+ ...deckFrom(deck),
863
+ ...deck
864
+ };
865
+ const made = await host.complete({
866
+ ...generationPrompt(asDeck, topic, existing),
867
+ maxTokens: 3e3,
868
+ timeoutMs: 12e4
869
+ });
870
+ if ("error" in made) return {
871
+ candidates: [],
872
+ status: `The model did not answer: ${made.error}`
873
+ };
874
+ const parsed = parseNotes(made.text);
875
+ if ("error" in parsed) return {
876
+ candidates: [],
877
+ status: parsed.error
878
+ };
879
+ const notes = parsed.notes.slice(0, 9);
880
+ const known = new Set(existing.map((f) => f.toLowerCase()));
881
+ let candidates = notes.map((n) => ({
882
+ ...n,
883
+ isChecked: true
884
+ }));
885
+ let status;
886
+ if (deck.learn) {
887
+ await onStatus("Checking by back-translation…");
888
+ const checked = await host.complete({
889
+ ...backTranslationPrompt(asDeck, notes),
890
+ maxTokens: 1500,
891
+ timeoutMs: 6e4
892
+ });
893
+ const flagged = "text" in checked ? flagMismatches(notes, checked.text) : void 0;
894
+ candidates = flagged ?? notes.map((n) => ({
895
+ ...n,
896
+ isChecked: false
897
+ }));
898
+ if (!flagged) status = "The back-translation failed: check the notes yourself and tick the good ones";
899
+ }
900
+ candidates = candidates.map((c) => known.has(c.front.toLowerCase()) ? {
901
+ ...c,
902
+ isDuplicate: true,
903
+ isChecked: false
904
+ } : c);
905
+ return {
906
+ candidates,
907
+ status
908
+ };
909
+ };
910
+ /** Adds the ticked notes, and the new deck with them; the band then takes a card. Returns how many were added. */
911
+ const commitAdd = async (host, view) => {
912
+ const chosen = view.candidates.filter((c) => c.isChecked && !c.isDuplicate);
913
+ const added = await mutate(host, (data) => {
914
+ if (view.newDeck) ensureDeck(data, deckFrom(view.newDeck));
915
+ return addNotes(data, view.deck, chosen, `ebb add: ${view.topic}`, Date.now());
916
+ });
917
+ await showNextCard(host);
918
+ return added;
919
+ };
920
+ return {
921
+ showNextCard,
922
+ skip,
923
+ next,
924
+ gradeCard,
925
+ endTurn,
926
+ bandView,
927
+ runCommand,
928
+ notePrompt,
929
+ planAdd,
930
+ askDeck,
931
+ draftNotes,
932
+ commitAdd
933
+ };
934
+ };
935
+ /** Ticks or unticks the candidate at `index`; a note the deck has stays unticked. */
936
+ var toggleCandidate = (view, index) => ({
937
+ ...view,
938
+ candidates: view.candidates.map((c, j) => j === index && !c.isDuplicate ? {
939
+ ...c,
940
+ isChecked: !c.isChecked
941
+ } : c)
942
+ });
943
+ /** The mark of a candidate in the list of `/ebb add`. */
944
+ var checkbox = (c) => {
945
+ if (c.isDuplicate) return "[have]";
946
+ return c.isChecked ? "[x]" : "[ ]";
947
+ };
948
+ /** How many notes `commitAdd` would add. */
949
+ var tickedCount = (view) => view.candidates.filter((c) => c.isChecked && !c.isDuplicate).length;
950
+ /** A candidate as one line of the list. */
951
+ var candidateLine = (c) => `${checkbox(c)} ${c.front} — ${c.back}${c.hint ? ` (${c.hint})` : ""}${c.flag ? ` ⚠ ${c.flag}` : ""}`;
952
+ // pi/host.ts
953
+ var isOk = (work) => work.then(() => true, () => false);
954
+ var fileHost = (home, dir) => ({
955
+ home,
956
+ dir,
957
+ exists: (path) => isOk(stat(path)),
958
+ read: (path) => readFile(path, "utf8"),
959
+ write: async (path, text) => {
960
+ await mkdir(dirname(path), { recursive: true });
961
+ await writeFile(path, text);
962
+ },
963
+ mtimeMs: async (path) => (await stat(path)).mtimeMs,
964
+ mkdir: (path) => isOk(mkdir(path)),
965
+ mkdirp: async (path) => void await mkdir(path, { recursive: true }),
966
+ rename: (from, to) => isOk(rename(from, to)),
967
+ remove: (path) => rm(path, {
968
+ recursive: true,
969
+ force: true
970
+ }),
971
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
972
+ fetch: async (url) => {
973
+ const r = await fetch(url);
974
+ return {
975
+ ok: r.ok,
976
+ status: r.status,
977
+ text: await r.text()
978
+ };
979
+ }
980
+ });
981
+ // pi/index.ts
982
+ var WIDGET = "ebb";
983
+ var KEYS = {
984
+ forgot: "alt+r",
985
+ got: "alt+g",
986
+ skip: "alt+s",
987
+ toggle: "alt+h"
988
+ };
989
+ var files = fileHost(homedir(), dataDir(homedir(), process.env.XDG_DATA_HOME));
990
+ var SETTINGS = `${files.dir}/pi.json`;
991
+ var textOf = (content) => content.filter((c) => c.type === "text").map((c) => c.text).join("");
992
+ /** The lines of the band; none while it is hidden, paused or without a card. */
993
+ var bandLines = (v, theme) => {
994
+ if (v.kind === "error") return [theme.fg("error", `ebb: cannot read the data: ${v.error}`)];
995
+ if (v.kind === "no-deck") return [theme.fg("dim", "ebb: /ebb add <topic> or /ebb install <file or URL> to get cards")];
996
+ if (v.kind === "none") return void 0;
997
+ const f = v.face;
998
+ const bar = theme.fg("dim", "│");
999
+ const key = (keys, label) => `${theme.fg("accent", keys)} ${label}`;
1000
+ if (v.isRevealed) {
1001
+ const hint = f.hint ? theme.fg("dim", ` (${f.hint})`) : "";
1002
+ return [`${theme.bold(f.ask)} — ${f.answer}${hint} ${bar} ${key(KEYS.forgot, "Next")}`];
1003
+ }
1004
+ const keys = [
1005
+ key(KEYS.forgot, "Forgot"),
1006
+ key(KEYS.got, "Got it"),
1007
+ key(KEYS.skip, "Skip")
1008
+ ].join(" ");
1009
+ return [`${theme.bold(f.ask)} ${bar} ${keys}`];
1010
+ };
1011
+ function ebb(pi) {
1012
+ const session = createSession();
1013
+ let ctx;
1014
+ let isHidden = false;
1015
+ const draw = async () => {
1016
+ if (!ctx?.hasUI) return;
1017
+ const ui = ctx.ui;
1018
+ const lines = isHidden ? void 0 : bandLines(await session.bandView(host), ui.theme);
1019
+ ui.setWidget(WIDGET, lines);
1020
+ };
1021
+ const host = {
1022
+ ...files,
1023
+ complete: async ({ system, prompt, maxTokens, timeoutMs }) => {
1024
+ const model = ctx?.model;
1025
+ if (!ctx || !model) return { error: "no model is selected" };
1026
+ const reply = await ctx.modelRegistry.complete(model, {
1027
+ systemPrompt: system,
1028
+ messages: [{
1029
+ role: "user",
1030
+ content: [{
1031
+ type: "text",
1032
+ text: prompt
1033
+ }],
1034
+ timestamp: Date.now()
1035
+ }]
1036
+ }, {
1037
+ maxTokens,
1038
+ signal: AbortSignal.timeout(timeoutMs)
1039
+ });
1040
+ if (reply.stopReason === "error" || reply.stopReason === "aborted") return { error: reply.errorMessage ?? reply.stopReason };
1041
+ return { text: textOf(reply.content) };
1042
+ },
1043
+ showBand: draw
1044
+ };
1045
+ const notify = (text, level = "info") => ctx?.ui.notify(text, level);
1046
+ /** Runs a shortcut; a failure says what went wrong. */
1047
+ const act = (work) => async (shortcutCtx) => {
1048
+ ctx = shortcutCtx;
1049
+ try {
1050
+ await work();
1051
+ } catch (error) {
1052
+ notify(`ebb: ${String(error)}`, "error");
1053
+ }
1054
+ };
1055
+ /** A band shortcut acts only on a card the band shows. */
1056
+ const onCard = (work) => act(async () => {
1057
+ const v = await session.bandView(host);
1058
+ if (!isHidden && v.kind === "card") await work(v.isRevealed);
1059
+ });
1060
+ const showNextCard = async () => {
1061
+ const error = await session.showNextCard(host);
1062
+ if (error) notify(`ebb: ${error}`, "error");
1063
+ };
1064
+ /** `/ebb add`: the deck, its languages, then the list of new notes to tick. */
1065
+ const add = async (command, args) => {
1066
+ if (command.mode !== "tui") return "/ebb add needs the interactive mode";
1067
+ const plan = await session.planAdd(host, args);
1068
+ if ("text" in plan) return plan.text;
1069
+ let view = plan.view;
1070
+ let deck = plan.deck;
1071
+ if (!deck) {
1072
+ command.ui.setStatus(WIDGET, "ebb: reading the request…");
1073
+ const newDeck = await session.askDeck(host, args, plan.named).finally(() => command.ui.setStatus(WIDGET, void 0));
1074
+ if (newDeck.learn && !newDeck.native) {
1075
+ const native = (await command.ui.input(`Translate ${newDeck.learn} into which language?`, "for example: English"))?.trim();
1076
+ if (!native) return "Nothing added.";
1077
+ newDeck.native = native;
1078
+ }
1079
+ view = {
1080
+ ...view,
1081
+ deck: newDeck.id,
1082
+ newDeck
1083
+ };
1084
+ deck = newDeck;
1085
+ }
1086
+ const drafted = deck;
1087
+ const chosen = await command.ui.custom((tui, theme, _keys, done) => {
1088
+ let current = view;
1089
+ const redraw = async (change) => {
1090
+ current = {
1091
+ ...current,
1092
+ ...change
1093
+ };
1094
+ tui.requestRender();
1095
+ };
1096
+ session.draftNotes(host, drafted, current.topic, (status) => redraw({ status })).then(redraw, (error) => redraw({ status: `Error: ${String(error)}` }));
1097
+ return {
1098
+ render: (width) => {
1099
+ const rows = current.candidates.map((c, i) => `${theme.fg("accent", String(i + 1))} ${candidateLine(c)}`);
1100
+ const actions = current.candidates.length ? `${theme.fg("accent", "a")} add ticked (${tickedCount(current)}) ${theme.fg("accent", "x")} cancel` : `${theme.fg("accent", "x")} cancel`;
1101
+ return [
1102
+ theme.bold(`New notes: ${current.topic}`),
1103
+ ...current.status ? [theme.fg("dim", current.status)] : [],
1104
+ ...rows,
1105
+ actions
1106
+ ].map((line) => truncateToWidth(line, width));
1107
+ },
1108
+ handleInput: (data) => {
1109
+ const digit = Number(data);
1110
+ if (Number.isInteger(digit) && digit >= 1 && digit <= current.candidates.length) {
1111
+ current = toggleCandidate(current, digit - 1);
1112
+ tui.requestRender();
1113
+ return;
1114
+ }
1115
+ if (data === "a" && current.candidates.length) done(current);
1116
+ if (data === "x" || matchesKey(data, "escape")) done(void 0);
1117
+ },
1118
+ invalidate: () => void 0
1119
+ };
1120
+ });
1121
+ if (!chosen) return "Nothing added.";
1122
+ return `ebb: notes added: ${await session.commitAdd(host, chosen)}`;
1123
+ };
1124
+ pi.on("session_start", async (_event, startCtx) => {
1125
+ ctx = startCtx;
1126
+ const settings = await files.read(SETTINGS).catch(() => "{}");
1127
+ try {
1128
+ isHidden = JSON.parse(settings).hidden === true;
1129
+ } catch {
1130
+ isHidden = false;
1131
+ }
1132
+ await showNextCard();
1133
+ });
1134
+ pi.on("input", (event, inputCtx) => {
1135
+ ctx = inputCtx;
1136
+ if (event.source === "interactive") session.notePrompt(event.text);
1137
+ });
1138
+ pi.on("agent_end", async (_event, endCtx) => {
1139
+ ctx = endCtx;
1140
+ await session.endTurn(host);
1141
+ });
1142
+ pi.registerCommand("ebb", {
1143
+ description: "Spaced repetition above the editor: add, install, decks, pause, stats",
1144
+ handler: async (args, command) => {
1145
+ ctx = command;
1146
+ const text = await session.runCommand(host, args, (tail) => add(command, tail)).catch((error) => `ebb: ${String(error)}`);
1147
+ notify(text);
1148
+ }
1149
+ });
1150
+ pi.registerShortcut(KEYS.forgot, {
1151
+ description: "ebb: Forgot, or Next after the answer",
1152
+ handler: onCard((isRevealed) => isRevealed ? session.next(host) : session.gradeCard(host, 1))
1153
+ });
1154
+ pi.registerShortcut(KEYS.got, {
1155
+ description: "ebb: Got it",
1156
+ handler: onCard(async (isRevealed) => !isRevealed && session.gradeCard(host, 3))
1157
+ });
1158
+ pi.registerShortcut(KEYS.skip, {
1159
+ description: "ebb: skip the card",
1160
+ handler: onCard(async (isRevealed) => !isRevealed && session.skip(host))
1161
+ });
1162
+ pi.registerShortcut(KEYS.toggle, {
1163
+ description: "ebb: hide or show the card above the editor",
1164
+ handler: act(async () => {
1165
+ isHidden = !isHidden;
1166
+ await files.write(SETTINGS, JSON.stringify({ hidden: isHidden }));
1167
+ await draw();
1168
+ })
1169
+ });
1170
+ }
1171
+ export { ebb as default };
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "pi-ebb",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Spaced repetition in the background of pi: a card above the editor, graded with a shortcut",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/r13v/ebb.git",
10
+ "directory": "pi"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/r13v/ebb/issues"
14
+ },
15
+ "homepage": "https://github.com/r13v/ebb/tree/main/pi#readme",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "keywords": [
20
+ "pi-package",
21
+ "spaced-repetition",
22
+ "flashcards",
23
+ "fsrs"
24
+ ],
25
+ "files": [
26
+ "dist",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "pi": {
31
+ "extensions": [
32
+ "./dist/index.js"
33
+ ]
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-tui": "*"
37
+ }
38
+ }