eklavya 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +69 -0
  2. package/dist/ask.js +58 -0
  3. package/dist/ask.js.map +1 -0
  4. package/dist/assets/dashboard.html +407 -0
  5. package/dist/assets/tokens.css +135 -0
  6. package/dist/assets/tutor-skill.md +345 -0
  7. package/dist/cli.js +223 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/concurrency.js +41 -0
  10. package/dist/concurrency.js.map +1 -0
  11. package/dist/config.js +158 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/dashboard.js +214 -0
  14. package/dist/dashboard.js.map +1 -0
  15. package/dist/db.js +26 -0
  16. package/dist/db.js.map +1 -0
  17. package/dist/hooks/checkpoint-quiz.js +152 -0
  18. package/dist/hooks/checkpoint-quiz.js.map +1 -0
  19. package/dist/hooks/lib.js +177 -0
  20. package/dist/hooks/lib.js.map +1 -0
  21. package/dist/hooks/pre-tool-gate.js +53 -0
  22. package/dist/hooks/pre-tool-gate.js.map +1 -0
  23. package/dist/hooks/session-start.js +128 -0
  24. package/dist/hooks/session-start.js.map +1 -0
  25. package/dist/hooks/stop-quiz-check.js +147 -0
  26. package/dist/hooks/stop-quiz-check.js.map +1 -0
  27. package/dist/install.js +313 -0
  28. package/dist/install.js.map +1 -0
  29. package/dist/mcq.js +34 -0
  30. package/dist/mcq.js.map +1 -0
  31. package/dist/migrate.js +45 -0
  32. package/dist/migrate.js.map +1 -0
  33. package/dist/migrations/001_init.sql +71 -0
  34. package/dist/migrations/002_stop_markers.sql +14 -0
  35. package/dist/migrations/003_gate_repo.sql +5 -0
  36. package/dist/migrations/004_attempt_outcome.sql +15 -0
  37. package/dist/migrations/005_session_concept_origin.sql +24 -0
  38. package/dist/migrations/006_attempt_format.sql +31 -0
  39. package/dist/migrations/007_checkpoints.sql +23 -0
  40. package/dist/migrations/008_difficulty_levels.sql +57 -0
  41. package/dist/paths.js +25 -0
  42. package/dist/paths.js.map +1 -0
  43. package/dist/plugin/.claude-plugin/marketplace.json +14 -0
  44. package/dist/plugin/.claude-plugin/plugin.json +17 -0
  45. package/dist/plugin/.mcp.json +11 -0
  46. package/dist/plugin/agents/tutor.md +63 -0
  47. package/dist/plugin/cli/eklavya-gate +67 -0
  48. package/dist/plugin/hooks/hooks.json +57 -0
  49. package/dist/plugin/hooks/run.mjs +169 -0
  50. package/dist/plugin/scripts/install-git-hook.sh +81 -0
  51. package/dist/plugin/skills/gate/SKILL.md +17 -0
  52. package/dist/plugin/skills/learn/SKILL.md +28 -0
  53. package/dist/plugin/skills/level/SKILL.md +53 -0
  54. package/dist/plugin/skills/mode/SKILL.md +56 -0
  55. package/dist/plugin/skills/progress/SKILL.md +72 -0
  56. package/dist/plugin/skills/quiz/SKILL.md +31 -0
  57. package/dist/plugin/skills/setup/SKILL.md +49 -0
  58. package/dist/plugin/skills/tutor/SKILL.md +345 -0
  59. package/dist/seed/git.json +49 -0
  60. package/dist/seed/node-backend.json +43 -0
  61. package/dist/seed/react.json +46 -0
  62. package/dist/seed/web-auth.json +78 -0
  63. package/dist/seed.js +112 -0
  64. package/dist/seed.js.map +1 -0
  65. package/dist/server.js +52 -0
  66. package/dist/server.js.map +1 -0
  67. package/dist/session.js +24 -0
  68. package/dist/session.js.map +1 -0
  69. package/dist/slug.js +75 -0
  70. package/dist/slug.js.map +1 -0
  71. package/dist/srs.js +206 -0
  72. package/dist/srs.js.map +1 -0
  73. package/dist/store.js +450 -0
  74. package/dist/store.js.map +1 -0
  75. package/dist/tools/config_tools.js +119 -0
  76. package/dist/tools/config_tools.js.map +1 -0
  77. package/dist/tools/get_concept_graph.js +104 -0
  78. package/dist/tools/get_concept_graph.js.map +1 -0
  79. package/dist/tools/get_gate_status.js +20 -0
  80. package/dist/tools/get_gate_status.js.map +1 -0
  81. package/dist/tools/get_learner_profile.js +142 -0
  82. package/dist/tools/get_learner_profile.js.map +1 -0
  83. package/dist/tools/get_session_quiz_plan.js +393 -0
  84. package/dist/tools/get_session_quiz_plan.js.map +1 -0
  85. package/dist/tools/index.js +45 -0
  86. package/dist/tools/index.js.map +1 -0
  87. package/dist/tools/log_session_concepts.js +103 -0
  88. package/dist/tools/log_session_concepts.js.map +1 -0
  89. package/dist/tools/record_attempt.js +158 -0
  90. package/dist/tools/record_attempt.js.map +1 -0
  91. package/dist/tools/types.js +4 -0
  92. package/dist/tools/types.js.map +1 -0
  93. package/dist/tools/upsert_concepts.js +89 -0
  94. package/dist/tools/upsert_concepts.js.map +1 -0
  95. package/package.json +66 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * SessionStart: stamp the session id and print a one-line learner profile.
