diff --git a/CHANGELOG.md b/CHANGELOG.md
index d0fdb8c6..e8d227e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## [Unreleased]
+### Tutor + Librarian agents — mobile UI (AI-Agent-2/3) — mobile (2026-06-26)
+
+Mobile (Expo/React Native) parity for the two agent UIs shipped on web. **Tutor "Smart session"** — entry on the Vocabulary tab → `/tutor` screen: plan view (rationale + per-item exercise badge/difficulty/`why` + reading nudge), study via the existing mobile `FlashCard` rendered straight from the **enriched** plan item (no vocab re-fetch), HITL feedback re-plan → summary. **Librarian "Ask the librarian"** — entry on the Search tab → `/librarian` screen: NL query → reasoning + ranked cards, `library` recs tap through to `/book/[slug]`, `open_library` recs are dashed "Suggestion — not in your library yet" cards with **no navigation**, `usedExternal` note. Both faithfully port the web hardening: mounted-guard + `AbortController` (no setState after screen pop), feedback-failure retry re-submits the **same** session, `MAX_ROUNDS=8` client cap, clamped (`numberOfLines`) untrusted LLM text, unknown-`exerciseType` fallback. Implementation: `apps/mobile/src/lib/agents.ts` (DTO types + `authFetch` API + pure helpers), `useTutorSession`/`useLibrarian` hooks, `TutorPlanView` + `librarian/*` components, screens `app/tutor.tsx` + `app/librarian.tsx`; strings via shared `t()` (keys added to `packages/shared/src/i18n/en.json`), all colors via `useTheme()` (dark-mode correct), haptics/TTS wired. No new network processor (first-party `/me/tutor/*` + `/me/librarian` only — Play Data Safety unaffected). `npx tsc --noEmit` clean; **52 mobile vitest** green (14 new pure-helper tests). Adversarial QA: **0 blockers** — the two web→mobile regression risks (book route exists + correct; external never navigable) both confirmed. **On-device verification** owed (Expo Go can't run all native modules): nav to book + back-stack, Android keyboard-avoid on the librarian input, haptics/TTS on hardware, dark mode. **Deferred**: SSE streaming, external-book ingest, admin replay, tablet layout. This brings both shipped agents to full web+mobile parity.
+
### Librarian Agent — web UI (Ask the librarian) (AI-Agent-3) — web (2026-06-24)
The frontend for the Librarian agent (backend shipped earlier): a **"Ask the librarian"** natural-language book-discovery surface at `/:lang/discover` (entry from the Discover menu). Type a request — *"books like 1984 about surveillance, under 300 pages"* — → `POST /me/librarian` → the agent's **`reasoning`** + ranked recommendation cards, each with title/authors and the per-item, request-grounded **`why`**. **Library** results reuse the catalog book-card and link to `/:lang/books/{slug}`; **external** (`open_library`) results render as clearly-marked "**Suggestion — not in your library yet**" cards with **no in-app navigation** (the backend is recommend-only — an external suggestion must never masquerade as a real catalog book). A subtle `usedExternal` note shows when the librarian reached beyond the catalog. New `DiscoverPage`, `useLibrarian` state machine (`idle→asking→results|empty|error`), `LibrarianResults`/`LibrarianRecommendationCard`, `librarian.ts` client (`isValidLibrarianQuery` mirrors the backend ≥2/≤500 guard). Reuses the Tutor-UI hardening: mounted-guard + `AbortController` (no setState-after-unmount), `retry()` re-runs the same query, untrusted LLM strings (`reasoning`/`why`) line-clamped (`overflow-wrap:anywhere`) + React-escaped. Auth-gated (signed-out → existing auth modal CTA, stays on the page); SEO-noindexed (`/me/` surface). Adversarial QA: **0 blockers/should-fixes** — the two real risks (a library item with a null `slug` → no broken `/books/undefined` link; external item → never a link) are both guarded and test-covered. `tsc` clean; **595 web tests** green (11 new); `vite build` green; browser-checked (query → reasoning + library link + marked external suggestion + usedExternal + empty/error/min-query/signed-out, **0 console errors**). **Deferred**: mobile Librarian UI, SSE streaming, "add external book to my library"/ingest, pagination. Surfaces a shipped agent that previously had no UI.
diff --git a/apps/mobile/app/(tabs)/search.tsx b/apps/mobile/app/(tabs)/search.tsx
index f9ba7875..bffffc9a 100644
--- a/apps/mobile/app/(tabs)/search.tsx
+++ b/apps/mobile/app/(tabs)/search.tsx
@@ -288,6 +288,30 @@ export default function DiscoverScreen() {
+ {/* Ask the librarian — natural-language, reasoned recommendations (signed-in only; the screen gates). */}
+ {!searched && (
+ router.push('/librarian')}
+ activeOpacity={0.85}
+ accessibilityRole="button"
+ accessibilityLabel={t('librarian.title')}
+ >
+
+
+
+
+
+ {t('librarian.title')}
+
+
+ {t('librarian.entry.hint')}
+
+
+
+
+ )}
+
{loading ? (
{[0, 1, 2, 3].map(i => (
@@ -451,6 +475,19 @@ const styles = StyleSheet.create({
container: { flex: 1 },
center: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },
searchBar: { padding: 12 },
+ librarianEntry: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ marginHorizontal: 12,
+ marginBottom: 8,
+ padding: 12,
+ borderRadius: 12,
+ borderWidth: 1,
+ },
+ librarianIcon: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
+ librarianTitle: { fontSize: 15 },
+ librarianSubtitle: { fontSize: 12, marginTop: 2 },
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/apps/mobile/app/(tabs)/vocabulary.tsx b/apps/mobile/app/(tabs)/vocabulary.tsx
index 57aa99d4..4bd6deae 100644
--- a/apps/mobile/app/(tabs)/vocabulary.tsx
+++ b/apps/mobile/app/(tabs)/vocabulary.tsx
@@ -297,8 +297,8 @@ export default function VocabularyScreen() {
))}
- {dueCount > 0 && (
-
+
+ {dueCount > 0 && (
router.push(`/vocabulary/review?reviewMode=${reviewMode}`)}
@@ -306,8 +306,20 @@ export default function VocabularyScreen() {
Practice ({dueCount})
-
- )}
+ )}
+ {/* Smart session — AI tutor plans what to study and explains why (signed-in only; vocab tab is) */}
+ router.push('/tutor')}
+ accessibilityRole="button"
+ accessibilityLabel={t('tutor.entry.cta')}
+ >
+
+
+ {t('tutor.entry.cta')}
+
+
+
>
)}
diff --git a/apps/mobile/app/librarian.tsx b/apps/mobile/app/librarian.tsx
new file mode 100644
index 00000000..03793b12
--- /dev/null
+++ b/apps/mobile/app/librarian.tsx
@@ -0,0 +1,159 @@
+import { useState } from 'react'
+import {
+ View, Text, StyleSheet, SafeAreaView, ScrollView, TextInput,
+ ActivityIndicator, KeyboardAvoidingView, Platform,
+} from 'react-native'
+import { Ionicons } from '@expo/vector-icons'
+import { useRouter, Stack } from 'expo-router'
+import { useTheme } from '../src/context/ThemeContext'
+import { useLanguage } from '../src/context/LanguageContext'
+import { useAuth } from '../src/context/AuthContext'
+import { useLibrarian } from '../src/hooks/useLibrarian'
+import { isValidLibrarianQuery, MAX_QUERY_LENGTH } from '../src/lib/agents'
+import { fonts } from '../src/theme/typography'
+import { EmptyState } from '../src/components/ui/EmptyState'
+import { PressableScale } from '../src/components/ui/PressableScale'
+import { LibrarianResults } from '../src/components/librarian/LibrarianResults'
+
+// Ask the librarian (Librarian agent, AI-Agent-3). NL request → reasoning + ranked recommendation cards.
+// Signed-in only (it's a `/me/` endpoint). `library` cards navigate to the in-app book; `open_library` cards
+// are marked external suggestions with no navigation.
+export default function LibrarianScreen() {
+ const { colors } = useTheme()
+ const { t } = useLanguage()
+ const { user } = useAuth()
+ const router = useRouter()
+ const librarian = useLibrarian()
+ const [query, setQuery] = useState('')
+
+ const canAsk = isValidLibrarianQuery(query) && librarian.phase !== 'asking'
+ const handleAsk = () => {
+ if (canAsk) librarian.run(query.trim())
+ }
+ const handleOpen = (slug: string) => router.push(`/book/${slug}`)
+
+ const screen =
+
+ // Signed-out
+ if (!user) {
+ return (
+ <>
+ {screen}
+
+ router.replace('/(auth)/login')}
+ />
+
+ >
+ )
+ }
+
+ return (
+ <>
+ {screen}
+
+
+ {/* Prompt */}
+
+
+ {t('librarian.subtitle')}
+
+
+
+ {librarian.phase === 'asking' ? (
+
+ ) : (
+ <>
+
+ {t('librarian.ask')}
+ >
+ )}
+
+
+
+
+ {librarian.phase === 'asking' && (
+
+
+
+ {t('librarian.thinking')}
+
+
+ )}
+
+ {librarian.phase === 'empty' && (
+
+ )}
+
+ {librarian.phase === 'error' && (
+ librarian.retry()}
+ />
+ )}
+
+ {librarian.phase === 'results' && librarian.response && (
+
+ )}
+
+
+
+ >
+ )
+}
+
+const styles = StyleSheet.create({
+ promptBox: { padding: 16, gap: 10 },
+ subtitle: { fontSize: 13, lineHeight: 19 },
+ input: {
+ minHeight: 90,
+ borderRadius: 12,
+ borderWidth: 1,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ fontSize: 15,
+ lineHeight: 21,
+ textAlignVertical: 'top',
+ },
+ askBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ paddingVertical: 13,
+ borderRadius: 12,
+ },
+ askBtnText: { color: '#fff', fontSize: 16 },
+ results: { padding: 16, paddingTop: 0, paddingBottom: 32 },
+ center: { alignItems: 'center', justifyContent: 'center', paddingVertical: 48, gap: 16 },
+ thinking: { fontSize: 14, textAlign: 'center' },
+})
diff --git a/apps/mobile/app/tutor.tsx b/apps/mobile/app/tutor.tsx
new file mode 100644
index 00000000..24d2baf3
--- /dev/null
+++ b/apps/mobile/app/tutor.tsx
@@ -0,0 +1,263 @@
+import { useEffect } from 'react'
+import { View, Text, StyleSheet, SafeAreaView, ActivityIndicator } from 'react-native'
+import { Ionicons } from '@expo/vector-icons'
+import { useRouter, Stack } from 'expo-router'
+import { useTheme } from '../src/context/ThemeContext'
+import { useLanguage } from '../src/context/LanguageContext'
+import { useAuth } from '../src/context/AuthContext'
+import { useTts } from '../src/hooks/useTts'
+import { useHaptics } from '../src/hooks/useHaptics'
+import { useTutorSession } from '../src/hooks/useTutorSession'
+import { fonts } from '../src/theme/typography'
+import { EmptyState } from '../src/components/ui/EmptyState'
+import { PressableScale } from '../src/components/ui/PressableScale'
+import { FlashCard } from '../src/components/vocabulary/FlashCard'
+import { TutorPlanView } from '../src/components/vocabulary/TutorPlanView'
+import { exerciseLabel } from '../src/lib/agents'
+
+// Smart session (Learning Tutor, AI-Agent-2). Layers the tutor's PLANNING + reasoning over the existing
+// flashcard. Flow: plan view (the showcase) → study (reuse FlashCard) → feedback re-plan → summary.
+export default function TutorScreen() {
+ const { colors } = useTheme()
+ const { t, language } = useLanguage()
+ const { user } = useAuth()
+ const router = useRouter()
+ const tutor = useTutorSession()
+ const { toggle: toggleTts } = useTts()
+ const haptics = useHaptics()
+
+ const handleSpeak = (text: string) => toggleTts(text, { lang: language })
+ const goBack = () => router.back()
+
+ // Auto-plan once on mount when signed in.
+ useEffect(() => {
+ if (user) tutor.start()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [user])
+
+ const screen =
+
+ // Signed-out
+ if (!user) {
+ return (
+ <>
+ {screen}
+
+ router.replace('/(auth)/login')}
+ />
+
+ >
+ )
+ }
+
+ // Planning / idle
+ if (tutor.phase === 'planning' || tutor.phase === 'idle') {
+ return (
+ <>
+ {screen}
+
+
+
+
+ {t('tutor.planning')}
+
+
+
+ >
+ )
+ }
+
+ // Error (retry re-plans a fresh session, or re-submits pending feedback — see hook)
+ if (tutor.phase === 'error') {
+ return (
+ <>
+ {screen}
+
+ tutor.retry()}
+ />
+
+ >
+ )
+ }
+
+ // Empty (nothing due — first plan came back empty)
+ if (tutor.phase === 'empty') {
+ return (
+ <>
+ {screen}
+
+ router.replace('/(tabs)/search')}
+ />
+
+ >
+ )
+ }
+
+ // Summary
+ if (tutor.phase === 'summary') {
+ const { studied, correct } = tutor.stats
+ const rate = studied > 0 ? Math.round((correct / studied) * 100) : 0
+ return (
+ <>
+ {screen}
+
+
+
+
+ {t('tutor.summary.done')}
+
+
+
+ {studied}
+
+ {t('tutor.summary.studied')}
+
+
+
+ {rate}%
+
+ {t('tutor.summary.accuracy')}
+
+
+
+ {!!tutor.readingNudge && (
+
+ 📖
+
+ {tutor.readingNudge}
+
+
+ )}
+
+ {t('tutor.summary.back')}
+
+
+
+ >
+ )
+ }
+
+ // plan + study phases need an active turn
+ if (!tutor.turn) return screen
+
+ // Plan showcase
+ if (tutor.phase === 'plan') {
+ return (
+ <>
+ {screen}
+
+ {tutor.adjusted && (
+
+
+
+ {t('tutor.plan.adjustedNote')}
+
+
+ )}
+
+
+ >
+ )
+ }
+
+ // Study phase
+ const entry = tutor.currentEntry
+ if (!entry) return screen
+ const queue = tutor.turn.queue
+ const progress = queue.length > 0 ? (tutor.currentIndex / queue.length) * 100 : 0
+
+ const handleAnswer = (isCorrect: boolean, responseTimeMs: number) => {
+ haptics.play(isCorrect ? 'correct' : 'wrong')
+ tutor.answer(isCorrect, responseTimeMs)
+ }
+
+ return (
+ <>
+
+
+ {/* Progress */}
+
+
+
+
+ {/* Per-card reasoning — the tutor explains WHY this card */}
+
+
+
+ {exerciseLabel(entry.item.exerciseType, t)}
+
+
+
+ {entry.item.why}
+
+
+
+
+ handleAnswer(isCorrect, responseTimeMs)}
+ onSpeak={handleSpeak}
+ onFlip={() => haptics.play('flip')}
+ />
+
+
+ >
+ )
+}
+
+const styles = StyleSheet.create({
+ planning: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, gap: 16 },
+ planningText: { fontSize: 14, textAlign: 'center' },
+ progressTrack: { height: 3 },
+ progressFill: { height: '100%' },
+ whyRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingVertical: 10, borderBottomWidth: 1 },
+ whyBadge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10 },
+ whyBadgeText: { fontSize: 11 },
+ whyText: { flex: 1, fontSize: 13, lineHeight: 18 },
+ cardArea: { flex: 1, padding: 20, justifyContent: 'center' },
+
+ adjustedNote: { flexDirection: 'row', alignItems: 'center', gap: 6, marginHorizontal: 16, marginTop: 12, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 10 },
+ adjustedText: { fontSize: 12 },
+
+ summary: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
+ summaryTitle: { fontSize: 22, marginTop: 16 },
+ summaryStats: { flexDirection: 'row', gap: 40, marginTop: 24 },
+ summaryStat: { alignItems: 'center' },
+ summaryValue: { fontSize: 32 },
+ summaryLabel: { fontSize: 12, marginTop: 2 },
+ nudge: { flexDirection: 'row', gap: 8, borderRadius: 14, padding: 14, alignItems: 'flex-start', marginTop: 24, alignSelf: 'stretch' },
+ nudgeIcon: { fontSize: 16 },
+ nudgeText: { flex: 1, fontSize: 14, lineHeight: 20 },
+ summaryBtn: { marginTop: 28, paddingVertical: 14, paddingHorizontal: 32, borderRadius: 12, alignSelf: 'stretch', alignItems: 'center' },
+ summaryBtnText: { color: '#fff', fontSize: 16 },
+})
diff --git a/apps/mobile/src/components/librarian/LibrarianRecommendationCard.tsx b/apps/mobile/src/components/librarian/LibrarianRecommendationCard.tsx
new file mode 100644
index 00000000..ce46cd8c
--- /dev/null
+++ b/apps/mobile/src/components/librarian/LibrarianRecommendationCard.tsx
@@ -0,0 +1,114 @@
+import { View, Text, StyleSheet } from 'react-native'
+import { Ionicons } from '@expo/vector-icons'
+import { useTheme } from '../../context/ThemeContext'
+import { fonts } from '../../theme/typography'
+import { PressableScale } from '../ui/PressableScale'
+import type { LibrarianRecommendation } from '../../lib/agents'
+
+interface Props {
+ index: number
+ rec: LibrarianRecommendation
+ t: (key: string) => string
+ onOpen: (slug: string) => void
+}
+
+// A single ranked recommendation. Two visually-distinct treatments:
+// - `library` (has a slug) → a tappable card that navigates into the catalog book page.
+// - `open_library` → a clearly-marked "suggestion, not in your library yet" card with NO navigation.
+// `title`/`why`/`authors` come straight from the model — `numberOfLines` bounds their height so an over-long
+// string can't blow up the layout.
+export function LibrarianRecommendationCard({ index, rec, t, onOpen }: Props) {
+ const { colors } = useTheme()
+ const isLibrary = rec.source === 'library' && !!rec.slug
+ const authors = rec.authors.length > 0 ? rec.authors.join(', ') : t('librarian.unknownAuthor')
+
+ const meta: string[] = []
+ if (rec.year != null) meta.push(String(rec.year))
+ if (rec.pages != null) meta.push(`${rec.pages} ${t('librarian.pages')}`)
+
+ const body = (
+ <>
+
+ {index + 1}
+
+
+
+
+ {rec.title}
+
+ {!isLibrary && (
+
+
+ {t('librarian.suggestionBadge')}
+
+
+ )}
+
+
+ {authors}
+
+ {meta.length > 0 && (
+
+ {meta.join(' · ')}
+
+ )}
+
+ {rec.why}
+
+ {!isLibrary ? (
+
+ {t('librarian.suggestionNote')}
+
+ ) : (
+
+
+ {t('librarian.openBook')}
+
+
+
+ )}
+
+ >
+ )
+
+ if (isLibrary) {
+ return (
+ onOpen(rec.slug!)}
+ style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}
+ accessibilityRole="button"
+ accessibilityLabel={`${t('librarian.openBook')}: ${rec.title}`}
+ >
+ {body}
+
+ )
+ }
+
+ // External suggestion: NOT navigable — it doesn't exist in the catalog yet.
+ return (
+
+ {body}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ card: { flexDirection: 'row', gap: 12, borderRadius: 14, borderWidth: 1, padding: 14 },
+ cardExternal: { borderStyle: 'dashed' },
+ index: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center', marginTop: 2 },
+ indexText: { fontSize: 13 },
+ bodyText: { flex: 1 },
+ head: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
+ title: { fontSize: 18, flexShrink: 1 },
+ badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, borderWidth: 1 },
+ badgeText: { fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.4 },
+ authors: { fontSize: 13, marginTop: 2 },
+ meta: { fontSize: 12, marginTop: 2 },
+ why: { fontSize: 14, lineHeight: 20, marginTop: 8 },
+ suggestionNote: { fontSize: 12, marginTop: 8, fontStyle: 'italic' },
+ openRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 8 },
+ openText: { fontSize: 13 },
+})
diff --git a/apps/mobile/src/components/librarian/LibrarianResults.tsx b/apps/mobile/src/components/librarian/LibrarianResults.tsx
new file mode 100644
index 00000000..20eaa4ce
--- /dev/null
+++ b/apps/mobile/src/components/librarian/LibrarianResults.tsx
@@ -0,0 +1,60 @@
+import { View, Text, StyleSheet } from 'react-native'
+import { useTheme } from '../../context/ThemeContext'
+import { fonts } from '../../theme/typography'
+import { LibrarianRecommendationCard } from './LibrarianRecommendationCard'
+import type { LibrarianResponse } from '../../lib/agents'
+
+interface Props {
+ response: LibrarianResponse
+ t: (key: string) => string
+ onOpen: (slug: string) => void
+}
+
+// The showcase: surfaces the librarian's REASONING as a deliberate "here's what I found and why" block, then the
+// ranked, grounded recommendation cards. `reasoning` is untrusted model text — `numberOfLines`-clamped.
+export function LibrarianResults({ response, t, onOpen }: Props) {
+ const { colors } = useTheme()
+ const { reasoning, recommendations, usedExternal } = response
+
+ return (
+
+
+
+ {t('librarian.reasoningLabel')}
+
+
+ {reasoning}
+
+
+
+ {usedExternal && (
+
+ 🌐
+
+ {t('librarian.usedExternalNote')}
+
+
+ )}
+
+ {recommendations.map((rec, i) => (
+
+ ))}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: { gap: 10 },
+ reasoning: { borderRadius: 14, borderWidth: 1, padding: 14 },
+ reasoningLabel: { fontSize: 12, marginBottom: 6, textTransform: 'uppercase', letterSpacing: 0.5 },
+ reasoningText: { fontSize: 15, lineHeight: 21 },
+ externalNote: { flexDirection: 'row', gap: 8, borderRadius: 14, padding: 14, alignItems: 'flex-start' },
+ externalIcon: { fontSize: 16 },
+ externalText: { flex: 1, fontSize: 13, lineHeight: 19 },
+})
diff --git a/apps/mobile/src/components/vocabulary/TutorPlanView.tsx b/apps/mobile/src/components/vocabulary/TutorPlanView.tsx
new file mode 100644
index 00000000..6c0ad9d1
--- /dev/null
+++ b/apps/mobile/src/components/vocabulary/TutorPlanView.tsx
@@ -0,0 +1,120 @@
+import { View, Text, ScrollView, StyleSheet } from 'react-native'
+import { useTheme } from '../../context/ThemeContext'
+import { fonts } from '../../theme/typography'
+import { PressableScale } from '../ui/PressableScale'
+import { exerciseLabel, exerciseBadgeColor, type TutorPlanItem } from '../../lib/agents'
+
+interface Props {
+ rationale: string
+ plan: TutorPlanItem[]
+ readingNudge: string
+ adjusted: boolean
+ t: (key: string) => string
+ onStart: () => void
+}
+
+// The showcase: surfaces the tutor's reasoning as a deliberate "here's your plan, and why" view — the visible
+// reasoning is the point, not debug text. `rationale`/`why`/`difficulty`/`readingNudge` are untrusted LLM text, so
+// every line is `numberOfLines`-clamped to bound height.
+export function TutorPlanView({ rationale, plan, readingNudge, adjusted, t, onStart }: Props) {
+ const { colors } = useTheme()
+ const badgePalette = {
+ recognition: '#3B82F6',
+ recall: '#F59E0B',
+ context: '#8B5CF6',
+ fallback: colors.textSecondary,
+ }
+
+ return (
+
+ {/* Rationale */}
+
+
+ {adjusted ? t('tutor.plan.adjustedLabel') : t('tutor.plan.rationaleLabel')}
+
+
+ {rationale}
+
+
+
+ {/* Ordered plan */}
+ {plan.map((item, i) => {
+ const accent = exerciseBadgeColor(item.exerciseType, badgePalette)
+ return (
+
+
+ {i + 1}
+
+
+
+
+ {item.word}
+
+
+
+ {exerciseLabel(item.exerciseType, t)}
+
+
+
+ {!!item.difficulty && (
+
+ {item.difficulty}
+
+ )}
+
+ {item.why}
+
+
+
+ )
+ })}
+
+ {/* Reading nudge — ties back to the reading thesis */}
+ {!!readingNudge && (
+
+ 📖
+
+ {readingNudge}
+
+
+ )}
+
+
+ {t('tutor.plan.start')}
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ content: { padding: 16, paddingBottom: 32, gap: 10 },
+ rationale: { borderRadius: 14, borderWidth: 1, padding: 14 },
+ rationaleLabel: { fontSize: 12, marginBottom: 6, textTransform: 'uppercase', letterSpacing: 0.5 },
+ rationaleText: { fontSize: 15, lineHeight: 21 },
+ item: { flexDirection: 'row', gap: 12, borderRadius: 14, borderWidth: 1, padding: 14 },
+ index: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center', marginTop: 2 },
+ indexText: { fontSize: 13 },
+ itemBody: { flex: 1 },
+ itemHead: { flexDirection: 'row', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
+ itemWord: { fontSize: 19, flexShrink: 1 },
+ badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, borderWidth: 1 },
+ badgeText: { fontSize: 11 },
+ difficulty: { fontSize: 12, marginTop: 2 },
+ why: { fontSize: 13, lineHeight: 18, marginTop: 6 },
+ nudge: { flexDirection: 'row', gap: 8, borderRadius: 14, padding: 14, alignItems: 'flex-start' },
+ nudgeIcon: { fontSize: 16 },
+ nudgeText: { flex: 1, fontSize: 14, lineHeight: 20 },
+ startBtn: { marginTop: 8, paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
+ startText: { color: '#fff', fontSize: 16 },
+})
diff --git a/apps/mobile/src/hooks/useLibrarian.ts b/apps/mobile/src/hooks/useLibrarian.ts
new file mode 100644
index 00000000..98792556
--- /dev/null
+++ b/apps/mobile/src/hooks/useLibrarian.ts
@@ -0,0 +1,70 @@
+import { useState, useCallback, useRef, useEffect } from 'react'
+import { askLibrarian, isValidLibrarianQuery, type LibrarianResponse } from '../lib/agents'
+
+// Librarian (AI-Agent-3), mobile port of apps/web/src/hooks/useLibrarian.ts.
+// - idle → prompt only, no request issued yet
+// - asking → request in flight ("the librarian is thinking…")
+// - results → got a non-empty recommendation list
+// - empty → ran fine but found no good match
+// - error → request failed (retryable)
+export type LibrarianPhase = 'idle' | 'asking' | 'results' | 'empty' | 'error'
+
+export function useLibrarian() {
+ const [phase, setPhase] = useState('idle')
+ const [response, setResponse] = useState(null)
+ const [error, setError] = useState(null)
+ // The query the current/last request was issued for — kept so retry re-runs the SAME query.
+ const lastQueryRef = useRef('')
+
+ // Mounted guard + in-flight abort so no setState fires after unmount (or after a superseded request).
+ const mountedRef = useRef(true)
+ const abortRef = useRef(null)
+
+ useEffect(() => {
+ mountedRef.current = true
+ return () => {
+ mountedRef.current = false
+ abortRef.current?.abort()
+ }
+ }, [])
+
+ const newSignal = useCallback(() => {
+ abortRef.current?.abort()
+ const ctrl = new AbortController()
+ abortRef.current = ctrl
+ return ctrl.signal
+ }, [])
+
+ const run = useCallback(async (query: string) => {
+ // Client guard mirrors the backend min-length so we don't burn a rate-limited agent run on junk.
+ if (!isValidLibrarianQuery(query)) return
+ lastQueryRef.current = query
+ setPhase('asking')
+ setError(null)
+ const signal = newSignal()
+ try {
+ const res = await askLibrarian(query, signal)
+ if (!mountedRef.current || signal.aborted) return
+ setResponse(res)
+ setPhase(res.recommendations.length === 0 ? 'empty' : 'results')
+ } catch (err) {
+ if (!mountedRef.current || signal.aborted) return
+ setError(err instanceof Error ? err.message : 'Failed to reach the librarian')
+ setPhase('error')
+ }
+ }, [newSignal])
+
+ const retry = useCallback(() => {
+ if (lastQueryRef.current) void run(lastQueryRef.current)
+ }, [run])
+
+ const reset = useCallback(() => {
+ abortRef.current?.abort()
+ setPhase('idle')
+ setResponse(null)
+ setError(null)
+ lastQueryRef.current = ''
+ }, [])
+
+ return { phase, response, error, run, retry, reset }
+}
diff --git a/apps/mobile/src/hooks/useTutorSession.ts b/apps/mobile/src/hooks/useTutorSession.ts
new file mode 100644
index 00000000..e69c568b
--- /dev/null
+++ b/apps/mobile/src/hooks/useTutorSession.ts
@@ -0,0 +1,206 @@
+import { useState, useCallback, useRef, useEffect } from 'react'
+import type { ReviewCardDto } from '@textstack/shared'
+import {
+ startTutorSession,
+ sendTutorFeedback,
+ buildQueue,
+ isSessionComplete,
+ type TutorPlanItem,
+ type TutorSessionResponse,
+ type TutorFeedbackResult,
+} from '../lib/agents'
+
+// Learning Tutor (AI-Agent-2), mobile port of apps/web/src/hooks/useTutorSession.ts. The tutor PLANS what to
+// study next over the learner's real SRS + reading state, then hands off to the existing flashcard. Flow:
+// planning → plan (showcase) → study → (re-plan) → summary + empty/error.
+// The plan is held server-side in a session so the HITL re-plan turn survives across requests.
+
+export type TutorPhase = 'idle' | 'planning' | 'plan' | 'study' | 'summary' | 'empty' | 'error'
+
+export interface TutorSessionStats {
+ studied: number
+ correct: number
+}
+
+// Origin of an error so the error view can retry the RIGHT thing:
+// - 'planning' → re-plan a fresh session via start()
+// - 'feedback' → re-submit the SAME session's pending results (don't lose progress)
+export type TutorErrorOrigin = 'planning' | 'feedback'
+
+const EMPTY_STATS: TutorSessionStats = { studied: 0, correct: 0 }
+
+// Belt-and-suspenders cap on the re-plan loop. The server also caps turns, but never trust it to stop.
+const MAX_ROUNDS = 8
+
+export interface TutorTurn {
+ rationale: string
+ readingNudge: string
+ plan: TutorPlanItem[]
+ queue: { item: TutorPlanItem; card: ReviewCardDto }[]
+}
+
+export function useTutorSession() {
+ const [phase, setPhase] = useState('idle')
+ const [error, setError] = useState(null)
+ const [errorOrigin, setErrorOrigin] = useState(null)
+ const [sessionId, setSessionId] = useState(null)
+ const [turn, setTurn] = useState(null)
+ const [adjusted, setAdjusted] = useState(false) // true once the tutor has re-planned at least once
+ const [currentIndex, setCurrentIndex] = useState(0)
+ const [stats, setStats] = useState(EMPTY_STATS)
+ const [readingNudge, setReadingNudge] = useState('')
+
+ // Results accumulated for the current turn's queue, fed back on completion. Kept on a ref (not state) so a
+ // feedback retry after an error re-sends the SAME pending results without losing the learner's progress.
+ const resultsRef = useRef([])
+ // Re-plan round counter — client backstop against a server that keeps returning items.
+ const roundsRef = useRef(0)
+ // Mounted guard + in-flight abort so no setState fires after unmount when navigating away mid-plan.
+ const mountedRef = useRef(true)
+ const abortRef = useRef(null)
+
+ useEffect(() => {
+ mountedRef.current = true
+ return () => {
+ mountedRef.current = false
+ abortRef.current?.abort()
+ }
+ }, [])
+
+ // Replace any in-flight request's controller and hand back a fresh signal.
+ const newSignal = useCallback(() => {
+ abortRef.current?.abort()
+ const ctrl = new AbortController()
+ abortRef.current = ctrl
+ return ctrl.signal
+ }, [])
+
+ const loadTurn = useCallback((res: TutorSessionResponse, isReplan: boolean) => {
+ if (!mountedRef.current) return
+ setSessionId(res.sessionId)
+ setReadingNudge(res.readingNudge)
+ if (isSessionComplete(res.plan)) {
+ setPhase(isReplan ? 'summary' : 'empty')
+ return
+ }
+ // Stop runaway loops: if the tutor keeps handing back work past the cap, end the session.
+ if (isReplan && roundsRef.current >= MAX_ROUNDS) {
+ setPhase('summary')
+ return
+ }
+ const queue = buildQueue(res.plan)
+ resultsRef.current = []
+ setCurrentIndex(0)
+ setTurn({ rationale: res.rationale, readingNudge: res.readingNudge, plan: res.plan, queue })
+ if (isReplan) setAdjusted(true)
+ setPhase('plan')
+ }, [])
+
+ const start = useCallback(async (maxItems?: number) => {
+ setPhase('planning')
+ setError(null)
+ setErrorOrigin(null)
+ setAdjusted(false)
+ setStats(EMPTY_STATS)
+ roundsRef.current = 0
+ resultsRef.current = []
+ const signal = newSignal()
+ try {
+ const res = await startTutorSession(maxItems, signal)
+ if (!mountedRef.current) return
+ loadTurn(res, false)
+ } catch (err) {
+ if (!mountedRef.current || signal.aborted) return
+ setError(err instanceof Error ? err.message : 'Failed to plan session')
+ setErrorOrigin('planning')
+ setPhase('error')
+ }
+ }, [loadTurn, newSignal])
+
+ const beginStudy = useCallback(() => {
+ setPhase('study')
+ setCurrentIndex(0)
+ }, [])
+
+ // Submit the feedback turn → either continue with the re-planned items or finish. On failure, keep the SAME
+ // session + pending results so retry re-submits (doesn't start a brand-new session).
+ const submitFeedback = useCallback(async () => {
+ if (!sessionId) return
+ setPhase('planning')
+ roundsRef.current += 1
+ const signal = newSignal()
+ try {
+ const res = await sendTutorFeedback(sessionId, resultsRef.current, signal)
+ if (!mountedRef.current) return
+ loadTurn(res, true)
+ } catch (err) {
+ if (!mountedRef.current || signal.aborted) return
+ setError(err instanceof Error ? err.message : 'Failed to update plan')
+ setErrorOrigin('feedback')
+ setPhase('error')
+ }
+ }, [sessionId, loadTurn, newSignal])
+
+ // Retry after an error: re-plan a fresh session, or re-submit the same session's pending feedback.
+ const retry = useCallback(() => {
+ if (errorOrigin === 'feedback') {
+ setError(null)
+ setErrorOrigin(null)
+ void submitFeedback()
+ } else {
+ void start()
+ }
+ }, [errorOrigin, submitFeedback, start])
+
+ // Record a result for the current card and advance; on the last card, trigger the feedback re-plan.
+ const answer = useCallback((correct: boolean, responseTimeMs: number) => {
+ const queue = turn?.queue
+ if (!queue) return
+ const entry = queue[currentIndex]
+ if (!entry) return
+ resultsRef.current = [
+ ...resultsRef.current,
+ { wordId: entry.item.wordId, correct, responseTimeMs },
+ ]
+ setStats(prev => ({ studied: prev.studied + 1, correct: prev.correct + (correct ? 1 : 0) }))
+ const nextIdx = currentIndex + 1
+ if (nextIdx >= queue.length) {
+ void submitFeedback()
+ } else {
+ setCurrentIndex(nextIdx)
+ }
+ }, [turn, currentIndex, submitFeedback])
+
+ const reset = useCallback(() => {
+ abortRef.current?.abort()
+ setPhase('idle')
+ setError(null)
+ setErrorOrigin(null)
+ setSessionId(null)
+ setTurn(null)
+ setAdjusted(false)
+ setCurrentIndex(0)
+ setStats(EMPTY_STATS)
+ resultsRef.current = []
+ roundsRef.current = 0
+ }, [])
+
+ const currentEntry = turn?.queue[currentIndex] ?? null
+
+ return {
+ phase,
+ error,
+ errorOrigin,
+ turn,
+ adjusted,
+ currentIndex,
+ currentEntry,
+ stats,
+ readingNudge,
+ start,
+ beginStudy,
+ answer,
+ retry,
+ reset,
+ }
+}
diff --git a/apps/mobile/src/lib/agents.test.ts b/apps/mobile/src/lib/agents.test.ts
new file mode 100644
index 00000000..798e6aa1
--- /dev/null
+++ b/apps/mobile/src/lib/agents.test.ts
@@ -0,0 +1,144 @@
+import { describe, it, expect } from 'vitest'
+import {
+ isValidLibrarianQuery,
+ buildPlanCard,
+ buildQueue,
+ isSessionComplete,
+ exerciseLabel,
+ exerciseBadgeColor,
+ MIN_QUERY_LENGTH,
+ MAX_QUERY_LENGTH,
+ type TutorPlanItem,
+} from './agents'
+
+function planItem(overrides: Partial = {}): TutorPlanItem {
+ return {
+ wordId: 'w1',
+ word: 'ephemeral',
+ stage: 2,
+ exerciseType: 'recall',
+ difficulty: 'Medium',
+ why: 'You missed this last time.',
+ translation: 'тимчасовий',
+ definition: 'lasting a very short time',
+ sentence: 'An ephemeral moment of joy.',
+ bookTitle: 'Some Book',
+ hint: 'starts with e',
+ distractors: ['a', 'b'],
+ ...overrides,
+ }
+}
+
+describe('isValidLibrarianQuery', () => {
+ it('rejects too-short queries (after trim)', () => {
+ expect(isValidLibrarianQuery('')).toBe(false)
+ expect(isValidLibrarianQuery('a')).toBe(false)
+ expect(isValidLibrarianQuery(' a ')).toBe(false)
+ })
+
+ it('accepts queries at and above the minimum trimmed length', () => {
+ expect(isValidLibrarianQuery('ab')).toBe(true)
+ expect(isValidLibrarianQuery(' ab ')).toBe(true)
+ expect(isValidLibrarianQuery('books like 1984')).toBe(true)
+ })
+
+ it('rejects queries longer than the max (raw length, not trimmed)', () => {
+ expect(isValidLibrarianQuery('x'.repeat(MAX_QUERY_LENGTH))).toBe(true)
+ expect(isValidLibrarianQuery('x'.repeat(MAX_QUERY_LENGTH + 1))).toBe(false)
+ })
+
+ it('exposes the documented bounds', () => {
+ expect(MIN_QUERY_LENGTH).toBe(2)
+ expect(MAX_QUERY_LENGTH).toBe(500)
+ })
+})
+
+describe('buildPlanCard', () => {
+ it('projects an enriched plan item into a context-mode ReviewCardDto', () => {
+ const card = buildPlanCard(planItem())
+ expect(card).toMatchObject({
+ wordId: 'w1',
+ word: 'ephemeral',
+ translation: 'тимчасовий',
+ definition: 'lasting a very short time',
+ reviewMode: 'context',
+ originalSentence: 'An ephemeral moment of joy.',
+ bookTitle: 'Some Book',
+ hint: 'starts with e',
+ isNew: false,
+ blankSentence: null,
+ explanation: null,
+ options: null,
+ correctOptionIndex: null,
+ })
+ })
+
+ it('coerces missing optional fields to null (never undefined)', () => {
+ const card = buildPlanCard(planItem({
+ translation: undefined,
+ definition: undefined,
+ sentence: undefined,
+ bookTitle: undefined,
+ hint: undefined,
+ }))
+ expect(card.translation).toBeNull()
+ expect(card.definition).toBeNull()
+ expect(card.originalSentence).toBeNull()
+ expect(card.bookTitle).toBeNull()
+ expect(card.hint).toBeNull()
+ })
+})
+
+describe('buildQueue', () => {
+ it('produces one entry per plan item, preserving order, nothing dropped', () => {
+ const plan = [planItem({ wordId: 'a' }), planItem({ wordId: 'b' }), planItem({ wordId: 'c' })]
+ const queue = buildQueue(plan)
+ expect(queue).toHaveLength(3)
+ expect(queue.map(q => q.item.wordId)).toEqual(['a', 'b', 'c'])
+ expect(queue.map(q => q.card.wordId)).toEqual(['a', 'b', 'c'])
+ })
+
+ it('returns an empty queue for an empty plan', () => {
+ expect(buildQueue([])).toEqual([])
+ })
+})
+
+describe('isSessionComplete', () => {
+ it('is true only when the re-plan is empty', () => {
+ expect(isSessionComplete([])).toBe(true)
+ expect(isSessionComplete([planItem()])).toBe(false)
+ })
+})
+
+describe('exerciseLabel', () => {
+ const t = (key: string) => `i18n:${key}`
+
+ it('uses the i18n key for known exercise types', () => {
+ expect(exerciseLabel('recognition', t)).toBe('i18n:tutor.exercise.recognition')
+ expect(exerciseLabel('recall', t)).toBe('i18n:tutor.exercise.recall')
+ expect(exerciseLabel('context', t)).toBe('i18n:tutor.exercise.context')
+ })
+
+ it('falls back to the raw value for an unknown model type (no leaked i18n key)', () => {
+ expect(exerciseLabel('cloze_madness', t)).toBe('cloze_madness')
+ })
+
+ it('falls back to a generic label for blank/whitespace types', () => {
+ expect(exerciseLabel('', t)).toBe('i18n:tutor.exercise.generic')
+ expect(exerciseLabel(' ', t)).toBe('i18n:tutor.exercise.generic')
+ })
+})
+
+describe('exerciseBadgeColor', () => {
+ const palette = { recognition: '#a', recall: '#b', context: '#c', fallback: '#z' }
+
+ it('maps known types to their accent', () => {
+ expect(exerciseBadgeColor('recognition', palette)).toBe('#a')
+ expect(exerciseBadgeColor('recall', palette)).toBe('#b')
+ expect(exerciseBadgeColor('context', palette)).toBe('#c')
+ })
+
+ it('maps unknown types to the fallback', () => {
+ expect(exerciseBadgeColor('???', palette)).toBe('#z')
+ })
+})
diff --git a/apps/mobile/src/lib/agents.ts b/apps/mobile/src/lib/agents.ts
new file mode 100644
index 00000000..275a402b
--- /dev/null
+++ b/apps/mobile/src/lib/agents.ts
@@ -0,0 +1,193 @@
+import { authFetch } from '@textstack/shared'
+import type { ReviewCardDto } from '@textstack/shared'
+
+// AI agents shared by the mobile Tutor ("Smart session") and Librarian ("Ask the librarian") screens.
+// RN-free on purpose: the DTOs, the typed `authFetch` calls, and the pure helpers below are all unit-testable
+// under Vitest (see agents.test.ts) without bundling React Native. Mirrors the web clients/hooks 1:1
+// (apps/web/src/api/{tutor,librarian}.ts) plus the same hardening (clamp untrusted text, unknown-type fallback,
+// re-plan cap, client-side query guard).
+
+// ---------------------------------------------------------------------------
+// Tutor (AI-Agent-2) — types mirror Contracts/Agents/TutorDtos.cs (camelCase via the API)
+// ---------------------------------------------------------------------------
+
+/**
+ * One planned study item. The backend ENRICHES each item with the full card payload (translation, definition,
+ * sentence, bookTitle, hint, distractors), so the UI renders the study card straight from the plan — no separate
+ * vocab fetch + join. References a REAL vocab card by `wordId`, with per-item `why` reasoning.
+ */
+export interface TutorPlanItem {
+ wordId: string
+ word: string
+ stage: number
+ exerciseType: string // recognition | recall | context (untrusted — model may emit anything)
+ difficulty: string // label string
+ why: string // per-item reasoning
+ translation?: string | null
+ definition?: string | null
+ sentence?: string | null
+ bookTitle?: string | null
+ hint?: string | null
+ distractors: string[] // [] when none, never null
+}
+
+/** The tutor's response: the persisted session, the ordered plan, and the surfaced reasoning. */
+export interface TutorSessionResponse {
+ sessionId: string
+ plan: TutorPlanItem[]
+ rationale: string // overall session reasoning
+ readingNudge: string // ties back to reading (the thesis)
+ runId: string
+}
+
+/** One learner result fed back to the tutor for re-planning. */
+export interface TutorFeedbackResult {
+ wordId: string
+ correct: boolean
+ responseTimeMs: number
+}
+
+/** Plan a new tutor session over the learner's current state. `maxItems` is optional (server-capped). */
+export function startTutorSession(maxItems?: number, signal?: AbortSignal): Promise {
+ return authFetch('/me/tutor/session', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(maxItems != null ? { maxItems } : {}),
+ signal,
+ })
+}
+
+/**
+ * Submit the learner's results for the current session and get the re-planned remainder. An empty `plan` in the
+ * response means the session is complete.
+ */
+export function sendTutorFeedback(
+ sessionId: string,
+ results: TutorFeedbackResult[],
+ signal?: AbortSignal,
+): Promise {
+ return authFetch(`/me/tutor/session/${sessionId}/feedback`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ results }),
+ signal,
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Librarian (AI-Agent-3) — types mirror Contracts/Agents/LibrarianDtos.cs (camelCase via the API)
+// ---------------------------------------------------------------------------
+
+export const MIN_QUERY_LENGTH = 2
+export const MAX_QUERY_LENGTH = 500
+
+/**
+ * Where a recommendation came from. `library` → the book IS in the catalog (`slug`/`editionId` set) — link to its
+ * page. `open_library` → an external suggestion NOT in the library yet (no slug) — never navigate in-app.
+ */
+export type LibrarianSource = 'library' | 'open_library'
+
+/** One ranked recommendation. `why` is the per-item, request-grounded reason. */
+export interface LibrarianRecommendation {
+ source: LibrarianSource
+ editionId?: string | null
+ slug?: string | null
+ title: string
+ authors: string[]
+ why: string
+ language?: string | null
+ year?: number | null
+ pages?: number | null
+}
+
+/**
+ * The librarian's response: ranked `recommendations`, the overall `reasoning`, `usedExternal` (did it expand to
+ * Open Library because the library was thin?) and the persisted `runId` for replay in the admin UI.
+ */
+export interface LibrarianResponse {
+ recommendations: LibrarianRecommendation[]
+ reasoning: string
+ usedExternal: boolean
+ runId: string
+}
+
+/** Run the librarian on a natural-language request. Auth required (`/me/*`); rate-limited server-side. */
+export function askLibrarian(query: string, signal?: AbortSignal): Promise {
+ return authFetch('/me/librarian', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query }),
+ signal,
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Pure helpers (RN-free, unit-tested) — shared between hooks/components
+// ---------------------------------------------------------------------------
+
+/** Pure client guard mirroring the backend: ≥2 trimmed chars, ≤500 chars. Returns true when worth an agent run. */
+export function isValidLibrarianQuery(query: string): boolean {
+ const trimmed = query.trim()
+ return trimmed.length >= MIN_QUERY_LENGTH && query.length <= MAX_QUERY_LENGTH
+}
+
+/**
+ * Builds a classic-flashcard `ReviewCardDto` directly from an ENRICHED plan item. The backend already validated +
+ * enriched every item, so there's no vocab fetch + join — the plan item is self-sufficient. Pure projection.
+ */
+export function buildPlanCard(item: TutorPlanItem): ReviewCardDto {
+ return {
+ wordId: item.wordId,
+ word: item.word,
+ translation: item.translation ?? null,
+ definition: item.definition ?? null,
+ reviewMode: 'context',
+ blankSentence: null,
+ originalSentence: item.sentence ?? null,
+ bookTitle: item.bookTitle ?? null,
+ hint: item.hint ?? null,
+ explanation: null,
+ isNew: false,
+ options: null,
+ correctOptionIndex: null,
+ }
+}
+
+/** Projects an enriched plan into renderable study entries — one card per item, nothing dropped. */
+export function buildQueue(plan: TutorPlanItem[]): { item: TutorPlanItem; card: ReviewCardDto }[] {
+ return plan.map(item => ({ item, card: buildPlanCard(item) }))
+}
+
+/** A re-plan with no items means the tutor decided the session is done. */
+export function isSessionComplete(plan: TutorPlanItem[]): boolean {
+ return plan.length === 0
+}
+
+const KNOWN_EXERCISE_TYPES = new Set(['recognition', 'recall', 'context'])
+
+/**
+ * Label for an exercise type. Known types resolve via i18n; anything unexpected from the model falls back to the
+ * raw value (or a generic label) rather than leaking `tutor.exercise.`.
+ */
+export function exerciseLabel(exerciseType: string, t: (key: string) => string): string {
+ if (KNOWN_EXERCISE_TYPES.has(exerciseType)) return t(`tutor.exercise.${exerciseType}`)
+ const raw = exerciseType?.trim()
+ return raw ? raw : t('tutor.exercise.generic')
+}
+
+/** Known exercise types map to a themed accent color; unknown types get the neutral fallback. */
+export function exerciseBadgeColor(
+ exerciseType: string,
+ palette: { recognition: string; recall: string; context: string; fallback: string },
+): string {
+ switch (exerciseType) {
+ case 'recognition':
+ return palette.recognition
+ case 'recall':
+ return palette.recall
+ case 'context':
+ return palette.context
+ default:
+ return palette.fallback
+ }
+}
diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts
index 8f3ca3b8..d347285d 100644
--- a/apps/mobile/src/lib/api.ts
+++ b/apps/mobile/src/lib/api.ts
@@ -130,4 +130,22 @@ export async function deleteAccount(accessToken: string): Promise {
}
}
+// AI agent endpoints (Tutor "Smart session" + Librarian "Ask the librarian"). Implemented in ./agents on top of
+// the shared `authFetch` (Bearer auth, base URL, error/status handling) — re-exported here so callers reach them
+// through the consolidated api module, alongside the request types.
+export {
+ startTutorSession,
+ sendTutorFeedback,
+ askLibrarian,
+ isValidLibrarianQuery,
+} from './agents'
+export type {
+ TutorPlanItem,
+ TutorSessionResponse,
+ TutorFeedbackResult,
+ LibrarianSource,
+ LibrarianRecommendation,
+ LibrarianResponse,
+} from './agents'
+
export { API_URL }
diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts
index 04b725b0..42f7ed3e 100644
--- a/apps/mobile/vitest.config.ts
+++ b/apps/mobile/vitest.config.ts
@@ -35,6 +35,13 @@ export default defineConfig({
find: '@react-native-async-storage/async-storage',
replacement: resolve(__dirname, 'src/lib/__mocks__/async-storage.ts'),
},
+ // Shared workspace package is a source path-alias (not built) — point
+ // Vitest at its source entry so lib tests that import RN-free helpers
+ // from modules which also re-export `authFetch` (e.g. agents.ts) resolve.
+ {
+ find: '@textstack/shared',
+ replacement: resolve(__dirname, '../../packages/shared/src/index.ts'),
+ },
],
},
define: {
diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json
index 9e407dbe..66af7a17 100644
--- a/packages/shared/src/i18n/en.json
+++ b/packages/shared/src/i18n/en.json
@@ -1,4 +1,77 @@
{
+ "tutor": {
+ "title": "Smart session",
+ "planning": "Your tutor is planning your session…",
+ "entry": {
+ "cta": "Smart session",
+ "hint": "Let your AI tutor plan what to study and explain why"
+ },
+ "plan": {
+ "rationaleLabel": "Here's your plan, and why",
+ "adjustedLabel": "Your tutor adjusted your plan",
+ "adjustedNote": "Your tutor adjusted your plan based on how you did.",
+ "start": "Start studying"
+ },
+ "exercise": {
+ "recognition": "Recognition",
+ "recall": "Recall",
+ "context": "Context",
+ "generic": "Exercise"
+ },
+ "summary": {
+ "done": "Session complete",
+ "studied": "Studied",
+ "accuracy": "Accuracy",
+ "back": "Back to vocabulary"
+ },
+ "empty": {
+ "title": "Nothing to study right now",
+ "subtitle": "You're all caught up. Keep reading to grow your vocabulary.",
+ "cta": "Browse books"
+ },
+ "error": {
+ "title": "Couldn't plan your session",
+ "subtitle": "Something went wrong reaching your tutor.",
+ "retry": "Try again"
+ },
+ "signIn": {
+ "title": "Sign in for a smart session",
+ "subtitle": "Your AI tutor plans what to study from the words you save while reading.",
+ "cta": "Sign in"
+ }
+ },
+ "librarian": {
+ "title": "Ask the librarian",
+ "subtitle": "Describe what you want to read and the librarian reasons over the library to recommend the best fits.",
+ "placeholder": "Describe what you want to read — e.g. 'books like 1984 about surveillance, under 300 pages'",
+ "inputLabel": "What do you want to read?",
+ "ask": "Ask",
+ "thinking": "The librarian is thinking…",
+ "reasoningLabel": "Here's what I found and why",
+ "usedExternalNote": "The library was thin on this, so I reached beyond the catalog for a couple of suggestions.",
+ "suggestionBadge": "Suggestion",
+ "suggestionNote": "Not in your library yet — an outside suggestion.",
+ "openBook": "Open book",
+ "unknownAuthor": "Unknown author",
+ "pages": "pages",
+ "entry": {
+ "hint": "Tell the librarian what you want to read"
+ },
+ "empty": {
+ "title": "Couldn't find a good match",
+ "subtitle": "Try rephrasing your request — add a theme, a comparable book, or a length."
+ },
+ "error": {
+ "title": "Couldn't reach the librarian",
+ "subtitle": "Something went wrong. Please try again.",
+ "retry": "Try again"
+ },
+ "signIn": {
+ "title": "Sign in to ask the librarian",
+ "subtitle": "The librarian recommends books from a natural-language request, with its reasoning.",
+ "cta": "Sign in"
+ }
+ },
"home": {
"hero": {
"title": "Learn languages by reading real books",