Learn anything in Claude Code with a plan you approve first and a check you pass at every step. Looks facts up on the web before it teaches.

<h1 align="center">sensei</h1>
<a href="https://github.com/rishichawda/sensei/actions/workflows/ci.yml"><img src="https://github.com/rishichawda/sensei/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="https://github.com/rishichawda/sensei/releases"><img src="https://img.shields.io/github/v/release/rishichawda/sensei" alt="Release"></a> <a href="https://github.com/rishichawda/sensei/blob/main/LICENSE"><img src="https://img.shields.io/github/license/rishichawda/sensei" alt="License"></a>
<!-- Demo GIF goes here. -->
An AI explanation can be very clear as you're reading it, and then be completely gone by the next day. Sensei is built around two habits that combat that.
You see the path before you walk it. Before teaching sensei asks what you want from the topic, assesses your current level with a few graded questions and then draws up the plan as a dependency map showing what ideas build on what. You either approve the map or make changes to it. It then teaches in that order so nothing is explained before the idea it depends on.
You prove each step as you go. After each chunk of the lesson the Sensei will ask a short question. A hook will grade your answer and record it so the outcome is not dependent on what the model says happened. A correct answer sees you move on. A miss gets you a fresh explanation and another go.
<table> <tr> <td width="50%"><img src="assets/plan-proposed.png" alt="The proposed plan, shown in the map pane and marked as not approved yet." width="100%"></td> <td width="50%"><img src="assets/check-question.png" alt="A check question after the explanation, with the map pane still open." width="100%"></td> </tr> </table>
Sensei remembers. It saves your progress per topic on your machine so when you come back it picks up where you left off and skips the bits you've already done.
claude plugin marketplace add https://github.com/rishichawda/sensei
claude plugin install sensei@rishichawda
Start a new session and ask Claude: "Teach me how DNS resolution works".
Other ways to start:
A map you can open. Type /map to get a pane showing the approved plan with one chip per step: ○ not started, ● in progress, ✓ passed, ✗ missed. Click on a chip to see what it builds on and what it builds to. A line appears above the prompt indicating your current position in the lesson and a toast appears for each pass/miss with the number of questions answered correctly. The map is not drawn in the chat.
Notes in your Obsidian vault. Set vault path via /plugin configure sensei and sensei will save what you learn, one note per concept under sensei/<topic>/. The note will contain the explanation, a flashcard for the Spaced Repetition plugin, your answers and how any mistake was re-explained. A _map.md note contains the plan as a diagram which Obsidian will draw up for you. The path takes effect on your next message. Concept notes are only ever appended to; sensei only rewrites _map.md.
Diagrams when they're useful. A lesson can contain a diagram created by a bundled helper which will check its own output prior to displaying it. Mermaid is used for structure and flow, SVG for geometry and plots. If a diagram can't be generated then the lesson proceeds without it.
Hooks. hooks/hooks.json runs Node scripts that ship with the plugin, at session start, on each prompt, around each AskUserQuestion call, and after Bash and Skill calls. During a lesson they grade your answer to each check question and record the result, add a reminder for the model when a question is missing its tag or a verdict, and mirror the lesson to your vault if you set one. On a prompt that contains "teach me", "continue teaching" or "resume teaching" they add a reminder to use the teach skill. The one hook that runs before a call (on AskUserQuestion) can only deny it, to ask for a missing tag; it never approves a call. The hooks read the tool call and result, not the conversation transcript; the transcript's file name is only used to place a small marker file beside it.
Files it writes. Under your Claude config directory (~/.claude, or CLAUDE_CONFIG_DIR), in sensei/: one progress ledger per topic in ledgers/, a file per running lesson in active/, the vault path you set, and two debug logs (mastery-hook-debug.log, vault-debug.log) that keep the last 200 lines each. They hold topic and step names, the lesson's tag data (which can include the label of the correct option) and error messages, not the text of your questions or your answers. Beside the session transcript: two small marker files ending in .teach-active-state.json and .teach-invoked-state.json. In your vault, only if you set one: notes under <vault>/sensei/. In the current project: rendered diagrams as PNG files in viz/. While drawing a Mermaid diagram it also uses a temporary folder, which it deletes afterwards. Sensei writes nowhere else. Delete the sensei/ folder to remove your progress.
What it runs. The diagram helpers run Mermaid CLI and a local copy of Chrome or Chromium to draw Mermaid diagrams, and @resvg/resvg-js to draw SVG, on your machine. Chrome is started with no network access, so a diagram cannot make it request a URL. Claude Code installs these npm packages from the plugin's lockfile when you install the plugin. The mod in hooks/mod/register.tsx registers the /map command, handles only that command, and draws the map pane, the line above the prompt and the toasts from the ledger files, which it reads once a second. It changes no other call.
What it sends. Sensei's own code makes no network requests, has no telemetry and sends nothing to the author. The one thing that leaves your machine is the web lookups. Before it teaches, and whenever it is unsure of a fact, sensei starts the researcher agent, which uses Claude Code's WebSearch and WebFetch tools to search the web and read pages. The model writes the search text, so it can contain the topic you asked about and what you told it you want from the topic. Sensei's code does not add your files, vault notes, progress or answers to a search. The agent treats the pages it reads as data, never as instructions. See PRIVACY.md for the full list and how to delete your data.
One weekend morning, coffee in hand at my desk, I came across a YouTube video about using AI and Obsidian to learn anything. I use Obsidian and love picking up new skills, so I wanted a tutor in the tool I already use, one that keeps what I learn in my own notes. The video linked a project, but that was for pi. So I made one myself as a Claude Code plugin. It plans before it teaches, checks before it moves on, and remembers where I stopped.
See CONTRIBUTING.md and the code of conduct. Sensei is released under the Apache License 2.0.
hooks/mod/register.tsx 142 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { MapView } from '../../types'
5import { displayName } from '../vault/graph.mjs'
6import { bandLine, changedStatuses, chipLabel, drawnPlan, goalOf, layers, nodeDetail, progress, proposedPlan, snapshot } from './map-layout.mjs'
7
8const PANE = 'map'
9const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
10const view = atom({ plugin: 'sensei', key: 'view' } as const, { topic: null, ledger: null, selected: null } as MapView)
11
12// The planning glyph's frame; only moves while a lesson is being planned.
13const spin = atom({ plugin: 'sensei', key: 'spin' } as const, 0)
14
15async function configRoot($: any): Promise<string | null> {
16 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
17 if (dir) return dir
18 const home = await $.env.get('HOME')
19 return home ? `${home}/.claude` : null
20}
21
22async function readJson($: any, path: string): Promise<any> {
23 try {
24 return JSON.parse(await $.fs.read(path))
25 } catch {
26 return null
27 }
28}
29
30// The active file is written by the vault hook (keyed by session id); the ledger
31// is the teach skill's own record. Either missing means no lesson is running.
32async function load($: any): Promise<{ topic: string; ledger: any } | null> {
33 const root = await configRoot($)
34 if (!root) return null
35 const id = await $.session.id()
36 const active = await readJson($, `${root}/sensei/active/${id}.json`)
37 if (!active || typeof active.topic !== 'string') return null
38 const ledger = await readJson($, `${root}/sensei/ledgers/${active.topic}.json`)
39 return ledger ? { topic: active.topic, ledger } : null
40}
41
42export const register: Register = on => {
43 on('session.start', async ($, e, next) => {
44 await $.command.register({ name: 'map', description: 'Show your learning map' })
45
46 // `previous` only words toasts; losing it on a reload just skips one toast.
47 // `drawn` is what the view holds: any change to the ledger redraws.
48 let previous: ReturnType<typeof snapshot> = null
49 let drawn: string | undefined
50 let planning = false
51 const refresh = async () => {
52 const loaded = await load($)
53 const raw = JSON.stringify(loaded)
54 if (raw === drawn) return
55 drawn = raw
56 const current = loaded ? snapshot(loaded.topic, loaded.ledger) : null
57 const toast = changedStatuses(previous, current)
58 if (toast) $.ui.toast(toast)
59 previous = current
60 planning = bandLine(loaded?.ledger)?.animated === true
61 await update($, view, v => ({
62 topic: loaded?.topic ?? null,
63 ledger: loaded?.ledger ?? null,
64 selected: loaded && v.topic === loaded.topic ? v.selected : null,
65 }))
66 }
67 await refresh()
68 $.clock.every(1000, refresh)
69 $.clock.every(120, async () => {
70 if (planning) await update($, spin, n => (n + 1) % SPINNER.length)
71 })
72
73 return next(e)
74 })
75
76 on('command.run', { command: 'map' }, async $ => {
77 await $.ui.open({ id: PANE, title: 'Map' })
78 return { text: 'Opened your learning map.' }
79 })
80
81 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
82 const v = await read($, view)
83 const band = bandLine(v.ledger)
84 if (!band) return next(e)
85 const frame = band.animated ? SPINNER[await read($, spin)] : band.glyph
86 const { Text } = $.ui.resolve(e)
87 return (
88 <Text>
89 <Text color={band.tone}>{`${frame} ${band.head}`}</Text>
90 {band.detail && <Text dimColor>{` · ${band.detail}`}</Text>}
91 {band.last && <Text color="green" dimColor>{` · ✓ previously passed: ${band.last}`}</Text>}
92 <Text dimColor>{' · /map'}</Text>
93 </Text>
94 )
95 })
96
97 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
98 const { Box, Text, Button } = $.ui.resolve(e)
99 const v = await read($, view)
100 const ledger = v.ledger as any
101 const map = drawnPlan(ledger)
102 if (!map || map.nodes.length === 0) {
103 const goal = goalOf(ledger)
104 return <Text dimColor>{goal ? `No plan yet · ${goal}. Claude shows one for you to approve first.` : 'No plan yet. Ask Claude to teach you something and it shows a plan for you to approve first.'}</Text>
105 }
106 const isProposal = map === proposedPlan(ledger)
107 const { passed, total } = progress(ledger)
108 const rows = layers(map.nodes, map.edges)
109 const detail = v.selected ? nodeDetail(ledger, v.selected) : null
110
111 return (
112 <Box flexDirection="column" gap={1}>
113 <Box flexDirection="column">
114 {isProposal && <Text bold>Proposed plan — not approved yet</Text>}
115 <Text bold>{goalOf(ledger) ?? displayName(v.topic ?? '')}</Text>
116 <Text dimColor>{isProposal ? `${map.nodes.length} steps` : `${passed} of ${total} passed`}</Text>
117 </Box>
118 {rows.map((row, i) => (
119 <Box key={`row:${i}`} flexDirection="row" flexWrap="wrap" gap={1}>
120 {row.map(id => (
121 <Button
122 key={`node:${id}`}
123 label={chipLabel(ledger, id)}
124 variant={id === v.selected ? 'primary' : 'secondary'}
125 onPress={() => update($, view, s => ({ ...s, selected: id }))}
126 />
127 ))}
128 </Box>
129 ))}
130 {detail && (
131 <Box flexDirection="column">
132 <Text bold>{detail.name}</Text>
133 <Text>{`Status: ${detail.status}${detail.taught ? ` · taught ${detail.taught}` : ''}`}</Text>
134 {detail.prerequisites.length > 0 && <Text>{`Builds on: ${detail.prerequisites.join(', ')}`}</Text>}
135 {detail.dependents.length > 0 && <Text>{`Leads to: ${detail.dependents.join(', ')}`}</Text>}
136 </Box>
137 )}
138 </Box>
139 )
140 })
141}
142hooks/vault/graph.mjs 21 lines1// No imports: the Claude Code mod imports this file, and a mod has no Node.
2
3export function displayName(slug) {
4 return String(slug).replace(/-+/g, " ").trim();
5}
6
7// What the learner reads for a step: the plain-words label the plan carries. A plan is
8// only stored with a label for every step (validatePlan).
9export function labelOf(plan, id) {
10 const label = plan?.labels?.[id];
11 return typeof label === "string" ? label : "";
12}
13
14// Edges are [prerequisite, dependent] pairs.
15export function prerequisitesOf(plan, node) {
16 const edges = Array.isArray(plan?.edges) ? plan.edges : [];
17 return edges
18 .filter((edge) => Array.isArray(edge) && edge[1] === node && typeof edge[0] === "string")
19 .map((edge) => edge[0]);
20}
21hooks/mod/map-layout.mjs 163 lines1// Imported by the Claude Code mod, which has no Node: import only other plain
2// plugin files, never a `node:` module.
3import { labelOf, prerequisitesOf } from "../vault/graph.mjs";
4
5// The ledger is a file the learner may have edited: a plan counts only when its nodes
6// are an array, and a goal only when it is text.
7const wellFormed = (candidate) => (Array.isArray(candidate?.nodes) ? candidate : null);
8const plan = (ledger) => wellFormed(ledger?.session?.dependencyMap);
9
10// A plan the learner has been shown but has not approved yet (set before approval,
11// removed by it).
12export function proposedPlan(ledger) {
13 return wellFormed(ledger?.session?.proposedPlan);
14}
15
16export function goalOf(ledger) {
17 const goal = ledger?.session?.goal;
18 return typeof goal === "string" && goal !== "" ? goal : null;
19}
20
21export const drawnPlan = (ledger) => plan(ledger) ?? proposedPlan(ledger);
22const statusOf = (ledger, id) => ledger?.nodes?.[id]?.status ?? null;
23
24// Row = longest prerequisite chain above the node. A cycle never crashes: the
25// nodes that cannot be placed go together in the last row.
26export function layers(nodes, edges) {
27 const ids = [...new Set(Array.isArray(nodes) ? nodes : [])];
28 const known = new Set(ids);
29 const preds = new Map(ids.map((id) => [id, []]));
30 for (const edge of Array.isArray(edges) ? edges : []) {
31 if (!Array.isArray(edge)) continue;
32 const [from, to] = edge;
33 if (from !== to && known.has(from) && known.has(to) && !preds.get(to).includes(from)) preds.get(to).push(from);
34 }
35 const rows = [];
36 const placed = new Set();
37 let remaining = ids;
38 while (remaining.length > 0) {
39 let ready = remaining.filter((id) => preds.get(id).every((p) => placed.has(p)));
40 if (ready.length === 0) ready = remaining;
41 rows.push(ready);
42 for (const id of ready) placed.add(id);
43 remaining = remaining.filter((id) => !placed.has(id));
44 }
45 return rows;
46}
47
48export function progress(ledger) {
49 const nodes = plan(ledger)?.nodes ?? [];
50 return { passed: nodes.filter((id) => statusOf(ledger, id) === "passed").length, total: nodes.length };
51}
52
53// The ledger records only passed and failed; the node being taught has neither yet.
54function stateOf(ledger, id) {
55 const status = statusOf(ledger, id);
56 if (status === "passed" || status === "failed") return status;
57 return ledger?.session?.nextNodeId === id ? "in progress" : "not started";
58}
59
60const GLYPHS = { passed: "✓", failed: "✗", "in progress": "●", "not started": "○" };
61
62export function glyph(ledger, id) {
63 return GLYPHS[stateOf(ledger, id)];
64}
65
66export function chipLabel(ledger, id) {
67 return `${glyph(ledger, id)} ${labelOf(drawnPlan(ledger), id)}`;
68}
69
70// The step passed most recently. A batched check passes several at one instant, so a
71// tie goes to the later step in the plan.
72function lastPassed(ledger) {
73 let best = null;
74 let bestAt = "";
75 for (const id of plan(ledger)?.nodes ?? []) {
76 if (statusOf(ledger, id) !== "passed") continue;
77 const at = ledger.nodes[id].lastTaughtAt ?? "";
78 if (best === null || at >= bestAt) [best, bestAt] = [id, at];
79 }
80 return best === null ? null : labelOf(plan(ledger), best);
81}
82
83const line = (glyph, head, tone, { detail = null, last = null, animated = false } = {}) => ({ glyph, head, tone, detail, last, animated });
84
85// What the band above the prompt says, from the ledger alone, so it never shows a stale result.
86// `head` is the state, drawn in `tone` after its glyph; `detail` (the goal) and `last` are drawn
87// plain. Glyph and colour follow the plan: ◌ planning (animated, plain), ◇ proposed (yellow, waiting
88// for the learner), ● in progress (cyan), ✗ retry (red), ✓ done (green). An approved plan is in
89// progress at once: teaching starts when it is approved, long before the first check is recorded.
90export function bandLine(ledger) {
91 const session = ledger?.session;
92 if (!session) return null;
93 const { passed, total } = progress(ledger);
94 const proposed = proposedPlan(ledger);
95 if (session.status === "intake" && proposed) return line("◇", `Plan proposed · ${proposed.nodes.length} steps`, "yellow", { detail: goalOf(ledger) });
96 if (session.status === "intake") return goalOf(ledger) ? line("◌", "Planning", undefined, { detail: goalOf(ledger), animated: true }) : null;
97 if (session.status === "complete") return line("✓", `All ${total} steps passed`, "green");
98 if ((session.status === "planned" || session.status === "in-progress") && session.nextNodeId) {
99 const retry = statusOf(ledger, session.nextNodeId) === "failed";
100 const head = `Step ${passed + 1} of ${total} · ${retry ? "Retry" : "In progress"}: ${labelOf(plan(ledger), session.nextNodeId)}`;
101 return line(retry ? "✗" : "●", head, retry ? "red" : "cyan", { last: lastPassed(ledger) });
102 }
103 return null;
104}
105
106export function snapshot(topic, ledger) {
107 if (!ledger) return null;
108 const statuses = {};
109 const taught = {};
110 const rounds = {};
111 const names = {};
112 for (const [id, node] of Object.entries(ledger.nodes ?? {})) {
113 statuses[id] = node?.status ?? "";
114 taught[id] = node?.lastTaughtAt ?? "";
115 rounds[id] = roundOf(node);
116 names[id] = labelOf(drawnPlan(ledger), id);
117 }
118 return { topic, statuses, taught, rounds, names, next: ledger.session?.nextNodeId ?? null, sessionStatus: ledger.session?.status ?? null };
119}
120
121// The questions a step's last call held; a step recorded before the counts existed is one.
122function roundOf(node) {
123 const { right, wrong } = node?.lastRound ?? {};
124 if (Number.isInteger(right) && Number.isInteger(wrong) && right + wrong > 0) return { right, wrong };
125 return node?.status === "passed" ? { right: 1, wrong: 0 } : { right: 0, wrong: 1 };
126}
127
128// One line for every result recorded since the last poll (a new status, or the same status
129// stamped again by a later check), or null. Counts questions, not steps. The toast display
130// shows one toast at a time, so a batched check (a pass and a miss together) is one line.
131export function changedStatuses(previous, current) {
132 if (!previous || !current || previous.topic !== current.topic) return null;
133 let right = 0;
134 let wrong = 0;
135 let step = null;
136 for (const [id, status] of Object.entries(current.statuses)) {
137 if (previous.statuses[id] === status && previous.taught[id] === current.taught[id]) continue;
138 if (status !== "passed" && status !== "failed") continue;
139 right += current.rounds[id].right;
140 wrong += current.rounds[id].wrong;
141 step = current.names[id];
142 }
143 if (right + wrong === 0) return null;
144 if (right + wrong === 1) return right === 1 ? `✓ ${step}` : `✗ ${step} — retry`;
145 const parts = [];
146 if (right > 0) parts.push(`✓ ${right} correct`);
147 if (wrong > 0) parts.push(`✗ ${wrong} to retry`);
148 return parts.join(" · ");
149}
150
151export function nodeDetail(ledger, id) {
152 const shown = drawnPlan(ledger);
153 const edges = Array.isArray(shown?.edges) ? shown.edges : [];
154 const taught = ledger?.nodes?.[id]?.lastTaughtAt;
155 return {
156 name: labelOf(shown, id),
157 status: plan(ledger) ? stateOf(ledger, id) : "proposed",
158 prerequisites: prerequisitesOf(shown, id).map((p) => labelOf(shown, p)),
159 dependents: edges.filter((e) => Array.isArray(e) && e[0] === id).map((e) => labelOf(shown, e[1])),
160 taught: typeof taught === "string" ? taught.slice(0, 10) : null,
161 };
162}
163types/index.d.ts 12 lines1export type MapView = {
2 topic: string | null
3 ledger: unknown | null
4 selected: string | null
5}
6
7declare module 'claude-code' {
8 interface PluginState {
9 sensei: { view: MapView; spin: number }
10 }
11}
12