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
package/dist/store.js ADDED
@@ -0,0 +1,450 @@
1
+ import { applyGrade, checkPromotion, decayedScore, initialMastery, isKnown, nextLevel, requiredConcepts, SCORE_WINDOW, START_LEVEL, } from './srs.js';
2
+ import { stripAskFooter } from './ask.js';
3
+ export function conceptBySlug(db, slug) {
4
+ return db.prepare('SELECT * FROM concepts WHERE slug = ?').get(slug);
5
+ }
6
+ export function allConceptSlugs(db) {
7
+ return db.prepare('SELECT id, slug, domain FROM concepts').all();
8
+ }
9
+ export function insertConcept(db, c) {
10
+ db.prepare(`INSERT INTO concepts (slug, name, domain, description, tier, source)
11
+ VALUES (?, ?, ?, ?, ?, ?)`).run(c.slug, c.name, c.domain, c.description ?? null, c.tier, c.source ?? 'llm');
12
+ return conceptBySlug(db, c.slug);
13
+ }
14
+ export function masteryFor(db, conceptId) {
15
+ const row = db.prepare('SELECT * FROM mastery WHERE concept_id = ?').get(conceptId);
16
+ if (!row)
17
+ return initialMastery();
18
+ return {
19
+ score: row.score,
20
+ ease: row.ease,
21
+ interval_d: row.interval_d,
22
+ reps: row.reps,
23
+ last_seen: row.last_seen,
24
+ next_review: row.next_review,
25
+ };
26
+ }
27
+ export function writeMastery(db, conceptId, state) {
28
+ db.prepare(`INSERT INTO mastery (concept_id, score, ease, interval_d, reps, last_seen, next_review)
29
+ VALUES (@id, @score, @ease, @interval_d, @reps, @last_seen, @next_review)
30
+ ON CONFLICT(concept_id) DO UPDATE SET
31
+ score = excluded.score, ease = excluded.ease, interval_d = excluded.interval_d,
32
+ reps = excluded.reps, last_seen = excluded.last_seen, next_review = excluded.next_review`).run({ id: conceptId, ...state });
33
+ }
34
+ /** Chronological grades, oldest first, windowed to what the score actually uses. */
35
+ export function gradesFor(db, conceptId, limit = SCORE_WINDOW) {
36
+ const rows = db
37
+ .prepare('SELECT grade FROM attempts WHERE concept_id = ? ORDER BY id DESC LIMIT ?')
38
+ .all(conceptId, limit);
39
+ return rows.map((r) => r.grade).reverse();
40
+ }
41
+ export function lastAttempt(db, conceptId) {
42
+ return db
43
+ .prepare('SELECT grade, difficulty, ts FROM attempts WHERE concept_id = ? ORDER BY id DESC LIMIT 1')
44
+ .get(conceptId);
45
+ }
46
+ export function recordAttemptRow(db, a) {
47
+ db.prepare(`INSERT INTO attempts
48
+ (concept_id, session_id, question, answer, grade, difficulty, feedback, outcome, format,
49
+ options, repo, level)
50
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(a.conceptId, a.sessionId, a.question, a.answer, a.grade, a.difficulty, a.feedback, a.outcome, a.format,
51
+ // Stored beside the stem, never inside it: `question` is what gets
52
+ // fingerprinted, and options that move would defeat the repeat check.
53
+ a.options ? JSON.stringify(a.options) : null,
54
+ // The project this was earned in, and the band it was asked at. Progress
55
+ // toward the next level is counted from these two (migration 008).
56
+ a.repo, a.level);
57
+ }
58
+ /** Applies one grade end to end: attempt row, then recomputed SM-2 state. */
59
+ export function gradeConcept(db, input) {
60
+ const before = masteryFor(db, input.conceptId);
61
+ recordAttemptRow(db, input);
62
+ const grades = gradesFor(db, input.conceptId);
63
+ const after = applyGrade({ state: before, grade: input.grade, grades, now: input.now });
64
+ writeMastery(db, input.conceptId, after);
65
+ return after;
66
+ }
67
+ export function logSessionConcept(db, sessionId, conceptId, context, origin = 'work') {
68
+ db.prepare(`INSERT INTO session_concepts (session_id, concept_id, context, origin)
69
+ VALUES (?, ?, ?, ?)
70
+ ON CONFLICT(session_id, concept_id) DO UPDATE SET
71
+ context = COALESCE(excluded.context, session_concepts.context),
72
+ -- Work wins and never degrades: a concept quizzed as review debt and
73
+ -- then genuinely touched by the task is part of the task.
74
+ origin = CASE
75
+ WHEN excluded.origin = 'work' THEN 'work'
76
+ ELSE COALESCE(session_concepts.origin, excluded.origin)
77
+ END`).run(sessionId, conceptId, context, origin);
78
+ }
79
+ /**
80
+ * `origin: 'work'` restricts this to what the task actually touched, excluding
81
+ * concepts a quiz pulled in as review debt. The gate's bar must be computed from
82
+ * that subset: `passedCount` only counts 'work', so letting review rows raise
83
+ * `required` would raise a bar that nothing the learner does can clear.
84
+ */
85
+ export function sessionConcepts(db, sessionId, origin) {
86
+ return db
87
+ .prepare(`SELECT c.*, sc.context, sc.ts AS logged_at
88
+ FROM session_concepts sc JOIN concepts c ON c.id = sc.concept_id
89
+ WHERE sc.session_id = ?
90
+ ${origin ? `AND COALESCE(sc.origin, 'work') = ?` : ''}
91
+ ORDER BY sc.ts ASC, c.tier ASC, c.slug ASC`)
92
+ .all(...(origin ? [sessionId, origin] : [sessionId]));
93
+ }
94
+ export function newConceptsThisSession(db, sessionId) {
95
+ return db
96
+ .prepare(`SELECT count(*) n FROM session_concepts sc
97
+ JOIN concepts c ON c.id = sc.concept_id
98
+ WHERE sc.session_id = ? AND c.source = 'llm'`)
99
+ .get(sessionId).n;
100
+ }
101
+ export function lastAttemptAt(db, sessionId) {
102
+ const row = db
103
+ .prepare('SELECT ts FROM attempts WHERE session_id = ? ORDER BY id DESC LIMIT 1')
104
+ .get(sessionId);
105
+ return row?.ts ?? null;
106
+ }
107
+ export const PASSING_GRADE = 3;
108
+ export function gateRow(db, sessionId) {
109
+ return db.prepare('SELECT * FROM gates WHERE session_id = ?').get(sessionId);
110
+ }
111
+ function countAnswered(db, sessionId) {
112
+ const row = db
113
+ .prepare(
114
+ // `answered` counts everything -- quizzing on review debt is not free.
115
+ // `passedCount` counts only 'work', because `required` is derived from the
116
+ // concepts the work touched and the two ends have to measure the same
117
+ // thing. Without this, `concept` focus widening to domain siblings, or
118
+ // `learn` focus selecting from an unrelated topic, would let a gate whose
119
+ // bar was set by today's diff be cleared without a single question about
120
+ // today's diff. NULL is pre-migration and reads as 'work' (migration 005).
121
+ `SELECT
122
+ count(DISTINCT a.concept_id) AS answered,
123
+ count(DISTINCT CASE
124
+ WHEN a.grade >= ? AND COALESCE(sc.origin, 'work') = 'work'
125
+ THEN a.concept_id
126
+ END) AS passedCount
127
+ FROM attempts a
128
+ JOIN session_concepts sc
129
+ ON sc.concept_id = a.concept_id AND sc.session_id = a.session_id
130
+ WHERE a.session_id = ?`)
131
+ .get(PASSING_GRADE, sessionId);
132
+ return row;
133
+ }
134
+ /**
135
+ * Recomputes and persists the gate for a session. `requiredHint` raises the bar
136
+ * (never lowers it) when new unmastered concepts show up mid-session.
137
+ */
138
+ export function syncGate(db, sessionId, config, opts = {}) {
139
+ const existing = gateRow(db, sessionId);
140
+ const { answered, passedCount } = countAnswered(db, sessionId);
141
+ const required = Math.max(existing?.required ?? 0, opts.requiredHint ?? 0);
142
+ // Keep a repo once known: a later call from a different cwd must not blank it,
143
+ // or the git pre-commit hook loses the row it enforces against.
144
+ const repo = opts.repo ?? existing?.repo ?? null;
145
+ const needed = Math.ceil(required * config.pass_threshold);
146
+ const passed = required === 0 ? true : passedCount >= needed;
147
+ db.prepare(`INSERT INTO gates (session_id, mode, required, answered, passed, updated_at, repo)
148
+ VALUES (?, ?, ?, ?, ?, datetime('now'), ?)
149
+ ON CONFLICT(session_id) DO UPDATE SET
150
+ mode = excluded.mode, required = excluded.required,
151
+ answered = excluded.answered, passed = excluded.passed,
152
+ updated_at = excluded.updated_at, repo = excluded.repo`).run(sessionId, config.mode, required, answered, passed ? 1 : 0, repo);
153
+ return { mode: config.mode, required, answered, passed, pass_threshold: config.pass_threshold, repo };
154
+ }
155
+ /**
156
+ * The best grade a multiple-choice answer can earn.
157
+ *
158
+ * Grade 5 on the SM-2 scale means "correct, and explained why". Picking the
159
+ * right option out of four cannot demonstrate that -- and one in four is a coin,
160
+ * so even a clean 4 is generous. Capping here rather than trusting the tutor to
161
+ * remember keeps recognition from inflating mastery, which is the one thing that
162
+ * would make the whole record untrustworthy.
163
+ *
164
+ * 4 still reaches `known` (0.8 >= MASTERY_THRESHOLD), so this limits how fast
165
+ * mastery is claimed, not whether it can be.
166
+ */
167
+ export const MAX_MCQ_GRADE = 4;
168
+ /** The last few questions actually asked about a concept, newest first. */
169
+ export function recentQuestions(db, conceptId, limit = 3) {
170
+ const rows = db
171
+ .prepare(`SELECT question, difficulty AS tier, grade, outcome, format FROM attempts
172
+ WHERE concept_id = ? AND question <> ''
173
+ ORDER BY id DESC LIMIT ?`)
174
+ .all(conceptId, limit);
175
+ return rows.map((r) => ({ ...r, taught: r.outcome === 'dont_know' }));
176
+ }
177
+ /**
178
+ * Loose match: whitespace and punctuation differences are still the same question.
179
+ *
180
+ * The ask footer is stripped before hashing. It is presentation -- the dials that
181
+ * asked -- and it changes when the level or the focus changes, so leaving it in
182
+ * would make one question fingerprint as several and quietly undo "never the same
183
+ * question twice".
184
+ */
185
+ export function questionFingerprint(question) {
186
+ return stripAskFooter(question).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
187
+ }
188
+ export function hasAskedQuestion(db, conceptId, question) {
189
+ const target = questionFingerprint(question);
190
+ if (!target)
191
+ return false;
192
+ const rows = db
193
+ .prepare('SELECT question FROM attempts WHERE concept_id = ? ORDER BY id DESC LIMIT 20')
194
+ .all(conceptId);
195
+ return rows.some((r) => questionFingerprint(r.question) === target);
196
+ }
197
+ /**
198
+ * Has this concept ever been blanked on and taught?
199
+ *
200
+ * Deliberately not derived from `recentQuestions`, which is windowed: once a few
201
+ * newer attempts pile up, the `dont_know` row falls out of that window and the
202
+ * flag flips back to false. The concepts that accumulate attempts fastest are
203
+ * the unmastered ones that keep resurfacing -- exactly the ones most likely to
204
+ * have been blanked on -- so deriving it there loses the flag precisely where it
205
+ * matters most, and the next question opens cold on a topic already explained.
206
+ */
207
+ export function wasEverTaught(db, conceptId) {
208
+ const row = db
209
+ .prepare(`SELECT 1 FROM attempts WHERE concept_id = ? AND outcome = 'dont_know' LIMIT 1`)
210
+ .get(conceptId);
211
+ return row !== undefined;
212
+ }
213
+ /**
214
+ * Concepts this session attempted but did not pass, minus the ones the learner
215
+ * declined.
216
+ *
217
+ * This is the way out of an otherwise unreachable gate. A blank grades 0, and a
218
+ * concept with any attempt is filtered from the session plan, so a session
219
+ * answered entirely with "I don't know" leaves `required` permanently unmet: the
220
+ * planner returns nothing, and the commit gate denies forever. Offering those
221
+ * concepts again -- once everything else is exhausted, a tier lower, and with
222
+ * `asked_before` forcing a different question -- turns the deadlock into a
223
+ * second lap over material that has now been taught.
224
+ *
225
+ * Declines are excluded on purpose, judged on the *latest* attempt so someone
226
+ * who declined and later engaged is not held to the earlier answer. "Leave me
227
+ * alone" is a choice, and enforced mode holding the gate against it is the
228
+ * enforcement working, not a deadlock.
229
+ */
230
+ export function gateRetryConcepts(db, sessionId) {
231
+ return db
232
+ .prepare(
233
+ // Carries `context` so a retry question stays grounded in the same code
234
+ // the first one was about -- the concept was taught, not the file.
235
+ `SELECT c.*, sc.context, sc.ts AS logged_at FROM concepts c
236
+ JOIN session_concepts sc ON sc.concept_id = c.id AND sc.session_id = ?
237
+ JOIN (
238
+ SELECT a.concept_id,
239
+ max(a.grade) AS best_grade,
240
+ (SELECT x.outcome FROM attempts x
241
+ WHERE x.session_id = a.session_id AND x.concept_id = a.concept_id
242
+ ORDER BY x.id DESC LIMIT 1) AS last_outcome
243
+ FROM attempts a
244
+ WHERE a.session_id = ?
245
+ GROUP BY a.concept_id
246
+ ) t ON t.concept_id = c.id
247
+ WHERE t.best_grade < ?
248
+ AND (t.last_outcome IS NULL OR t.last_outcome <> 'declined')
249
+ ORDER BY sc.ts ASC`)
250
+ .all(sessionId, sessionId, PASSING_GRADE);
251
+ }
252
+ /**
253
+ * Concepts in the same domains as the session's work, for `concept` focus.
254
+ *
255
+ * `project` focus asks about the diff; `concept` focus asks about the ideas the
256
+ * diff is an instance of, which means reaching past what the task happened to
257
+ * touch. Prerequisites of touched concepts come too: they are the part of "the
258
+ * general version" a learner is most likely to be missing.
259
+ */
260
+ export function domainSiblings(db, domains, exclude) {
261
+ if (domains.length === 0)
262
+ return [];
263
+ const rows = db
264
+ .prepare(`SELECT c.* FROM concepts c
265
+ WHERE c.domain IN (${domains.map(() => '?').join(',')})
266
+ ORDER BY c.tier ASC, c.slug ASC`)
267
+ .all(...domains);
268
+ return rows.filter((c) => !exclude.has(c.id));
269
+ }
270
+ /** Prerequisites of the given concepts, nearest first. Used to widen `concept` focus. */
271
+ export function prereqsOf(db, conceptIds) {
272
+ if (conceptIds.length === 0)
273
+ return [];
274
+ return db
275
+ .prepare(`SELECT DISTINCT c.* FROM concepts c
276
+ JOIN edges e ON e.from_concept = c.id
277
+ WHERE e.relation = 'prerequisite_of'
278
+ AND e.to_concept IN (${conceptIds.map(() => '?').join(',')})
279
+ ORDER BY c.tier ASC, c.slug ASC`)
280
+ .all(...conceptIds);
281
+ }
282
+ /**
283
+ * Resolve a free-text topic ("caching", "web auth") onto the graph, for `learn`
284
+ * focus.
285
+ *
286
+ * Tried in order of confidence: an exact domain, then a domain whose name the
287
+ * topic contains or is contained by, then concepts matching on slug or name.
288
+ * Returning both a domain and slugs lets the caller prefer the domain when the
289
+ * topic names one and fall back to loose concept matches when it does not.
290
+ *
291
+ * An empty result is meaningful and must not be papered over -- it means the
292
+ * graph has nothing on this topic yet, and the honest answer is to say so and
293
+ * offer to teach from first principles rather than to invent questions.
294
+ */
295
+ export function resolveTopic(db, topic) {
296
+ const needle = topic.trim().toLowerCase();
297
+ if (!needle)
298
+ return { domain: null, slugs: [] };
299
+ const slugged = needle.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
300
+ const domains = db.prepare('SELECT DISTINCT domain FROM concepts').all().map((r) => r.domain);
301
+ const exact = domains.find((d) => d.toLowerCase() === needle || d.toLowerCase() === slugged);
302
+ const loose = exact ??
303
+ domains.find((d) => {
304
+ const l = d.toLowerCase();
305
+ return l.includes(slugged) || slugged.includes(l);
306
+ });
307
+ const rows = db
308
+ .prepare(`SELECT slug FROM concepts
309
+ WHERE slug LIKE ? OR lower(name) LIKE ?
310
+ ORDER BY tier ASC, slug ASC
311
+ LIMIT 40`)
312
+ .all(`%${slugged}%`, `%${needle}%`);
313
+ return { domain: loose ?? null, slugs: rows.map((r) => r.slug) };
314
+ }
315
+ /** Concepts already asked about in this session — they have had their turn. */
316
+ export function attemptedConceptIds(db, sessionId) {
317
+ const rows = db
318
+ .prepare('SELECT DISTINCT concept_id FROM attempts WHERE session_id = ?')
319
+ .all(sessionId);
320
+ return new Set(rows.map((r) => r.concept_id));
321
+ }
322
+ /**
323
+ * Prerequisites of a concept the learner has not mastered yet. A tier-3
324
+ * judgement question about a concept whose foundations are missing is not a hard
325
+ * question, it is an unfair one — the tutor needs to see this before asking.
326
+ */
327
+ export function unmetPrereqs(db, conceptId, now) {
328
+ const rows = db
329
+ .prepare(`SELECT c.slug, m.score, m.reps, m.next_review
330
+ FROM edges e
331
+ JOIN concepts c ON c.id = e.from_concept
332
+ LEFT JOIN mastery m ON m.concept_id = c.id
333
+ WHERE e.to_concept = ? AND e.relation = 'prerequisite_of'
334
+ ORDER BY c.tier ASC, c.slug ASC`)
335
+ .all(conceptId);
336
+ return rows
337
+ .filter((r) => !isKnown({ score: decayedScore(r.score ?? 0, r.next_review, now), reps: r.reps ?? 0 }))
338
+ .map((r) => r.slug);
339
+ }
340
+ // ---------------------------------------------------------------------------
341
+ // Difficulty levels (phase-9)
342
+ //
343
+ // The level is per project and earned. `srs.ts` holds the rules; this holds the
344
+ // two facts they are computed from -- which band the project is on, and what has
345
+ // been answered since it got there.
346
+ // ---------------------------------------------------------------------------
347
+ /**
348
+ * The bucket for work outside any git repository.
349
+ *
350
+ * A scratch directory still deserves a level, but keying on `cwd` would mint a
351
+ * row per temp folder and mean nobody ever accumulates a hundred answers
352
+ * anywhere.
353
+ */
354
+ export const GLOBAL_PROJECT = '*';
355
+ export function projectKey(repoRoot) {
356
+ return repoRoot && repoRoot.trim() ? repoRoot : GLOBAL_PROJECT;
357
+ }
358
+ export function projectLevelRow(db, repo) {
359
+ return db.prepare('SELECT * FROM project_levels WHERE repo = ?').get(repo);
360
+ }
361
+ /**
362
+ * Counts the evidence at one level in one project.
363
+ *
364
+ * Derived, never stored. A counter column would drift from the attempt rows that
365
+ * justify it, and it would freeze `level_up_after` at whatever it was when each
366
+ * attempt landed -- so lowering the threshold would not release the learners
367
+ * already past it, which is the one change most likely to be made after watching
368
+ * a real learner.
369
+ *
370
+ * `since` is the level's `promoted_at`: answers from the previous band are spent.
371
+ * Declines are excluded from both halves of the accuracy fraction -- skipping a
372
+ * question honestly must never cost a level, or the level becomes a reason to
373
+ * guess.
374
+ */
375
+ export function levelCounts(db, repo, level, since) {
376
+ const row = db
377
+ .prepare(`SELECT
378
+ COALESCE(sum(CASE WHEN grade >= ? THEN 1 ELSE 0 END), 0) AS passed,
379
+ count(*) AS answered,
380
+ count(DISTINCT CASE WHEN grade >= ? THEN concept_id END) AS concepts
381
+ FROM attempts
382
+ WHERE repo = ? AND level = ?
383
+ AND COALESCE(outcome, 'answered') <> 'declined'
384
+ AND (? IS NULL OR ts >= ?)`)
385
+ .get(PASSING_GRADE, PASSING_GRADE, repo, level, since, since);
386
+ return { passed: row.passed, answered: row.answered, concepts: row.concepts };
387
+ }
388
+ /** Everything a caller needs to ask at the right band and report the runway. */
389
+ export function levelStanding(db, config, repoRoot) {
390
+ const repo = projectKey(repoRoot);
391
+ const pinned = config.difficulty !== 'auto';
392
+ const row = projectLevelRow(db, repo);
393
+ // A pin wins outright, and does not disturb the earned row underneath it: drop
394
+ // the pin later and the project is back where its evidence left it.
395
+ const level = pinned ? config.difficulty : (row?.level ?? START_LEVEL);
396
+ const entered_at = pinned ? null : (row?.promoted_at ?? null);
397
+ const counts = levelCounts(db, repo, level, entered_at);
398
+ const verdict = checkPromotion({
399
+ level,
400
+ counts,
401
+ after: config.level_up_after,
402
+ minAccuracy: config.level_up_accuracy,
403
+ });
404
+ return {
405
+ repo,
406
+ level,
407
+ pinned,
408
+ entered_at,
409
+ next: nextLevel(level),
410
+ counts,
411
+ accuracy: verdict.accuracy,
412
+ needed: {
413
+ answers: config.level_up_after,
414
+ accuracy: config.level_up_accuracy,
415
+ concepts: requiredConcepts(config.level_up_after),
416
+ },
417
+ unmet: verdict.unmet,
418
+ };
419
+ }
420
+ /**
421
+ * Promotes the project if this attempt has just earned it. Call inside the same
422
+ * transaction as the grade, or a crash between the two loses the evidence.
423
+ *
424
+ * `datetime('now')` rather than a JS timestamp, deliberately: `attempts.ts`
425
+ * defaults to SQLite's own format, and `promoted_at` is compared against it as a
426
+ * string. An ISO timestamp with a `T` in it would sort wrong against
427
+ * `2026-08-27 09:31:00` and silently count the previous band's answers.
428
+ */
429
+ export function promoteIfEarned(db, config, repoRoot) {
430
+ // A pinned level is a decision, not a stage: no progression, and no row written.
431
+ if (config.difficulty !== 'auto')
432
+ return null;
433
+ const standing = levelStanding(db, config, repoRoot);
434
+ const verdict = checkPromotion({
435
+ level: standing.level,
436
+ counts: standing.counts,
437
+ after: config.level_up_after,
438
+ minAccuracy: config.level_up_accuracy,
439
+ });
440
+ if (!verdict.promote || !verdict.to)
441
+ return null;
442
+ db.prepare(`INSERT INTO project_levels (repo, level, promoted_at, updated_at)
443
+ VALUES (?, ?, datetime('now'), datetime('now'))
444
+ ON CONFLICT(repo) DO UPDATE SET
445
+ level = excluded.level,
446
+ promoted_at = excluded.promoted_at,
447
+ updated_at = excluded.updated_at`).run(standing.repo, verdict.to);
448
+ return { from: standing.level, to: verdict.to, counts: standing.counts };
449
+ }
450
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,119 @@
1
+ import { z } from 'zod';
2
+ import path from 'node:path';
3
+ import { loadConfig, writeConfigFile, REPO_CONFIG_FILE } from '../config.js';
4
+ import { CWD_HINT } from './types.js';
5
+ export const getConfig = {
6
+ name: 'get_config',
7
+ title: 'Get config',
8
+ description: 'The effective Eklavya config: global ~/.eklavya/config.json merged with the repo .eklavya.json, repo winning.',
9
+ inputSchema: { cwd: z.string().optional().describe(CWD_HINT) },
10
+ handler: (args) => {
11
+ const resolved = loadConfig(args.cwd);
12
+ return {
13
+ config: resolved.config,
14
+ global_path: resolved.globalPath,
15
+ repo_path: resolved.repoPath,
16
+ repo_root: resolved.repoRoot,
17
+ // Which of the learner's own settings this repo is overriding. Say it
18
+ // rather than let a personal focus silently stop applying.
19
+ overridden_by_repo: resolved.overrides,
20
+ };
21
+ },
22
+ };
23
+ export const setConfig = {
24
+ name: 'set_config',
25
+ title: 'Set config',
26
+ description: 'Update Eklavya config. Scope "global" writes ~/.eklavya/config.json; scope "repo" writes .eklavya.json at the repo root, which is how a team lead pins enforced mode — or a difficulty level — for one project.',
27
+ inputSchema: {
28
+ scope: z.enum(['global', 'repo']).optional().describe('Defaults to global.'),
29
+ cwd: z.string().optional().describe(CWD_HINT),
30
+ mode: z
31
+ .enum(['ambient', 'enforced', 'off'])
32
+ .optional()
33
+ .describe('How hard Eklavya pushes: ambient offers, enforced gates commits, off is dormant.'),
34
+ focus: z
35
+ .enum(['project', 'concept', 'learn'])
36
+ .optional()
37
+ .describe('What Eklavya teaches, independent of mode. "project" quizzes the code just written; "concept" asks the transferable version of the same ideas; "learn" follows focus_topic. Defaults to project.'),
38
+ cadence: z
39
+ .enum(['interleaved', 'end'])
40
+ .optional()
41
+ .describe('When the questions land. "interleaved" (default) asks one question mid-task, at the seam where a concept was logged, and the Stop hook then only sweeps up what is left of max_questions_per_task. "end" is the old behaviour: nothing until the task is finished.'),
42
+ min_minutes_between_checkpoints: z.number().int().min(0).max(120).optional(),
43
+ difficulty: z
44
+ .enum(['auto', 'easy', 'medium', 'hard'])
45
+ .optional()
46
+ .describe('How hard questions on a project may get. "auto" (default) earns the level per project: everyone starts at easy (tiers 1-2), then medium (2-4), then hard (3-5). A literal level pins it and stops progression — "easy" on a repo keeps an onboarding codebase gentle for everyone, "hard" globally skips the runway.'),
47
+ level_up_after: z
48
+ .number()
49
+ .int()
50
+ .min(1)
51
+ .max(1000)
52
+ .optional()
53
+ .describe('Passing answers needed at a level, in one project, before it promotes. Defaults to 100.'),
54
+ level_up_accuracy: z
55
+ .number()
56
+ .min(0)
57
+ .max(1)
58
+ .optional()
59
+ .describe('Minimum accuracy over those answers, declines excluded. Defaults to 0.7.'),
60
+ focus_topic: z
61
+ .string()
62
+ .nullable()
63
+ .optional()
64
+ .describe('The topic "learn" focus teaches, e.g. "caching". Pass null to clear it.'),
65
+ pass_threshold: z.number().min(0).max(1).optional(),
66
+ max_questions_per_task: z.number().int().min(1).max(10).optional(),
67
+ min_minutes_between_quizzes: z.number().int().min(0).optional(),
68
+ max_new_concepts_per_session: z.number().int().min(0).max(50).optional(),
69
+ max_stop_blocks_per_session: z.number().int().min(0).max(20).optional(),
70
+ quiet: z.boolean().optional(),
71
+ domains_enabled: z.array(z.string()).optional(),
72
+ },
73
+ handler: (args) => {
74
+ const scope = args.scope ?? 'global';
75
+ const cwd = args.cwd;
76
+ const resolved = loadConfig(cwd);
77
+ const patch = {};
78
+ for (const key of [
79
+ 'mode',
80
+ 'focus',
81
+ 'focus_topic',
82
+ 'cadence',
83
+ 'difficulty',
84
+ 'level_up_after',
85
+ 'level_up_accuracy',
86
+ 'min_minutes_between_checkpoints',
87
+ 'pass_threshold',
88
+ 'max_questions_per_task',
89
+ 'min_minutes_between_quizzes',
90
+ 'max_new_concepts_per_session',
91
+ 'max_stop_blocks_per_session',
92
+ 'quiet',
93
+ 'domains_enabled',
94
+ ]) {
95
+ if (args[key] !== undefined)
96
+ patch[key] = args[key];
97
+ }
98
+ if (Object.keys(patch).length === 0) {
99
+ return { error: 'nothing_to_set', detail: 'Pass at least one setting to change.' };
100
+ }
101
+ let target;
102
+ if (scope === 'repo') {
103
+ const root = resolved.repoRoot;
104
+ if (!root) {
105
+ return {
106
+ error: 'no_repo_root',
107
+ detail: 'No git repository found from this directory, so there is nowhere to write .eklavya.json.',
108
+ };
109
+ }
110
+ target = resolved.repoPath ?? path.join(root, REPO_CONFIG_FILE);
111
+ }
112
+ else {
113
+ target = resolved.globalPath;
114
+ }
115
+ writeConfigFile(target, patch);
116
+ return { written_to: target, scope, config: loadConfig(cwd).config };
117
+ },
118
+ };
119
+ //# sourceMappingURL=config_tools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config_tools.js","sourceRoot":"","sources":["../../src/tools/config_tools.ts"],"names":[],"mappings":"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"}