---
title: Flashcards
---

# Flashcards

## What it is

Spaced-repetition flashcards for learning and retention. Flashcards is now a first-party builtin-source **plugin** in Omniscio: the UI ships as a self-contained webview under `src/plugins/flashcards/` and reaches core services through a typed bridge (`window.AgentMC.flashcards`). Backend (FSRS scheduler, Anki .apkg importer, media store, task-system integration, migrations, tables, CLI routes) STAYS in core so the data model + queries are shared with any future in-core consumer (e.g. an LMS meeting-intel handshake).

The user opens the plugin from its sidebar tile ("Flashcards"), which shows the pack list, a stats dashboard, pack detail with per-card review, and an Anki import wizard. Each pack holds cards; each card has a front + back (+ optional pasted images), a scheduling blob, and provenance (manual, anki-imported, or ai-generated).

## Where to find it

Flashcards has its own tile in the sidebar, **Flashcards**. It is a first-party plugin, so it installs and updates the way any other plugin does, and the panel itself is a self-contained view with a pack list, a stats dashboard, pack detail for reviewing individual cards, and an Anki import wizard.

### Creating a pack in the UI

"New pack" (pack list) and "Create your first pack" (empty state) open the card editor with no pack selected; the editor then shows a **Pack name** field and creates the pack together with its first card on Save, landing on the new pack's detail view. This is the only place the UI creates a pack (the bridge's `packCreate` is otherwise reached only by tests and the CLI).

### The stats dashboard

The pack list header carries a **Stats** button that sets `subView: 'dashboard-tile'`; `DashboardTile` renders retention %, a 30-day review heatmap, a 7-day forgetting-curve forecast, the streak and the due-today count, and its own header renders the way back to the pack list on every branch (loading / error / empty / full). Reachable since 2026-09-18 — until then `App.tsx` routed the subView and nothing ever set it, so the whole surface was dead UI. The same sweep removed `'onboarding-stage'` from the union: it was routed to `ReviewMode`, which returns `null` for any subView other than `'review'`, so it could only ever have rendered a blank screen (ReviewMode already delegates to `OnboardingStage` on its own, from `settings.sessionsCompleted < 3`). Guard: `tests/unit/lint/flashcards-subview-reachable.test.ts` fails whenever a `SubView` member has no navigation target.

## How it behaves

Reviews are scheduled by an FSRS spaced-repetition scheduler that runs inside the app, and everything lives in your local database — no account, no cloud, no cost. A pack's counts are worked out when the page is read rather than stored, so they are never stale; media attached to a card is held in a local store and survives a re-import.

## For agents

### Where the code lives

- `src/plugins/flashcards/manifest.json` - plugin id `flashcards`, category `productivity`, sidebar title "Flashcards", entry point `ui/index.html`.
- `src/plugins/flashcards/bridge/flashcards-bridge.ts` - 28-method switch dispatching to services (single-object args pattern; emits `IPC.FLASHCARDS_PUSH` after each mutation).
- `src/plugins/flashcards/web/src/` - the React webview: `App.tsx` (subView router), `components/` (PackListView, PackDetailView, CardEditor, ReviewMode, GradeButtons, OnboardingStage, MediaRefStrip, PackStatsPanel, DashboardTile, ImportWizard, FlashcardsErrorBoundary, ReviewComplete, FlashcardsEmptyState), `store/` (Zustand pack + review slices), `lib/` (flashcards-api bridge SDK, i18n shim, toast bus, telemetry stub, parse-cloze, is-fresh helpers), `ui/` (self-contained primitives: Button, AsyncButton, Modal, DialogShell, Toaster, Pill, EmptyState, Spinner, CenteredSpinner, ListItem, IconButton, ImageCanvas, Kbd, FormField, SegmentedControl, SearchInput, LoadingPanel, MenuShell, ConfirmDialog).
- `src/main/services/flashcards/` - core services: `flashcards-service.ts` (CRUD + review + spawn context builder + CLI-server-mediated spawn), `fsrs-scheduler.ts` (ts-fsrs 5.4.1 wrapping), `media-store.ts` (content-addressed image blob store), `importer/` (Anki .apkg parse + commit via yauzl + fzstd), `pack-group-task.ts` (task-system integration), `pick-apkg-file.ts` (OS-native file picker).
- `src/main/db/queries-flashcards.ts` + `queries-flashcards-session.ts` - SQL surface for pack + card + review + trickle-queue + settings + pack-session tables.
- `src/main/db/migrations/` - four additive migrations: `20260824-flashcards-tables.ts`, `20260825-flashcards-trickle-queue.ts`, `20260826-flashcards-media-table.ts`, `20260826-flashcards-retrievability.ts`.
- `src/main/services/cli/cli-server-flashcards-routes.ts` - 28 CLI routes under `/flashcards/*` (route-list identical to the bridge methods).
- `src/shared/flashcards/` - types + Zod schemas + parse-cloze + telemetry-event catalog shared by bridge, service, tests.
- `src/shared/push-event-schemas/flashcards.ts` - 9-kind discriminated union for `IPC.FLASHCARDS_PUSH` (pack.list.changed, pack.detail.changed, card.list.changed, review.session.updated, card.review.recorded, import.started, import.progress, import.completed, import.failed).
- `src/shared/ipc-channels/flashcards.ts` - reduced to the ONE push channel; the 28 request/response channels are retired (bridge is the transport).
- `tests/unit/plugins/flashcards/bridge/` - bridge dispatch, media round-trip, session-spawn cost preservation tests.

### Bridge shape (window.AgentMC.flashcards)

