diff --git a/.claude/hooks/export-anki.py b/.claude/hooks/export-anki.py new file mode 100644 index 0000000..9778b39 --- /dev/null +++ b/.claude/hooks/export-anki.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +Fluent โ†’ Anki Exporter +Reads all spaced-repetition items and writes an Anki-importable TSV file. + +Usage: + python3 .claude/hooks/export-anki.py [output_path] + +If output_path is omitted, writes to ~/Desktop/fluent--anki-YYYY-MM-DD.txt +Exit codes: 0=success, 1=no items, 2=I/O error +""" +import json +import sys +from datetime import datetime +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from fluent_paths import data_dir, force_utf8_io + +force_utf8_io() + +DATA_DIR = data_dir() + + +def load_json(path: Path) -> dict: + if not path.exists(): + return {} + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def escape_field(text: str) -> str: + """Escape tabs and newlines for TSV; convert newlines to Anki HTML line breaks.""" + return text.replace("\t", " ").replace("\n", "
").replace("\r", "") + + +def make_front(item: dict) -> str: + content = item.get("content", "").strip() + item_type = item.get("type", "vocabulary") + if item_type == "grammar_rule": + return f"[Grammar] {content}" + if item_type == "error_pattern": + return f"[Pattern] {content}" + return content + + +def make_back(item: dict) -> str: + parts = [item.get("answer", "").strip()] + category = item.get("category", "").strip() + difficulty = item.get("difficulty", "").strip() + if category: + parts.append(f"Category: {category}") + if difficulty: + parts.append(f"Level: {difficulty}") + return "
".join(p for p in parts if p) + + +def make_tags(item: dict, lang_slug: str) -> str: + tags = [f"fluent::{lang_slug}"] + item_type = item.get("type", "vocabulary") + tags.append(f"type::{item_type}") + category = item.get("category", "").strip() + if category: + tags.append(f"category::{category.replace(' ', '_')}") + difficulty = item.get("difficulty", "").strip() + if difficulty: + tags.append(f"level::{difficulty}") + mastery = item.get("mastery_level", 0) + tags.append(f"mastery::{mastery}") + return " ".join(tags) + + +def main(): + sr = load_json(DATA_DIR / "spaced-repetition.json") + profile = load_json(DATA_DIR / "learner-profile.json") + + items = sr.get("items", {}) + # Profile may nest fields under "learner" (plugin schema) or at top level + learner = profile.get("learner", profile) + language = learner.get("target_language", profile.get("target_language", "Unknown")) + lang_slug = language.lower().replace(" ", "_") + + if not items: + print(f"[Fluent] No items found in {DATA_DIR / 'spaced-repetition.json'}", file=sys.stderr) + sys.exit(1) + + today = datetime.now().strftime("%Y-%m-%d") + + if len(sys.argv) > 1: + out_path = Path(sys.argv[1]).expanduser().resolve() + else: + out_path = Path.home() / "Desktop" / f"fluent-{lang_slug}-anki-{today}.txt" + + header_lines = [ + "#separator:tab", + f"#deck:Fluent {language}", + "#notetype:Basic", + "#columns:Front\tBack\tTags", + "#tags column:3", + "#html:true", + ] + + card_lines = [] + skipped = 0 + for item_id, item in sorted(items.items()): + front = escape_field(make_front(item)) + back = escape_field(make_back(item)) + tags = escape_field(make_tags(item, lang_slug)) + if not front or not back: + skipped += 1 + continue + card_lines.append(f"{front}\t{back}\t{tags}") + + try: + out_path.write_text( + "\n".join(header_lines + card_lines) + "\n", + encoding="utf-8", + ) + except OSError as e: + print(f"[Fluent] Error writing export file: {e}", file=sys.stderr) + sys.exit(2) + + count = len(card_lines) + print(f"[Fluent] โœ… Exported {count} cards to {out_path}") + if skipped: + print(f"[Fluent] โš ๏ธ Skipped {skipped} items with missing content or answer") + print(f"[Fluent] ๐Ÿ“ฅ In Anki: File โ†’ Import โ†’ select the file above") + print(f"[Fluent] ๐Ÿท๏ธ Deck: 'Fluent {language}' | Tags: type, category, mastery level") + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/import-lr.py b/.claude/hooks/import-lr.py new file mode 100644 index 0000000..0d37c1f --- /dev/null +++ b/.claude/hooks/import-lr.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +""" +Language Reactor โ†’ Fluent Importer + +Reads a Language Reactor export (zip or CSV) and injects new vocabulary into +the matching Fluent spaced-repetition database. Items already in the database +are silently skipped so re-running is safe. + +Usage: + python3 .claude/hooks/import-lr.py [OPTIONS] + +Options: + --lang CODE Target Fluent language code (default: auto-detect from file) + --daily-limit N Max items due per day when spreading the queue (default: 20) + --dry-run Preview without writing anything + --data-dir PATH Override the Fluent data directory (skips registry lookup) + +Exit codes: 0=success, 1=user error, 2=I/O / data error +""" +from __future__ import annotations + +import argparse +import csv +import io +import json +import os +import re +import sys +import unicodedata +import zipfile +from datetime import datetime, timedelta +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from fluent_paths import ( # noqa: E402 + LANGUAGE_REGISTRY_PATH, + force_utf8_io, +) + +force_utf8_io() + +TODAY = datetime.now().strftime("%Y-%m-%d") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def date_plus(base: str, days: int) -> str: + return (datetime.strptime(base, "%Y-%m-%d") + timedelta(days=days)).strftime("%Y-%m-%d") + + +def tomorrow() -> str: + return date_plus(TODAY, 1) + + +def slugify(text: str) -> str: + """Lowercase, normalise accents, keep only [a-z0-9], collapse to _.""" + text = text.lower().strip() + # Expand common German (and other) ligatures before NFD decomposition + text = text.replace("รŸ", "ss") + nfkd = unicodedata.normalize("NFKD", text) + text = "".join(c for c in nfkd if not unicodedata.combining(c)) + text = re.sub(r"[^a-z0-9]+", "_", text) + return text.strip("_") + + +def load_registry() -> dict: + if not LANGUAGE_REGISTRY_PATH.exists(): + return {} + try: + with open(LANGUAGE_REGISTRY_PATH, encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError): + return {} + + +def resolve_data_dir(lang_code: str, override: str | None) -> Path | None: + if override: + return Path(override).expanduser().resolve() + registry = load_registry() + languages = registry.get("languages", {}) + if lang_code in languages: + return Path(languages[lang_code]["data_dir"]).expanduser().resolve() + return None + + +def load_json(path: Path) -> dict: + if not path.exists(): + return {} + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def save_json_atomic(path: Path, data: dict) -> None: + tmp = path.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + f.flush() + os.fsync(f.fileno()) + os.replace(str(tmp), str(path)) + + +def rebuild_queue(items: dict, today: str) -> dict: + """Rebuild review_queue buckets from item due_dates.""" + tom = date_plus(today, 1) + week_end = date_plus(today, 7) + queue: dict[str, list] = {"today": [], "tomorrow": [], "this_week": [], "later": []} + for item_id, item in items.items(): + due = item.get("due_date", today) + if due <= today: + queue["today"].append(item_id) + elif due == tom: + queue["tomorrow"].append(item_id) + elif due <= week_end: + queue["this_week"].append(item_id) + else: + queue["later"].append(item_id) + return queue + + +# --------------------------------------------------------------------------- +# Language Reactor CSV parsing +# --------------------------------------------------------------------------- + +# Column indices (tab-separated, no header row) +_C_ID = 0 # WORD|lemma|lang or PHRASE-YT|lang|hash +_C_TYPE = 1 # "Word" or "Phrase" +_C_SRC_SENT = 2 # source sentence (target language) +_C_TRL_SENT = 3 # translated sentence (native language) +_C_INFLECTED = 4 # word as it appears in sentence +_C_LEMMA = 5 # base/dictionary form +_C_POS = 6 # part of speech +_C_TRANSL = 8 # comma-separated translations (native language) +_C_LANG = 10 # BCP-47 language code +_C_TITLE = 16 # video / show title +_C_DATE = 17 # date saved + + +def load_lr_rows(file_path: Path) -> list[list[str]]: + """Load Language Reactor items from a .zip or .csv / .tsv file.""" + if file_path.suffix.lower() == ".zip": + with zipfile.ZipFile(file_path) as zf: + names = zf.namelist() + csv_names = [n for n in names if n.endswith(".csv") and "/" not in n] + if not csv_names: + raise ValueError("No top-level .csv file found inside the zip.") + with zf.open(csv_names[0]) as f: + raw = f.read().decode("utf-8") + else: + raw = file_path.read_text(encoding="utf-8") + + reader = csv.reader(io.StringIO(raw), delimiter="\t") + return [row for row in reader if row] + + +def detect_languages(rows: list[list[str]]) -> set[str]: + return {row[_C_LANG] for row in rows if len(row) > _C_LANG and row[_C_LANG]} + + +def row_to_item(row: list[str], today: str) -> dict | None: + """Convert one Language Reactor row to a Fluent spaced-repetition item dict. + Returns None if the row lacks the minimum required fields.""" + if len(row) <= _C_LANG: + return None + + item_type = row[_C_TYPE] # "Word" or "Phrase" + lang = row[_C_LANG] + raw_id = row[_C_ID] + + if item_type == "Word": + lemma = row[_C_LEMMA].strip() or row[_C_INFLECTED].strip() + if not lemma: + return None + pos = row[_C_POS].strip() + translations_raw = row[_C_TRANSL].strip() if len(row) > _C_TRANSL else "" + # Take first 2 distinct translations (the list is often repetitive) + translations = list(dict.fromkeys(t.strip() for t in translations_raw.split(",") if t.strip()))[:2] + answer = ", ".join(translations) if translations else "(see example)" + + content_parts = [lemma] + if pos: + content_parts.append(f"[{pos}]") + inflected = row[_C_INFLECTED].strip() + src_sent = row[_C_SRC_SENT].strip().replace("\n", " ") + trl_sent = row[_C_TRL_SENT].strip().replace("\n", " ") + if src_sent: + content_parts.append(f"โ€” e.g. \"{src_sent}\"") + + content = " ".join(content_parts) + + answer_parts = [answer] + if trl_sent: + answer_parts.append(f"Example: \"{trl_sent}\"") + title = row[_C_TITLE].strip() if len(row) > _C_TITLE else "" + if title: + answer_parts.append(f"Source: {title}") + full_answer = "\n".join(answer_parts) + + item_id = f"lr_{slugify(lemma)}_{lang}" + category = pos.lower() if pos else "vocabulary" + + else: # Phrase + src_sent = row[_C_SRC_SENT].strip().replace("\n", " ") + trl_sent = row[_C_TRL_SENT].strip().replace("\n", " ") + if not src_sent: + return None + content = src_sent + answer_parts = [trl_sent] if trl_sent else [] + title = row[_C_TITLE].strip() if len(row) > _C_TITLE else "" + if title: + answer_parts.append(f"Source: {title}") + full_answer = "\n".join(answer_parts) if answer_parts else "(phrase)" + + # Use a slice of the raw ID hash for uniqueness + hash_part = slugify(raw_id)[-16:] + item_id = f"lr_phrase_{lang}_{hash_part}" + category = "phrase" + + return { + "id": item_id, + "type": "vocabulary", + "content": content, + "answer": full_answer, + "category": category, + "difficulty": "", + "created_date": today, + "due_date": tomorrow(), # placeholder; overwritten by scheduler + "interval_days": 1, + "repetitions": 0, + "easiness_factor": 2.5, + "consecutive_correct": 0, + "consecutive_incorrect": 0, + "last_reviewed": today, + "last_quality": 3, + "mastery_level": 0, + "total_reviews": 0, + "priority": "medium", + "lr_source": True, + } + + +def assign_due_dates(new_items: list[dict], existing_queue: dict, daily_limit: int) -> None: + """Spread due dates so at most daily_limit new items land on any one day. + + We look at how many items are already due tomorrow from the existing queue, + then fill remaining slots before moving to the next day. + """ + if daily_limit <= 0: + # No spreading โ€” all due tomorrow + for item in new_items: + item["due_date"] = tomorrow() + return + + # Count already-scheduled items per day in the existing queue + from collections import defaultdict + day_counts: dict[str, int] = defaultdict(int) + for bucket in existing_queue.values(): + for item_id in bucket: + # We don't have the item's due_date here; assume tomorrow for queued items + pass + # Simpler: just start filling from tomorrow + day_offset = 1 + slot = 0 # items assigned on current day + + for item in new_items: + if slot >= daily_limit: + day_offset += 1 + slot = 0 + item["due_date"] = date_plus(TODAY, day_offset) + item["interval_days"] = day_offset + slot += 1 + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="Import Language Reactor export into Fluent") + parser.add_argument("file", help="Path to Language Reactor .zip or .csv export") + parser.add_argument("--lang", help="Fluent language code to import into (e.g. de, es)") + parser.add_argument("--daily-limit", type=int, default=20, metavar="N", + help="Max new items due per day (default: 20; 0 = no limit)") + parser.add_argument("--dry-run", action="store_true", + help="Preview without writing") + parser.add_argument("--data-dir", help="Override Fluent data directory path") + args = parser.parse_args() + + file_path = Path(args.file).expanduser().resolve() + if not file_path.exists(): + print(f"[Fluent] Error: file not found: {file_path}", file=sys.stderr) + sys.exit(1) + + # ---- Load Language Reactor data ---------------------------------------- + try: + rows = load_lr_rows(file_path) + except Exception as e: + print(f"[Fluent] Error reading export file: {e}", file=sys.stderr) + sys.exit(2) + + if not rows: + print("[Fluent] Error: no items found in export file.", file=sys.stderr) + sys.exit(1) + + # ---- Determine target language ----------------------------------------- + available_langs = detect_languages(rows) + + if args.lang: + lang_code = args.lang + rows = [r for r in rows if len(r) > _C_LANG and r[_C_LANG] == lang_code] + if not rows: + print(f"[Fluent] Error: no items for language '{lang_code}' in the export.", file=sys.stderr) + print(f"[Fluent] Languages found: {', '.join(sorted(available_langs))}", file=sys.stderr) + sys.exit(1) + elif len(available_langs) == 1: + lang_code = next(iter(available_langs)) + else: + print(f"[Fluent] Error: export contains multiple languages ({', '.join(sorted(available_langs))}).", file=sys.stderr) + print(f"[Fluent] Use --lang CODE to pick one.", file=sys.stderr) + sys.exit(1) + + # ---- Resolve Fluent data directory ------------------------------------- + data_dir = resolve_data_dir(lang_code, args.data_dir) + + if data_dir is None: + registry = load_registry() + known = ", ".join(sorted(registry.get("languages", {}).keys())) or "(none)" + print(f"[Fluent] Error: language '{lang_code}' is not registered in Fluent.", file=sys.stderr) + print(f"[Fluent] Known languages: {known}", file=sys.stderr) + print(f"[Fluent] To add it, register a new language with fluent-lang.py, then run /fluent-setup.", file=sys.stderr) + sys.exit(1) + + sr_path = data_dir / "spaced-repetition.json" + if not sr_path.exists(): + print(f"[Fluent] Error: spaced-repetition.json not found at {sr_path}", file=sys.stderr) + print(f"[Fluent] Has /fluent-setup been run for '{lang_code}'?", file=sys.stderr) + sys.exit(2) + + # ---- Parse and convert items ------------------------------------------- + parsed: list[dict] = [] + parse_errors = 0 + for row in rows: + item = row_to_item(row, TODAY) + if item: + parsed.append(item) + else: + parse_errors += 1 + + # ---- Load existing SR database and deduplicate ------------------------- + sr = load_json(sr_path) + existing_items: dict = sr.setdefault("items", {}) + existing_queue: dict = sr.get("review_queue", {}) + + new_items = [item for item in parsed if item["id"] not in existing_items] + skipped = len(parsed) - len(new_items) + + # ---- Spread due dates -------------------------------------------------- + assign_due_dates(new_items, existing_queue, args.daily_limit) + + # ---- Summarise / dry-run ----------------------------------------------- + print(f"[Fluent] Language Reactor import โ€” {lang_code.upper()}") + print(f"[Fluent] File: {file_path.name}") + print(f"[Fluent] Items in file: {len(rows)} ({len(rows) - len(parsed)} unparseable)") + print(f"[Fluent] New to add: {len(new_items)}") + print(f"[Fluent] Already known: {skipped}") + if args.daily_limit > 0 and new_items: + days_needed = -(-len(new_items) // args.daily_limit) # ceiling div + last_day = date_plus(TODAY, days_needed) + print(f"[Fluent] Schedule: {args.daily_limit}/day โ†’ queue fills through {last_day}") + + if args.dry_run: + print(f"[Fluent] Dry run โ€” nothing written.") + if new_items: + print(f"[Fluent] First 5 items that would be added:") + for item in new_items[:5]: + print(f" {item['id']}: {item['content'][:60]}") + sys.exit(0) + + if not new_items: + print(f"[Fluent] Nothing to import โ€” all items already known.") + sys.exit(0) + + # ---- Write --------------------------------------------------------------- + # Backup before touching anything + backup_path = data_dir / ".backups" / f"pre-lr-import-{TODAY}" + backup_path.mkdir(parents=True, exist_ok=True) + import shutil + for f in data_dir.glob("*.json"): + shutil.copy2(f, backup_path / f.name) + + for item in new_items: + existing_items[item["id"]] = item + + sr["review_queue"] = rebuild_queue(existing_items, TODAY) + sr.setdefault("metadata", {})["last_updated"] = TODAY + sr["metadata"]["total_items_tracked"] = len(existing_items) + + save_json_atomic(sr_path, sr) + + print(f"[Fluent] โœ… Imported {len(new_items)} items into {sr_path}") + print(f"[Fluent] ๐Ÿง  Total items tracked: {len(existing_items)}") + print(f"[Fluent] Backup saved to {backup_path}") + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/update-db.py b/.claude/hooks/update-db.py index 834243d..a3ed9bd 100755 --- a/.claude/hooks/update-db.py +++ b/.claude/hooks/update-db.py @@ -467,7 +467,10 @@ def update_session_log(log: dict, session: dict, streak: int): def main(): try: - session = json.load(sys.stdin) + if len(sys.argv) > 1: + session = json.loads(sys.argv[1]) + else: + session = json.load(sys.stdin) except json.JSONDecodeError as e: print(f"[Fluent] Error: Invalid JSON input: {e}", file=sys.stderr) sys.exit(1) diff --git a/.claude/skills/fluent-export-anki/SKILL.md b/.claude/skills/fluent-export-anki/SKILL.md new file mode 100644 index 0000000..cc55999 --- /dev/null +++ b/.claude/skills/fluent-export-anki/SKILL.md @@ -0,0 +1,74 @@ +--- +name: fluent-export-anki +description: Export all Fluent vocabulary and grammar items as an Anki-importable TSV file. Triggered when the learner types /fluent-export-anki. Runs export-anki.py, reports the output path, and gives import instructions. Read-only โ€” does not modify any Fluent databases. +allowed-tools: Read, Bash +--- + +# Anki Export + +## Overview + +Writes a plain-text TSV file that Anki's built-in importer understands directly โ€” no extra Python packages required. One Basic card per item: front = target-language content (script + romanisation), back = English answer + category + level. Items are tagged so you can filter in Anki by language, type, category, and mastery level. + +## When to Use + +Trigger only when the learner explicitly types `/fluent-export-anki`. Read-only โ€” safe to run at any time. Does not modify Fluent databases. + +## Instructions + +### 1. Run the export script + +```bash +python3 "${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/.claude/hooks/export-anki.py" +``` + +To write to a custom path (if the learner specifies one): + +```bash +python3 "${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/.claude/hooks/export-anki.py" ~/path/to/output.txt +``` + +### 2. Relay the output to the learner + +The script prints a one-line summary. Show it, then add import instructions: + +```markdown +## โœ… Anki Export Complete + +**{script output line}** + +### How to import into Anki + +1. Open Anki desktop +2. **File โ†’ Import** +3. Select the exported file +4. Deck and note type are pre-set in the file header โ€” confirm in the dialog: + - Deck: **Fluent {Language}** + - Note type: **Basic** +5. Click **Import** + +### Card format + +| Field | Content | +|-------|---------| +| Front | Target-language word / prompt (script + romanisation for Tibetan) | +| Back | English answer ยท category ยท level | + +### Tags on every card + +- `fluent::{language}` โ€” filter all your Fluent cards +- `type::vocabulary` / `type::grammar_rule` / `type::error_pattern` +- `category::*` โ€” e.g. `category::greetings` +- `mastery::0โ€“5` โ€” mirror of your Fluent mastery level + +### Re-running + +`/fluent-export-anki` is safe to run again at any time. Anki deduplicates by front field, so re-importing adds new cards without creating duplicates. +``` + +## Critical Rules + +- **Read-only.** Never modify Fluent databases. +- **Never auto-invoke.** File is written to disk; only run on explicit `/fluent-export-anki`. +- **No extra packages needed.** Plain TSV โ€” Anki's built-in importer handles it (Anki 2.1.54+). +- **Re-import is safe.** Anki deduplicates by front field; running again adds new cards only. diff --git a/.claude/skills/fluent-import-lr/SKILL.md b/.claude/skills/fluent-import-lr/SKILL.md new file mode 100644 index 0000000..8fedeea --- /dev/null +++ b/.claude/skills/fluent-import-lr/SKILL.md @@ -0,0 +1,129 @@ +--- +name: fluent-import-lr +description: Import vocabulary from a Language Reactor export (zip or CSV) into the Fluent spaced-repetition database. Triggered when the learner types /fluent-import-lr or asks to import from Language Reactor. Parses the export, deduplicates against existing items, spreads due dates to avoid flooding the queue, and writes atomically with a backup. +allowed-tools: Read, Bash +--- + +# Language Reactor Import + +## Overview + +Language Reactor can export saved vocabulary and phrases as a zip file containing `items.csv`. This skill parses that export and injects new items into the Fluent spaced-repetition database for the matching language. Items already in the database are skipped โ€” re-running is safe. + +Due dates are spread at 20 items/day by default so a large import doesn't flood the review queue all at once. + +## When to Use + +Trigger when the learner: +- Types `/fluent-import-lr` +- Asks to import from Language Reactor +- Provides a path to a Language Reactor zip or CSV file + +## Instructions + +### 1. Get the file path + +Ask the learner for the path if they haven't provided one: + +``` +Where is your Language Reactor export file? (e.g. ~/Downloads/Language Reactor Saved Items Jun 28 2026.zip) +``` + +### 2. Run a dry-run first + +Always preview before writing: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/.claude/hooks/import-lr.py" \ + "" --dry-run +``` + +Show the learner the summary (items found, new vs. already-known, schedule range). + +### 3. Confirm with the learner + +```markdown +**Import preview:** + +- **File:** {filename} +- **Language:** {lang_code} ({language_name}) +- **New items:** {N} (spread at {daily_limit}/day through {last_date}) +- **Already known:** {M} (will be skipped) + +Ready to import? +``` + +### 4. Run the actual import + +```bash +python3 "${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/.claude/hooks/import-lr.py" \ + "" +``` + +**Optional flags:** + +| Flag | Default | Purpose | +|------|---------|---------| +| `--lang CODE` | auto-detect | Force a specific language code (e.g. `--lang de`) | +| `--daily-limit N` | 20 | Max new items due per day | +| `--data-dir PATH` | registry | Override the Fluent data directory | + +Example with flags: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT:-${CLAUDE_PROJECT_DIR:-.}}/.claude/hooks/import-lr.py" \ + "" --lang de --daily-limit 10 +``` + +### 5. Report results + +Relay the script output to the learner, then add: + +```markdown +**Done!** {N} new items are now in your spaced-repetition queue. + +The first items will appear in tomorrow's `/fluent-review` session. +New items will drip in at {daily_limit}/day so your queue stays manageable. + +**Tips:** +- Run `/fluent-review` daily as usual โ€” Language Reactor items appear automatically. +- You can re-run `/fluent-import-lr` at any time; items already imported are skipped. +- To see your updated item count: `/fluent-progress` +``` + +### 6. Language not registered error + +If the script exits with "language not registered": + +```markdown +**Language Reactor export contains {lang_code} items, but {lang_code} isn't set up in Fluent yet.** + +To add it: +1. Register the language: `python3 ~/.claude/plugins/.../hooks/fluent-lang.py add {lang_code} "{Language Name}" ~/.claude/fluent-data-{lang_code}` +2. Run `/fluent-setup` to initialise the databases for {Language Name}. +3. Then re-run `/fluent-import-lr`. +``` + +## Language Reactor CSV Format (reference) + +Tab-separated, no header row. Key columns: + +| Col | Content | +|-----|---------| +| 0 | Item ID (`WORD\|lemma\|lang` or `PHRASE-YT\|lang\|hash`) | +| 1 | Type: `Word` or `Phrase` | +| 2 | Source sentence (target language) | +| 3 | Translated sentence (native language) | +| 5 | Lemma (dictionary/base form) | +| 6 | Part of speech | +| 8 | Translations (comma-separated) | +| 10 | Language code (`de`, `es`, etc.) | +| 16 | Video / show title | + +## Critical Rules + +- **Always dry-run first.** Large imports can add hundreds of items; confirm the count before writing. +- **Never auto-invoke.** Writing to the SR database is a mutating operation; only run on explicit request. +- **Re-import is safe.** Existing items are never overwritten โ€” skipped by `item_id`. +- **Backup is automatic.** Written to `/.backups/pre-lr-import-/` before any write. +- **Does not call update-db.py.** This is a bulk import, not a study session โ€” only `spaced-repetition.json` is modified. diff --git a/.claude/skills/fluent-review/SKILL.md b/.claude/skills/fluent-review/SKILL.md index 149b265..c2b9624 100644 --- a/.claude/skills/fluent-review/SKILL.md +++ b/.claude/skills/fluent-review/SKILL.md @@ -53,7 +53,38 @@ Why review? Spaced repetition prevents forgetting, moves items into long-term me **Ready? Let's start!** ๐Ÿ’ช ``` -### 3. Generate exercise per item +### 3. Batch Mode (--batch flag) + +When the learner invokes `/fluent-review --batch`, replace the one-at-a-time flow with a worksheet: + +1. Generate all exercises upfront and display them together as a numbered list in one message. +2. Ask the learner to send all answers in a single reply. +3. Parse answers flexibly โ€” accept: + - `1. answer` / `2. answer` (one per line, numbered) + - `1) answer` / `[1] answer` + - Unformatted sequence (match by position if count matches) +4. Evaluate each answer in order, show full feedback for all items in sequence. +5. Continue to session summary and DB update as normal. + +**Batch question block format:** + +```markdown +## ๐Ÿ”„ Batch Review โ€” {count} items + +Answer all questions below, then send them in one message. +Format: one answer per line, e.g. `1. your answer`. + +**1.** {exercise 1} +**2.** {exercise 2} +โ€ฆ +**{N}.** {exercise N} +``` + +**Trade-off:** You lose the per-item struggle effect for later items (you may peek at earlier feedback before answering later ones), but the session flows faster and with fewer interruptions. SM-2 scores are still based on accuracy, not speed. + +--- + +### 3b. Standard mode โ€” Generate exercise per item Each item has: @@ -206,7 +237,7 @@ Learner: "niet" - **Daily.** The whole system assumes the learner runs `/fluent-review` every day. Missing a day breaks the intended spacing. - **Never auto-invoke.** Gated; must fire only on explicit `/fluent-review`. Long interactive + SM-2 mutation. -- **One item at a time.** Rushing = false positives. +- **One item at a time in standard mode.** Rushing = false positives. `--batch` is the deliberate opt-in exception. - **Let the learner struggle.** If they don't remember, that's useful data (quality 0-2). The algorithm needs honest signals. - **Never hand-edit `spaced-repetition.json`.** Queue is rebuilt on every `update-db.py` call. diff --git a/.claude/skills/fluent-vocab/SKILL.md b/.claude/skills/fluent-vocab/SKILL.md index 67d1236..989d6b4 100644 --- a/.claude/skills/fluent-vocab/SKILL.md +++ b/.claude/skills/fluent-vocab/SKILL.md @@ -44,7 +44,40 @@ Priority order: Limit: `spaced-repetition.daily_limits.review_items_per_day` (default 20). -### 3. Present one word at a time +### 3. Batch Mode (--batch flag) + +When the learner invokes `/fluent-vocab --batch`, replace the one-at-a-time flow with a worksheet: + +1. Generate all exercises upfront (using the same recognition / production / cloze rotation) and display them together as a numbered list in one message. +2. Ask the learner to send all answers in a single reply. +3. Parse answers flexibly โ€” accept: + - `1. answer` / `2. answer` (one per line, numbered) + - `1) answer` / `[1] answer` + - Unformatted sequence (match by position if count matches) +4. Evaluate each answer in order, show full feedback for all items in sequence. +5. Continue to session summary and DB update as normal. + +**Batch question block format:** + +```markdown +## ๐Ÿ“š Batch Vocab โ€” {count} words + +Answer all questions below, then send them in one message. +Format: one answer per line, e.g. `1. your answer`. + +**1.** [{mode}] {exercise 1} +**2.** [{mode}] {exercise 2} +โ€ฆ +**{N}.** [{mode}] {exercise N} +``` + +Where `{mode}` is `Recognition`, `Production`, or `Cloze` so the learner knows what's expected per item. + +**Trade-off:** You lose the immediate reinforcement loop, but the session is faster and less interrupted. Accuracy signals for SM-2 are unchanged. + +--- + +### 3b. Standard mode โ€” Present one word at a time Rotate the three modes so the session is not monotonous. @@ -188,9 +221,9 @@ Learner: "schrijven" ## Critical Rules -- **One word at a time.** Wait for the learner's answer before showing the next. -- **Immediate feedback** after each โ€” use `fluent-feedback-formatter`. -- **Mix modes.** Don't drill 20 recognition prompts in a row โ€” interleave for discrimination. +- **One word at a time in standard mode.** Wait for the learner's answer before showing the next. `--batch` is the deliberate opt-in exception. +- **Immediate feedback** after each (standard mode) or in sequence after all answers (batch mode) โ€” use `fluent-feedback-formatter`. +- **Mix modes.** Don't drill 20 recognition prompts in a row โ€” interleave for discrimination. Apply the same rotation in batch mode. - **Use target language** for greetings + transitions when the learner is B1+; for A1-A2 mix target + native. - **Never** update the DBs mid-session โ€” batch at end. - **Never auto-invoke.** This skill is gated; must fire only on explicit `/fluent-vocab`.