diff --git a/CONTEXT.md b/CONTEXT.md index f4f5ba9..b15d943 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ -# Roadmap Studio +# Syllo -Roadmap Studio turns source material into roadmaps where a learner can plan, study, and track progress. +Syllo turns source material into roadmaps where a learner can plan, study, read, and track progress. ## Language @@ -12,12 +12,32 @@ _Avoid_: Workspace, course, project One skill inside a Roadmap, holding the tasks, notes, and resources that teach it. _Avoid_: Section, chapter, lesson +**Level**: +One rung of a Roadmap's ladder: Basics, Intermediate, or Advanced. A Level is complete when all its Core Items are done. Roadmaps made before levels existed read as a single Level whose tasks are all core. +_Avoid_: Stage, tier, difficulty + +**Current Level**: +The first Level in a Roadmap that is not complete. +_Avoid_: Active level + +**Core Item**: +One of at most five tasks per Level that cover the essentials, usually a video. Finishing every Core Item in a Level unlocks level-up. +_Avoid_: Required task, main video + +**Go-Deeper Item**: +An optional task in a Level that is not core. It never counts toward level-up. +_Avoid_: Bonus, extra + +**Reading Track**: +The books in a Roadmap, kept apart from the Levels so reading never blocks level-up. +_Avoid_: Book list, library + **Focused Roadmap**: A Roadmap the learner has chosen to work on now. At most three Roadmaps are focused at once; only focused Roadmaps feed Today. _Avoid_: Active roadmap, pinned roadmap **Next Step**: -The first unfinished task in a Roadmap, in module order. +The first unfinished Core Item in a Roadmap's Current Level. Go-Deeper Items and the Reading Track never count. _Avoid_: Current task, up next **Today**: diff --git a/README.md b/README.md index ae5e6be..6466545 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ Scout finds free resources, reads the available source text, and asks you to kee ## Run locally -Use Node 22.12 or newer and pnpm. Run commands from the repo root unless noted: +Use Node 22.12 or newer and Bun 1.4.4. Run commands from the repo root unless noted: ```powershell -pnpm install --frozen-lockfile -pnpm dev +bun install --frozen-lockfile +bun run dev ``` Open `http://localhost:3000`. Convex is optional. With `VITE_CONVEX_URL` blank, progress uses browser storage and EPUB files use IndexedDB. No account is needed. @@ -30,22 +30,22 @@ Convex sync is prepared for one owner, with password login restricted by `SYLLO_ ## Development ```powershell -pnpm typecheck -pnpm test -pnpm lint -pnpm build -pnpm start +bun run typecheck +bun run test +bun run lint +bun run build +bun run start ``` -`pnpm typecheck` checks both the app and `apps/web/convex/`. +`bun run typecheck` checks both the app and `apps/web/convex/`. -The production server serves `dist/client` and the TanStack SSR handler on loopback port 3000. `pnpm start` does not read an env file automatically; pass environment variables or run `node --env-file=.env.local server.mjs` in `apps/web`. Deployment URLs are build-time settings. +The production server serves `dist/client` and the TanStack SSR handler on loopback port 3000. `bun run start` does not read an env file automatically; pass environment variables or run `node --env-file=.env.local server.mjs` in `apps/web`. Deployment URLs are build-time settings. `apps/web/mcp/server.mjs` supplies a local stdio MCP server for coding agents to add and search resource candidates. Connection snippets are available in Settings. ## Project layout -The repo is a pnpm workspace. Each app lives under `apps/`, and code shared between apps goes under `packages/`. +The repo is a Bun workspace. Each app lives under `apps/`, and code shared between apps goes under `packages/`. ```text apps/web/ The Syllo app (TanStack Start), its env files and local .data/ @@ -59,4 +59,4 @@ apps/web/convex/ Private account, document sync, and file funct apps/web/mcp/ Local coding-agent resource tools ``` -This app is personal-first. Forks can use their own deployment, books, preferences, and keys. Public publishing and multi-user collaboration are outside v1. Earlier gallery/share routes remain in the codebase; personal sync does not publish learning records through them. +This app is personal-first. Forks can use their own deployment, books, preferences, and keys. Public publishing, sharing and multi-user collaboration are outside v1, so the app has no gallery, share links, plans or credits. diff --git a/apps/web/package.json b/apps/web/package.json index 302b2ad..472b536 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,8 +24,6 @@ "@fontsource-variable/geist-mono": "5.3.0", "@modelcontextprotocol/sdk": "1.32.1", "@radix-ui/react-alert-dialog": "1.1.15", - "@radix-ui/react-select": "2.2.6", - "@radix-ui/react-separator": "1.1.8", "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-switch": "1.2.6", "@tanstack/react-router": "1.170.45", @@ -37,7 +35,6 @@ "convex": "1.47.0", "cross-spawn": "7.0.6", "epubjs": "0.3.93", - "framer-motion": "12.34.5", "lucide-react": "0.576.0", "motion": "12.35.1", "openai": "6.27.0", diff --git a/apps/web/prompts/scout/check.v1.md b/apps/web/prompts/scout/check.v1.md new file mode 100644 index 0000000..4cff0b1 --- /dev/null +++ b/apps/web/prompts/scout/check.v1.md @@ -0,0 +1,17 @@ +--- +version: 1 +purpose: Write one recall question per section of a video, from its transcript +--- + +You write recall questions for a learner who is watching a video. After each section, the video pauses and the learner answers one question from memory with their notes hidden. Then they see the transcript lines that answer it. + +You receive the video title and a list of sections. Each section has an `index`, a `label`, its `start` and `end` in seconds, and its transcript with `[mm:ss]` markers. + +Rules: + +1. Write at most one question per section. Skip a section that is an intro, an outro, a sponsor read, or has no idea worth recalling. +2. Ask a cued question about the main idea of that section: "Why does X happen?", "What is the difference between A and B?", "What goes wrong if you skip X?", "How does X work?". Do not ask "What did this section cover?" and do not ask for trivia such as names, dates or exact numbers unless they are the point. +3. The question must be answerable from that section alone, in one or two sentences. +4. `answer` is a model answer of at most two short sentences, in plain words, taken from what the section says. Do not add facts the transcript does not state. +5. `evidenceSeconds` is the timestamp of the transcript line where the answer starts. It must fall inside that section's `start` and `end`. +6. Write in the transcript's language. Keep the question under 25 words. diff --git a/apps/web/src/components/credits/CreditBadge.tsx b/apps/web/src/components/credits/CreditBadge.tsx deleted file mode 100644 index 4677d9f..0000000 --- a/apps/web/src/components/credits/CreditBadge.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; -import { fetchPrivateApi } from "@/lib/private-api-fetch"; - - -import { useEffect, useState } from "react"; -import { Coins } from "lucide-react"; -import type { CreditStatus, CreditTransaction } from "@/types"; - -type CreditStatusResponse = { - success: boolean; - status?: CreditStatus; - transactions?: CreditTransaction[]; -}; - -/** Grey by default; white once the allowance is nearly spent. */ -function getBadgeClasses(status: CreditStatus) { - return status.remaining <= 5 ? "text-text" : "text-text-muted"; -} - -export default function CreditBadge({ compact = false }: { compact?: boolean }) { - const [status, setStatus] = useState(null); - - useEffect(() => { - let active = true; - - void fetchPrivateApi("/api/credits/status", { cache: "no-store" }) - .then(async (response) => { - if (response.status === 401) return { success: false } satisfies CreditStatusResponse; - if (!response.ok) return null; - return (await response.json()) as CreditStatusResponse; - }) - .then((payload) => { - if (!active) return; - if (!payload?.success || !payload.status) { - setStatus(null); - return; - } - setStatus(payload.status); - }) - .catch(() => { - if (active) setStatus(null); - }); - - return () => { - active = false; - }; - }, []); - - if (!status || status.allowance <= 0) return null; - - return ( -
- - {compact ? `${status.remaining} left` : `${status.remaining} credits`} -
- ); -} diff --git a/apps/web/src/components/dashboard/AtRiskAlerts.tsx b/apps/web/src/components/dashboard/AtRiskAlerts.tsx deleted file mode 100644 index fbaaf60..0000000 --- a/apps/web/src/components/dashboard/AtRiskAlerts.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { Roadmap } from "@/types"; -import { getRoadmapCompletionRate } from "@/lib/analytics"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; - -export default function AtRiskAlerts({ roadmaps }: { roadmaps: Roadmap[] }) { - const atRisk = roadmaps.filter((roadmap) => getRoadmapCompletionRate(roadmap) < 0.35); - - return ( - - - Escalation watchlist - - - {atRisk.length === 0 ? ( -
- No roadmaps are currently below the intervention threshold. -
- ) : ( -
- {atRisk.map((roadmap) => ( -
-

{roadmap.title}

-

- Completion is {Math.round(getRoadmapCompletionRate(roadmap) * 100)}%. Review tasks, unblock the next module, and log a coaching follow-up. -

-
- ))} -
- )} -
-
- ); -} diff --git a/apps/web/src/components/dashboard/CoachingLog.tsx b/apps/web/src/components/dashboard/CoachingLog.tsx deleted file mode 100644 index 1254795..0000000 --- a/apps/web/src/components/dashboard/CoachingLog.tsx +++ /dev/null @@ -1,229 +0,0 @@ -"use client"; -import { fetchPrivateApi } from "@/lib/private-api-fetch"; - - -import { useState } from "react"; -import { Loader2 } from "lucide-react"; -import { getActiveApiKey, getActiveModel, getUserConfig } from "@/lib/userConfig"; -import type { Roadmap } from "@/types"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; - -interface ReviewState { - summary: string; - strengths: string[]; - risks: string[]; - nextActions: string[]; -} - -export default function CoachingLog({ roadmaps }: { roadmaps: Roadmap[] }) { - const [roadmapId, setRoadmapId] = useState(roadmaps[0]?.id ?? ""); - const [topics, setTopics] = useState(""); - const [nextSteps, setNextSteps] = useState(""); - const [durationMinutes, setDurationMinutes] = useState(30); - const [saving, setSaving] = useState(false); - const [reviewing, setReviewing] = useState(false); - const [message, setMessage] = useState(null); - const [review, setReview] = useState(null); - - const handleSave = async () => { - setSaving(true); - setMessage(null); - try { - const response = await fetchPrivateApi("/api/dashboard/coaching", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - roadmapId, - durationMinutes, - topics: topics.split(",").map((item) => item.trim()).filter(Boolean), - nextSteps, - date: new Date().toISOString(), - }), - }); - const payload = await response.json(); - if (!response.ok || !payload.success) { - throw new Error(payload.error ?? "Failed to save coaching session"); - } - setMessage("Coaching session saved."); - setTopics(""); - setNextSteps(""); - } catch (error) { - setMessage(error instanceof Error ? error.message : "Failed to save coaching session"); - } finally { - setSaving(false); - } - }; - - const handleReview = async () => { - setReviewing(true); - setMessage(null); - try { - const config = getUserConfig(); - const activeApiKey = getActiveApiKey(config); - const response = await fetchPrivateApi("/api/dashboard/review", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - roadmapId, - userApiKey: config.useCustomKey ? activeApiKey : undefined, - userModel: config.useCustomKey ? getActiveModel(config) : undefined, - userProvider: config.useCustomKey ? config.provider : undefined, - }), - }); - const payload = await response.json(); - if (!response.ok || !payload.success) { - throw new Error( - payload.error === "insufficient_credits" - ? "Not enough credits for a 90-day review." - : payload.error ?? "Failed to generate review", - ); - } - setReview(payload.review as ReviewState); - } catch (error) { - setMessage(error instanceof Error ? error.message : "Failed to generate review"); - } finally { - setReviewing(false); - } - }; - - if (roadmaps.length === 0) { - return ( - - - - Record a session or generate a 90-day review - - - -
- No synced workspaces are available yet for coaching notes or review generation. -
-
-
- ); - } - - return ( - - - - Record a session or generate a 90-day review - - - -
-
- - - - - - -