Every method resolves with UNWRAPPED data and rejects with an `Error` when the main side reports a failure. Arguments are single-object payloads (matching the Wave 1 Zod schemas). Full list (28 methods):

- **Pack CRUD** - `packList()`, `packGet({packId})`, `packCreate({name, source?})`, `packRename({packId, name})`, `packDelete({packId})`.
- **Card CRUD** - `cardList({packId?, limit?, offset?})`, `cardCreate({packId, front, back, cardType, frontMediaRefs?, backMediaRefs?})`, `cardUpdate({cardId, ...})`, `cardDelete({cardId})`.
- **Review** - `reviewStart({packId: string | null})`, `gradeCard({sessionId, cardId, grade, latencyMs, reviewMode, interleaved})`, `reviewEnd({sessionId})`.
- **Settings + stats** - `settingsGet()`, `settingsUpdate({...})`, `stats()`.
- **Import (Anki .apkg)** - `pickApkgFile()`, `importParse({apkgFilePath})`, `importCommit({importId, packName})`, `importCancel({importId})`.
- **Sessions (spawn)** - `sessionStartOnCard({cardId, reason?, ...})`, `sessionStartOnPack({packId})`, `packSessionAttribution({packId})`, `trickleEnqueue({...})`, `trickleDismiss({...})`.
- **Media** - `mediaUpload({bytes: Uint8Array, originalName?})`, `mediaGet({mediaId})`, `mediaDelete({mediaId})`, `mediaList()`.

### CLI parity

CLI routes at `/flashcards/*` are preserved; the plugin bridge and CLI parity share the same 28-method surface. Read routes gate through `cliReadPreamble`; mutations gate through `cliMutationPreamble` (bearer auth + rate limit). The `flashcards-panel` unreleased-feature flag was retired with the plugin conversion - plugin install + enable is now the user-facing gate, and CLI routes are always registered so plugin-external agents can drive Flashcards data. `pickApkgFile` retains its CLI-parity exemption (OS-native dialog is not headless-callable).

### Cost preservation invariant

Session-spawn methods (`sessionStartOnCard`, `sessionStartOnPack`) MUST route through the local CLI server's `/project/:name/new` with bearer auth + the `X-AMC-Source-Session-Id` provenance header. The bridge dispatches these to `spawnClaudeSessionForCard` / `startSessionOnPack` in `src/main/services/flashcards/`; it does NOT reimplement the spawn logic. A `AMC_TEST_MOCK_CLI_POST=1` escape hatch in `spawnClaudeSessionForCard` returns a canned response so integration tests never fire a real (billable) session-spawn. Automated tests MUST use that escape hatch - invoking these methods live burns real Claude tokens.

### Media Buffer round-trip

`mediaUpload({bytes, originalName})` stores content-addressed image blobs under `<userData>/flashcards-media/`, returns `{mediaId, contentHash, sizeBytes, mime}`. `mediaGet({mediaId})` returns `{bytes, mime, sizeBytes}` - the bytes may arrive as a Node Buffer or a `{type:'Buffer', data:[...]}`-shaped structured-clone; the plugin store's `normalizeToUint8Array()` helper accepts either shape so a `new Blob([bytes])` render path stays safe. Verified by `tests/unit/plugins/flashcards/bridge/flashcards-bridge.media.test.ts`.

### Push events

Server-side emits `emitPush(IPC.FLASHCARDS_PUSH, {kind: '...', ...})` from the bridge (after each mutation) and from `import-service.ts` (import.started / progress / completed / failed). The host forwards that channel onto the plugin's OWN event bus as `flashcards:push` (`src/main/app/startup/register-push-routing.ts` → `dispatchPluginEvent('flashcards', …)`, plugin-scoped), because a sandboxed plugin webview only hears `window.AgentMC.events.on`, never the renderer push channels. Island side, `lib/plugin-events.ts` is the one subscription chokepoint: `subscribeToFlashcardsPush` (store) drops the pack-list TTL guard and refetches on any list-changing kind (pack/card changes, a recorded review, a completed import), and `usePushListener` (used by ImportWizard for progress) subscribes a component with optional debounce. Wired 2026-09-15 — before that both were no-op stubs, so a pack created through the bridge or the CLI never appeared without a reload.

### Pack counts are computed on read (not stored)

`flashcard_packs.card_count` / `due_count` are legacy columns: every writer (createPack and the Anki importer) inserts 0 and nothing maintains them — `due_count` cannot be maintained at all, since it changes as the clock passes with no write. `PACK_READ_COLUMNS` (queries-flashcards.ts) computes both from the card rows with the same predicate the review queue uses (`dueAt <= now`, not soft-deleted), so `pack.dueCount > 0` means exactly "Start review can start a session now". A lint guard (`tests/unit/lint/flashcards-pack-counts-computed.test.ts`) bans any raw SELECT of the stored columns. Fixed 2026-09-15: until then the raw zeros were served and "Start review" was disabled forever on every pack (imports only worked because the wizard calls `startReview` directly).

### Deps

`ts-fsrs@5.4.1` (FSRS scheduling), `yauzl` (.apkg zip reading), `fzstd` (Zstd decompression), `@types/yauzl` (typings) live in the ROOT `package.json` and are used by main-process code only. The plugin webview never imports these - its own `package.json` under `src/plugins/flashcards/web/` pulls `react`, `zustand`, `lucide-react`, `zod`, `tailwindcss`.

## Related

Flashcards keeps its own data and does not read your sessions; for the notes-and-memory side of Omniscio see [Global memory](global-memory.md).