3
+ *
4
+ * Hard rule (PRD §9.1): this must never break a session. Every failure path
5
+ * exits 0 with no output — `run()` enforces it.
6
+ */
7
+ import { levelStanding } from '../store.js';
8
+ import { run, openExisting, config, cwdOf, sessionId } from './lib.js';
9
+ /**
10
+ * The whole tutoring loop starts at log_session_concepts: it is the only writer
11
+ * of session_concepts, and the Stop hook quizzes from nothing else. An unlogged
12
+ * session is a silent no-op no matter how much was taught in it, and the Stop
13
+ * hook exits 0 without a word, so the failure looks like "Eklavya is broken".
14
+ *
15
+ * The tutor skill already says to log, but it is model-invoked — it competes
16
+ * with every other skill on the machine and may never load. So the instruction
17
+ * is stated here, where every session sees it before any skill is chosen.
18
+ */
19
+ const DIRECTIVE = `[Eklavya] Standing instruction for this session, on every task — not only when asked to teach:
20
+ - Once you know what a task involves, call log_session_concepts with the 3-8 concepts it
21
+ genuinely exercises. Omit session_id. Log while implementing, not at the end.
22
+ - Log what the code is *about* (jwt-verification, react-effect-cleanup, sql-index-selectivity),
23
+ not what you did. Give each one a context line naming the real code you wrote.
24
+ - Guessed slugs are fine: unknown ones are fuzzy-matched or created.
25
+ - Skip this and there is nothing to quiz on, so nothing is ever learned. Load the tutor skill
26
+ for how to ask and grade.
27
+ - Logging may come straight back with an [Eklavya checkpoint]: ONE multiple-choice question to
28
+ ask right then, before the next line of code, then back to the task in the same turn. That
29
+ interruption is the product -- learning while the work happens, not a pile of questions after
30
+ it. One question, no summary, no re-plan, no second question.`;
31
+ /**
32
+ * "seen" is a mastery row, which only exists once a concept has been attempted.
33
+ * Gated on that rather than on anything being mastered yet, or a learner three
34
+ * attempts in is told they have no history.
35
+ */
36
+ function domainSummary(db) {
37
+ const rows = db
38
+ .prepare(`SELECT c.domain AS domain,
39
+ sum(CASE WHEN m.score >= 0.7 AND m.reps >= 2 THEN 1 ELSE 0 END) AS known,
40
+ count(*) AS total,
41
+ sum(CASE WHEN m.concept_id IS NOT NULL THEN 1 ELSE 0 END) AS seen
42
+ FROM concepts c LEFT JOIN mastery m ON m.concept_id = c.id
43
+ GROUP BY c.domain
44
+ HAVING seen > 0
45
+ ORDER BY known DESC, seen DESC
46
+ LIMIT 3`)
47
+ .all();
48
+ return rows.map((r) => `${r.domain} ${r.known}/${r.total} known`).join(', ');
49
+ }
50
+ /**
51
+ * Not "reps > 0": a failing grade resets reps to 0, so that filter would hide
52
+ * precisely the concepts the learner is struggling with. A mastery row at all
53
+ * means it has been attempted.
54
+ */
55
+ function weakest(db) {
56
+ const rows = db
57
+ .prepare(`SELECT c.slug AS slug FROM concepts c
58
+ JOIN mastery m ON m.concept_id = c.id
59
+ WHERE m.score < 0.5
60
+ ORDER BY m.score ASC
61
+ LIMIT 3`)
62
+ .all();
63
+ return rows.map((r) => r.slug).join(', ');
64
+ }
65
+ function dueCount(db) {
66
+ const row = db
67
+ .prepare(`SELECT count(*) AS n FROM mastery
68
+ WHERE next_review IS NOT NULL
69
+ AND next_review <= strftime('%Y-%m-%dT%H:%M:%fZ','now')`)
70
+ .get();
71
+ return row?.n ?? 0;
72
+ }
73
+ await run(async (input) => {
74
+ const db = openExisting();
75
+ if (!db)
76
+ return 0;
77
+ const cwd = cwdOf(input);
78
+ const sid = sessionId(input, db);
79
+ // Stamp the session so MCP tools resolve the same id the hooks will use later
80
+ // (decision G1). This happens even when the banner is suppressed.
81
+ if (sid) {
82
+ db.prepare(`INSERT INTO meta (key, value) VALUES ('current_session', ?)
83
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(sid);
84
+ }
85
+ const resolved = config(cwd);
86
+ const { mode, focus, focus_topic, cadence, difficulty, quiet } = resolved.config;
87
+ if (mode === 'off')
88
+ return 0;
89
+ const focusLabel = focus === 'learn' && focus_topic ? `learn (${focus_topic})` : focus;
90
+ // The level, and how far into it they are. This is the one number that makes
91
+ // the runway visible: without it, week one and week ten look identical from
92
+ // the outside, and a learner on `easy` reads tier-2 questions as Eklavya being
93
+ // shallow rather than as a band they are working through.
94
+ //
95
+ // `levelStanding` is the server's own function, so the banner cannot drift
96
+ // from what the quiz planner actually does — the shell version reimplemented
97
+ // this query and had to keep it in step by hand.
98
+ let levelLabel = `${difficulty} (pinned)`;
99
+ if (difficulty === 'auto') {
100
+ const standing = levelStanding(db, resolved.config, resolved.repoRoot);
101
+ levelLabel = `${standing.level} (${standing.counts.passed}/${standing.needed.answers} on this project)`;
102
+ }
103
+ if (quiet)
104
+ return 0;
105
+ const domains = domainSummary(db);
106
+ const out = [];
107
+ if (!domains) {
108
+ // Nothing learned yet — say the useful thing instead of an empty scoreboard.
109
+ out.push(`[Eklavya] No learning history yet. Mode: ${mode}. Focus: ${focusLabel}. Cadence: ${cadence}. Level: ${levelLabel}.`);
110
+ }
111
+ else {
112
+ let line = `[Eklavya] Learner profile: ${domains}.`;
113
+ const weak = weakest(db);
114
+ if (weak)
115
+ line += ` Weak: ${weak}.`;
116
+ const due = dueCount(db);
117
+ if (due > 0)
118
+ line += ` ${due} concept(s) due for review.`;
119
+ out.push(`${line} Mode: ${mode}. Focus: ${focusLabel}. Cadence: ${cadence}. Level: ${levelLabel}.`);
120
+ }
121
+ if (resolved.overrides.length > 0) {
122
+ out.push(`[Eklavya] This repo overrides your global setting for: ${resolved.overrides.join(' ')} (.eklavya.json wins).`);
123
+ }
124
+ out.push(DIRECTIVE);
125
+ process.stdout.write(`${out.join('\n')}\n`);
126
+ return 0;
127
+ });
128
+ //# sourceMappingURL=session-start.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-start.js","sourceRoot":"","sources":["../../src/hooks/session-start.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Stop: turn a finished task into a quiz, at most once per batch of work.
3
+ *
4
+ * Blocking is exit 2 with the reason on stderr (deviation D1) — the current hooks
5
+ * reference documents no Stop-specific JSON envelope, but does document that
6
+ * exit 2 prevents stopping and uses stderr as the message.
7
+ *
8
+ * THE LOOP GUARD IS THE POINT OF THIS FILE.
9
+ * `stop_hook_active` is no longer a documented input (deviation D2), so nothing
10
+ * here may depend on the harness noticing we already blocked. The rule that keeps
11
+ * this finite:
12
+ *
13
+ * block only when the number of concepts logged for this session has GROWN
14
+ * since the last block.
15
+ *
16
+ * Blocking stamps the current count. A Stop that follows with the same count does
17
+ * not block, whatever happened in between — quiz answered, quiz skipped, model
18
+ * ignored us entirely. Only genuinely new logged work re-arms it, and
19
+ * `block_count` caps even that.
20
+ */
21
+ import { run, openExisting, config, cwdOf, sessionId, minutesSince, framingFor } from './lib.js';
22
+ await run(async (input) => {
23
+ const db = openExisting();
24
+ if (!db)
25
+ return 0;
26
+ // Belt and braces: honor it if the harness still sends it, depend on it never.
27
+ if (input.stop_hook_active === true)
28
+ return 0;
29
+ const cwd = cwdOf(input);
30
+ const sid = sessionId(input, db);
31
+ if (!sid)
32
+ return 0;
33
+ const { mode, focus, focus_topic, max_questions_per_task, max_stop_blocks_per_session, min_minutes_between_quizzes, } = config(cwd).config;
34
+ if (mode === 'off')
35
+ return 0;
36
+ const stats = db
37
+ .prepare(`SELECT
38
+ (SELECT count(*) FROM session_concepts sc
39
+ JOIN concepts c ON c.id = sc.concept_id
40
+ LEFT JOIN mastery m ON m.concept_id = c.id
41
+ WHERE sc.session_id = @sid
42
+ AND COALESCE(sc.origin,'work') = 'work'
43
+ AND NOT (COALESCE(m.score,0) >= 0.7 AND COALESCE(m.reps,0) >= 2)
44
+ AND sc.concept_id NOT IN
45
+ (SELECT concept_id FROM attempts WHERE session_id = @sid)) AS unmastered,
46
+ -- 'work' only, and the loop guard depends on it: record_attempt inserts a
47
+ -- session_concepts row for anything it is quizzed on, so counting review
48
+ -- rows here would let answering a question re-arm the block that asked it.
49
+ (SELECT count(*) FROM session_concepts
50
+ WHERE session_id = @sid AND COALESCE(origin,'work') = 'work') AS logged,
51
+ COALESCE((SELECT last_logged_count FROM stop_markers WHERE session_id = @sid), -1) AS last_logged,
52
+ COALESCE((SELECT block_count FROM stop_markers WHERE session_id = @sid), 0) AS blocks,
53
+ (SELECT last_blocked_at FROM stop_markers WHERE session_id = @sid) AS last_blocked,
54
+ (SELECT ts FROM attempts WHERE session_id = @sid ORDER BY id DESC LIMIT 1) AS last_answer,
55
+ -- Budget already spent, wherever it was spent. Since 1.4 the mid-work
56
+ -- checkpoint hook asks from the same allowance, so this is what turns the
57
+ -- Stop quiz from a fixed batch of four into a sweep of whatever is left.
58
+ (SELECT count(*) FROM attempts WHERE session_id = @sid) AS spent`)
59
+ .get({ sid });
60
+ if (!stats)
61
+ return 0;
62
+ // Nothing worth asking about. Concepts already answered in this session are
63
+ // excluded above: they have had their turn, and re-offering them is exactly the
64
+ // "asked me the same thing twice" failure this tool exists to avoid.
65
+ if (stats.unmastered <= 0)
66
+ return 0;
67
+ // --- the loop guard --------------------------------------------------------
68
+ if (!(stats.logged > stats.last_logged))
69
+ return 0;
70
+ if (stats.blocks >= max_stop_blocks_per_session)
71
+ return 0;
72
+ // Ambient mode respects the quiz cadence. Enforced mode must not, or a cooldown
73
+ // could make a commit gate unpassable (decision G5).
74
+ //
75
+ // Both clocks matter, and for the same reason. get_session_quiz_plan applies its
76
+ // cooldown from the last *answer*; this hook stamps the last *block*. Checking
77
+ // only the block would let us block a turn that the quiz plan then refuses as
78
+ // too soon, which reads to the model as being told to teach and given nothing to
79
+ // teach. Whichever happened more recently wins.
80
+ if (mode === 'ambient') {
81
+ if (minutesSince(stats.last_blocked) < min_minutes_between_quizzes)
82
+ return 0;
83
+ if (minutesSince(stats.last_answer) < min_minutes_between_quizzes)
84
+ return 0;
85
+ }
86
+ // ---------------------------------------------------------------------------
87
+ // --- what is left of the session budget ------------------------------------
88
+ // `max_questions_per_task` is a SESSION allowance, not an end-of-session batch
89
+ // size. Every question answered while the agent was working is one this hook
90
+ // must not ask again -- that is the whole trade the interleaved cadence makes,
91
+ // and without this subtraction it would be a lie: four questions mid-task and
92
+ // then four more at the end is worse than what it replaced.
93
+ //
94
+ // Answer the whole budget during the work and the Stop hook says nothing at all.
95
+ const remaining = max_questions_per_task - stats.spent;
96
+ if (remaining <= 0)
97
+ return 0;
98
+ // ---------------------------------------------------------------------------
99
+ const rows = db
100
+ .prepare(`SELECT c.slug || COALESCE(' (' || sc.context || ')', '') AS line
101
+ FROM session_concepts sc
102
+ JOIN concepts c ON c.id = sc.concept_id
103
+ LEFT JOIN mastery m ON m.concept_id = c.id
104
+ WHERE sc.session_id = @sid
105
+ AND COALESCE(sc.origin,'work') = 'work'
106
+ AND NOT (COALESCE(m.score,0) >= 0.7 AND COALESCE(m.reps,0) >= 2)
107
+ AND sc.concept_id NOT IN
108
+ (SELECT concept_id FROM attempts WHERE session_id = @sid)
109
+ ORDER BY sc.ts ASC
110
+ LIMIT @remaining`)
111
+ .all({ sid, remaining });
112
+ const concepts = rows.map((r) => r.line).join('; ');
113
+ // Stamp the guard BEFORE blocking. If anything below fails, the worst case is a
114
+ // missed quiz — never a loop.
115
+ db.prepare(`INSERT INTO stop_markers (session_id, last_blocked_at, last_logged_count, block_count)
116
+ VALUES (@sid, strftime('%Y-%m-%dT%H:%M:%fZ','now'), @logged, 1)
117
+ ON CONFLICT(session_id) DO UPDATE SET
118
+ last_blocked_at = excluded.last_blocked_at,
119
+ last_logged_count = excluded.last_logged_count,
120
+ block_count = stop_markers.block_count + 1`).run({ sid, logged: stats.logged });
121
+ const framing = framingFor(focus, focus_topic, 'stop');
122
+ const tone = mode === 'enforced'
123
+ ? 'This session is in enforced mode: the commit gate needs this quiz.'
124
+ : 'If they say skip, record it as grade 0 and let them go — do not ask twice.';
125
+ process.stderr.write(`Eklavya: before finishing, quiz the developer on what this task just taught.
126
+
127
+ Concepts: ${concepts}
128
+
129
+ Use the eklavya MCP server and the tutor skill: call get_session_quiz_plan, ask
130
+ ONE question at a time at each concept's tier_to_ask, and grade each answer with
131
+ record_attempt. The plan returns a "framing" field. Follow it.
132
+
133
+ Ask each question as MULTIPLE CHOICE via the AskUserQuestion tool: four options,
134
+ one correct and three plausible, header "Eklavya" so it is clear who is asking.
135
+ The tool's own "Other" choice is the escape hatch for "I don't know". They are
136
+ mid-task -- a blank prompt gets skipped whether or not they knew the answer.
137
+ End each question's text with a blank line and then that plan item's ask_footer,
138
+ verbatim: the mode, focus, level, tier and how many questions are coming. It is
139
+ the only thing on screen saying why a question is pitched where it is.
140
+ Record with format "mcq", the labels in "options", and only the stem in
141
+ "question" -- never the footer.
142
+ ${framing}
143
+ ${tone}
144
+ `);
145
+ return 2;
146
+ });
147
+ //# sourceMappingURL=stop-quiz-check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stop-quiz-check.js","sourceRoot":"","sources":["../../src/hooks/stop-quiz-check.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,313 @@
1
+ /**
2
+ * `eklavya install` — the whole setup, from a terminal, on macOS or Windows.
3
+ *
4
+ * Claude Code's own route is `/plugin marketplace add` + `/plugin install`, and
5
+ * that still works. This is the other door: it needs no Claude Code session, and
6
+ * it is the one that can install what Eklavya depends on, because a slash
7
+ * command cannot run npm.
8
+ *
9
+ * What it has to get right, in order:
10
+ *
11
+ * 1. Node 22+. Below that, better-sqlite3 has no prebuilt binary and npm falls
12
+ * through to node-gyp, which needs a C++ toolchain nobody has on Windows.
13
+ * This is a hard refusal with per-platform instructions, not a warning:
14
+ * continuing produces a half-install that fails later and mystifies.
15
+ * 2. The runtime. `npm install eklavya@<version> --prefix ~/.eklavya/runtime`
16
+ * puts the compiled server, the hooks and that one native dependency
17
+ * somewhere both install routes can find them.
18
+ * 3. The plugin payload, copied into the Claude Code marketplace directory.
19
+ * 4. Claude Code's three registry files, written directly. There is no public
20
+ * API for "install this plugin" from outside a session, so this reproduces
21
+ * what `/plugin install` does. See the comment on `register()`.
22
+ * 5. The database, created and seeded.
23
+ *
24
+ * Every step is idempotent: running it twice is how you upgrade.
25
+ */
26
+ import fs from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+ import { spawnSync } from 'node:child_process';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { openDb } from './db.js';
32
+ import { dbPath, eklavyaHome } from './paths.js';
33
+ const MIN_NODE_MAJOR = 22;
34
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
35
+ /** The npm package root — `dist/` under a build, so one level up. */
36
+ function packageRoot() {
37
+ return path.dirname(moduleDir);
38
+ }
39
+ /** The plugin payload the build copies into the package (see copy-assets.mjs). */
40
+ function payloadDir() {
41
+ return path.join(moduleDir, 'plugin');
42
+ }
43
+ function packageVersion() {
44
+ const manifest = path.join(packageRoot(), 'package.json');
45
+ return JSON.parse(fs.readFileSync(manifest, 'utf8')).version;
46
+ }
47
+ /**
48
+ * Claude Code's config root. `CLAUDE_CONFIG_DIR` is the documented override and
49
+ * people with several profiles do set it — writing to `~/.claude` regardless
50
+ * would install into a directory their Claude Code never reads.
51
+ */
52
+ export function claudeHome() {
53
+ return process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), '.claude');
54
+ }
55
+ export function marketplaceDir() {
56
+ return path.join(claudeHome(), 'plugins', 'marketplaces', 'eklavya');
57
+ }
58
+ export function runtimeHome() {
59
+ return process.env.EKLAVYA_RUNTIME ?? path.join(eklavyaHome(), 'runtime');
60
+ }
61
+ const say = (line) => process.stdout.write(`${line}\n`);
62
+ // --- 1. prerequisites -------------------------------------------------------
63
+ /** How to get a modern Node, on the platform actually in front of them. */
64
+ function nodeAdvice() {
65
+ if (process.platform === 'win32') {
66
+ return [
67
+ ' winget install OpenJS.NodeJS.LTS',
68
+ ' …or download the installer from https://nodejs.org',
69
+ ].join('\n');
70
+ }
71
+ if (process.platform === 'darwin') {
72
+ return [' brew install node', ' …or download the installer from https://nodejs.org'].join('\n');
73
+ }
74
+ return [
75
+ ' Use your distribution package, nvm, or https://nodejs.org',
76
+ ' e.g. nvm install 22 && nvm use 22',
77
+ ].join('\n');
78
+ }
79
+ function checkNode() {
80
+ const major = Number(process.versions.node.split('.')[0]);
81
+ if (major >= MIN_NODE_MAJOR)
82
+ return;
83
+ process.stderr.write(`Eklavya needs Node ${MIN_NODE_MAJOR} or newer — this is Node ${process.versions.node}.\n\n` +
84
+ 'Below Node 22, the SQLite driver has no prebuilt binary for your platform and npm\n' +
85
+ 'tries to compile it, which needs a full C++ toolchain. Upgrading Node is the fix:\n\n' +
86
+ `${nodeAdvice()}\n\nThen run \`npx eklavya install\` again.\n`);
87
+ process.exit(1);
88
+ }
89
+ /**
90
+ * git is optional, and saying so matters. Eklavya keys a project's difficulty
91
+ * level on the repository root, and the commit gate is a git hook — without git
92
+ * both degrade to the shared bucket rather than failing.
93
+ */
94
+ function checkGit() {
95
+ const probe = spawnSync('git', ['--version'], { stdio: 'ignore', shell: process.platform === 'win32' });
96
+ return probe.status === 0;
97
+ }
98
+ // --- 2. the runtime ---------------------------------------------------------
99
+ function installRuntime(version) {
100
+ const home = runtimeHome();
101
+ fs.mkdirSync(home, { recursive: true });
102
+ // A stamp file so the plugin's background self-heal does not race a manual
103
+ // install. Removed in the finally, whatever happens.
104
+ const stamp = path.join(home, '.installing');
105
+ fs.writeFileSync(stamp, String(process.pid), 'utf8');
106
+ try {
107
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
108
+ const result = spawnSync(npm, [
109
+ 'install',
110
+ `eklavya@${version}`,
111
+ '--prefix',
112
+ home,
113
+ '--omit=dev',
114
+ '--no-audit',
115
+ '--no-fund',
116
+ '--loglevel=error',
117
+ ], { stdio: ['ignore', 'inherit', 'inherit'], shell: process.platform === 'win32' });
118
+ if (result.status !== 0) {
119
+ process.stderr.write('\nInstalling the Eklavya runtime failed. The output above says why — the usual\n' +
120
+ 'causes are no network, or a registry proxy that blocks the download.\n' +
121
+ `You can retry with: npm install eklavya@${version} --prefix ${home}\n`);
122
+ process.exit(1);
123
+ }
124
+ }
125
+ finally {
126
+ try {
127
+ fs.rmSync(stamp, { force: true });
128
+ }
129
+ catch {
130
+ /* nothing depends on the stamp being gone */
131
+ }
132
+ }
133
+ }
134
+ /**
135
+ * Proves the native dependency actually loads, rather than trusting that npm
136
+ * exiting 0 means a working binary. A prebuilt binary for the wrong ABI installs
137
+ * cleanly and throws on first require — which would otherwise surface as a dead
138
+ * MCP server three commands later.
139
+ */
140
+ function verifyRuntime() {
141
+ const entry = path.join(runtimeHome(), 'node_modules', 'eklavya', 'dist', 'server.js');
142
+ if (!fs.existsSync(entry)) {
143
+ process.stderr.write(`The runtime installed but ${entry} is missing.\n`);
144
+ process.exit(1);
145
+ }
146
+ const probe = spawnSync(process.execPath, ['-e', 'require(process.argv[1]); console.log("ok")', path.join(runtimeHome(), 'node_modules', 'better-sqlite3')], { encoding: 'utf8' });
147
+ if (probe.status !== 0) {
148
+ process.stderr.write('\nThe SQLite driver installed but will not load:\n' +
149
+ `${(probe.stderr ?? '').trim()}\n\n` +
150
+ 'This usually means the prebuilt binary does not match this Node version.\n' +
151
+ `Try removing ${runtimeHome()} and running \`npx eklavya install\` again.\n`);
152
+ process.exit(1);
153
+ }
154
+ }
155
+ // --- 3. the plugin payload --------------------------------------------------
156
+ function copyPayload() {
157
+ const from = payloadDir();
158
+ if (!fs.existsSync(from)) {
159
+ process.stderr.write(`The plugin payload is missing from this package (expected ${from}).\n` +
160
+ 'This is a packaging bug — please report it.\n');
161
+ process.exit(1);
162
+ }
163
+ const to = marketplaceDir();
164
+ // Replace rather than merge: a stale hook or skill left behind by an older
165
+ // version is worse than a slow copy, and this directory is ours entirely.
166
+ fs.rmSync(to, { recursive: true, force: true });
167
+ fs.mkdirSync(path.dirname(to), { recursive: true });
168
+ fs.cpSync(from, to, { recursive: true });
169
+ }
170
+ // --- 4. Claude Code's registries --------------------------------------------
171
+ function readJson(file) {
172
+ try {
173
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
174
+ }
175
+ catch {
176
+ return {};
177
+ }
178
+ }
179
+ /** Write via temp file + rename: Claude Code may be reading this mid-write. */
180
+ function writeJson(file, data) {
181
+ fs.mkdirSync(path.dirname(file), { recursive: true });
182
+ const tmp = `${file}.tmp-${process.pid}`;
183
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
184
+ fs.renameSync(tmp, file);
185
+ }
186
+ /**
187
+ * Registers the plugin the way `/plugin install` would.
188
+ *
189
+ * There is no supported CLI for this, so these three files are written directly.
190
+ * That is a real cost and worth naming: their shape is Claude Code's private
191
+ * business and can change — `installed_plugins.json` already carries a
192
+ * `"version": 2`. The mitigation is that every write is additive and keyed, so a
193
+ * shape change loses the registration rather than corrupting anyone's config,
194
+ * and `eklavya doctor` reports it.
195
+ *
196
+ * The marketplace is registered with its GitHub source even though the files
197
+ * came from npm, and deliberately: that is what lets Claude Code's own
198
+ * `/plugin update` take over afterwards. npm is the on-ramp, not the channel.
199
+ *
200
+ * ponytail: three private files, no public API. If Claude Code ever ships
201
+ * `claude plugin install --local`, delete this and shell out to it.
202
+ */
203
+ function register(version) {
204
+ const pluginsDir = path.join(claudeHome(), 'plugins');
205
+ const marketplaces = path.join(pluginsDir, 'known_marketplaces.json');
206
+ const known = readJson(marketplaces);
207
+ known.eklavya = {
208
+ source: { source: 'github', repo: 'ProjectAJ14/eklavya' },
209
+ installLocation: marketplaceDir(),
210
+ lastUpdated: new Date().toISOString(),
211
+ autoUpdate: true,
212
+ };
213
+ writeJson(marketplaces, known);
214
+ const installedPath = path.join(pluginsDir, 'installed_plugins.json');
215
+ const installed = readJson(installedPath);
216
+ if (typeof installed.version !== 'number')
217
+ installed.version = 2;
218
+ const plugins = (installed.plugins ?? {});
219
+ plugins['eklavya@eklavya'] = [
220
+ {
221
+ scope: 'user',
222
+ installPath: marketplaceDir(),
223
+ version,
224
+ installedAt: new Date().toISOString(),
225
+ lastUpdated: new Date().toISOString(),
226
+ },
227
+ ];
228
+ installed.plugins = plugins;
229
+ writeJson(installedPath, installed);
230
+ const settingsPath = path.join(claudeHome(), 'settings.json');
231
+ const settings = readJson(settingsPath);
232
+ const enabled = (settings.enabledPlugins ?? {});
233
+ enabled['eklavya@eklavya'] = true;
234
+ settings.enabledPlugins = enabled;
235
+ writeJson(settingsPath, settings);
236
+ }
237
+ function deregister() {
238
+ const pluginsDir = path.join(claudeHome(), 'plugins');
239
+ const marketplaces = path.join(pluginsDir, 'known_marketplaces.json');
240
+ const known = readJson(marketplaces);
241
+ if ('eklavya' in known) {
242
+ delete known.eklavya;
243
+ writeJson(marketplaces, known);
244
+ }
245
+ const installedPath = path.join(pluginsDir, 'installed_plugins.json');
246
+ const installed = readJson(installedPath);
247
+ const plugins = (installed.plugins ?? {});
248
+ if ('eklavya@eklavya' in plugins) {
249
+ delete plugins['eklavya@eklavya'];
250
+ installed.plugins = plugins;
251
+ writeJson(installedPath, installed);
252
+ }
253
+ const settingsPath = path.join(claudeHome(), 'settings.json');
254
+ const settings = readJson(settingsPath);
255
+ const enabled = (settings.enabledPlugins ?? {});
256
+ if ('eklavya@eklavya' in enabled) {
257
+ delete enabled['eklavya@eklavya'];
258
+ settings.enabledPlugins = enabled;
259
+ writeJson(settingsPath, settings);
260
+ }
261
+ }
262
+ // --- commands ---------------------------------------------------------------
263
+ export function install(args) {
264
+ const version = packageVersion();
265
+ say(`Installing Eklavya ${version}`);
266
+ checkNode();
267
+ say(` node ${process.versions.node}`);
268
+ if (!args.includes('--skip-runtime')) {
269
+ say(' runtime installing (first run downloads the SQLite driver)…');
270
+ installRuntime(version);
271
+ verifyRuntime();
272
+ say(` runtime ${runtimeHome()}`);
273
+ }
274
+ copyPayload();
275
+ say(` plugin ${marketplaceDir()}`);
276
+ register(version);
277
+ say(' registered eklavya@eklavya, enabled for Claude Code');
278
+ // Creating the DB here rather than on first server start means `eklavya
279
+ // doctor` and the dashboard work before Claude Code has ever been opened.
280
+ const db = openDb();
281
+ db.close();
282
+ say(` database ${dbPath()}`);
283
+ if (!checkGit()) {
284
+ say('');
285
+ say(' note: git was not found. Eklavya still works — the per-project difficulty');
286
+ say(' level falls back to a shared bucket, and the commit gate needs git.');
287
+ }
288
+ say('');
289
+ say('Done. Restart Claude Code (or start a session) and Eklavya loads with it.');
290
+ say('Next: run /eklavya:setup in Claude Code to choose a mode, or `eklavya doctor` here.');
291
+ }
292
+ export function uninstall(args) {
293
+ const purge = args.includes('--purge');
294
+ deregister();
295
+ say(' registered removed from Claude Code');
296
+ fs.rmSync(marketplaceDir(), { recursive: true, force: true });
297
+ say(' plugin removed');
298
+ fs.rmSync(runtimeHome(), { recursive: true, force: true });
299
+ say(' runtime removed');
300
+ if (purge) {
301
+ // Only ever on an explicit flag. This is everything the learner has done —
302
+ // months of spaced repetition — and an uninstall that silently deletes it is
303
+ // not an uninstall, it is data loss.
304
+ fs.rmSync(eklavyaHome(), { recursive: true, force: true });
305
+ say(` data removed (${eklavyaHome()})`);
306
+ }
307
+ else {
308
+ say(` data kept (${dbPath()}) — pass --purge to delete your learning history`);
309
+ }
310
+ say('');
311
+ say('Eklavya is uninstalled. Restart Claude Code to unload it.');
312
+ }
313
+ //# sourceMappingURL=install.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"install.js","sourceRoot":"","sources":["../src/install.ts"],"names":[],"mappings":"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"}
package/dist/mcq.js ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Where the correct option goes in a multiple-choice question.
3
+ *
4
+ * This is decided server-side, for the same reason `tier_to_ask` and
5
+ * `format_to_use` are: left to the model, it is not decided at all. An LLM asked
6
+ * to "vary the position" reliably puts the right answer first — it writes the
7
+ * answer it is thinking of, then invents distractors after it — so learners
8
+ * discover within a handful of questions that A is always correct and stop
9
+ * reading the options. The quiz keeps working and stops measuring anything.
10
+ *
11
+ * Deterministic rather than random: the same question asked twice in a session
12
+ * must not move under the learner, tests need a fixed answer, and a plan is
13
+ * rebuilt on every call. Varying by attempt count is what makes a re-ask of the
14
+ * same concept land somewhere new.
15
+ */
16
+ /** FNV-1a, 32-bit. Small, dependency-free, and well spread over short strings. */
17
+ function hash(seed) {
18
+ let h = 0x811c9dc5;
19
+ for (let i = 0; i < seed.length; i += 1) {
20
+ h ^= seed.charCodeAt(i);
21
+ h = Math.imul(h, 0x01000193) >>> 0;
22
+ }
23
+ return h >>> 0;
24
+ }
25
+ export const MCQ_OPTION_COUNT = 4;
26
+ /**
27
+ * 1-based position for the correct option. `askedCount` is how many questions
28
+ * this learner has already been asked about the concept, so a concept that comes
29
+ * back for review does not come back in the same shape.
30
+ */
31
+ export function answerPosition(slug, askedCount = 0, optionCount = MCQ_OPTION_COUNT) {
32
+ return (hash(`${slug}:${askedCount}`) % optionCount) + 1;
33
+ }
34
+ //# sourceMappingURL=mcq.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcq.js","sourceRoot":"","sources":["../src/mcq.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,kFAAkF;AAClF,SAAS,IAAI,CAAC,IAAY;IACxB,IAAI,CAAC,GAAG,UAAU,CAAC;IACnB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACxC,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;QACxB,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,UAAU,CAAC,KAAK,CAAC,CAAC;IACrC,CAAC;IACD,OAAO,CAAC,KAAK,CAAC,CAAC;AACjB,CAAC;AAED,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC;AAElC;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY,EAAE,UAAU,GAAG,CAAC,EAAE,WAAW,GAAG,gBAAgB;IACzF,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,IAAI,UAAU,EAAE,CAAC,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;AAC3D,CAAC"}