@bis-code/study-dash 0.2.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 (89) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +19 -0
  3. package/.mcp.json +8 -0
  4. package/LICENSE +21 -0
  5. package/commands/dashboard.md +8 -0
  6. package/commands/import.md +12 -0
  7. package/commands/learn.md +13 -0
  8. package/hooks/hooks.json +27 -0
  9. package/package.json +36 -0
  10. package/rules/tutor-mode.md +38 -0
  11. package/server/dist/bundle.mjs +1240 -0
  12. package/server/dist/dashboard/api.d.ts +16 -0
  13. package/server/dist/dashboard/api.js +150 -0
  14. package/server/dist/dashboard/api.js.map +1 -0
  15. package/server/dist/dashboard/server.d.ts +21 -0
  16. package/server/dist/dashboard/server.js +171 -0
  17. package/server/dist/dashboard/server.js.map +1 -0
  18. package/server/dist/index.d.ts +2 -0
  19. package/server/dist/index.js +40 -0
  20. package/server/dist/index.js.map +1 -0
  21. package/server/dist/services/curriculum.d.ts +25 -0
  22. package/server/dist/services/curriculum.js +110 -0
  23. package/server/dist/services/curriculum.js.map +1 -0
  24. package/server/dist/services/exercises.d.ts +35 -0
  25. package/server/dist/services/exercises.js +215 -0
  26. package/server/dist/services/exercises.js.map +1 -0
  27. package/server/dist/services/qa.d.ts +15 -0
  28. package/server/dist/services/qa.js +30 -0
  29. package/server/dist/services/qa.js.map +1 -0
  30. package/server/dist/services/viz.d.ts +8 -0
  31. package/server/dist/services/viz.js +21 -0
  32. package/server/dist/services/viz.js.map +1 -0
  33. package/server/dist/storage/db.d.ts +11 -0
  34. package/server/dist/storage/db.js +51 -0
  35. package/server/dist/storage/db.js.map +1 -0
  36. package/server/dist/storage/files.d.ts +10 -0
  37. package/server/dist/storage/files.js +34 -0
  38. package/server/dist/storage/files.js.map +1 -0
  39. package/server/dist/storage/schema.d.ts +3 -0
  40. package/server/dist/storage/schema.js +126 -0
  41. package/server/dist/storage/schema.js.map +1 -0
  42. package/server/dist/tools/curriculum.d.ts +4 -0
  43. package/server/dist/tools/curriculum.js +137 -0
  44. package/server/dist/tools/curriculum.js.map +1 -0
  45. package/server/dist/tools/exercises.d.ts +4 -0
  46. package/server/dist/tools/exercises.js +76 -0
  47. package/server/dist/tools/exercises.js.map +1 -0
  48. package/server/dist/tools/qa.d.ts +4 -0
  49. package/server/dist/tools/qa.js +56 -0
  50. package/server/dist/tools/qa.js.map +1 -0
  51. package/server/dist/tools/viz.d.ts +4 -0
  52. package/server/dist/tools/viz.js +54 -0
  53. package/server/dist/tools/viz.js.map +1 -0
  54. package/server/dist/types.d.ts +103 -0
  55. package/server/dist/types.js +2 -0
  56. package/server/dist/types.js.map +1 -0
  57. package/server/node_modules/better-sqlite3/LICENSE +21 -0
  58. package/server/node_modules/better-sqlite3/README.md +99 -0
  59. package/server/node_modules/better-sqlite3/binding.gyp +38 -0
  60. package/server/node_modules/better-sqlite3/build/Release/better_sqlite3.node +0 -0
  61. package/server/node_modules/better-sqlite3/deps/common.gypi +68 -0
  62. package/server/node_modules/better-sqlite3/deps/copy.js +31 -0
  63. package/server/node_modules/better-sqlite3/deps/defines.gypi +41 -0
  64. package/server/node_modules/better-sqlite3/deps/download.sh +122 -0
  65. package/server/node_modules/better-sqlite3/deps/patches/1208.patch +15 -0
  66. package/server/node_modules/better-sqlite3/deps/sqlite3/sqlite3.c +261480 -0
  67. package/server/node_modules/better-sqlite3/deps/sqlite3/sqlite3.h +13715 -0
  68. package/server/node_modules/better-sqlite3/deps/sqlite3/sqlite3ext.h +719 -0
  69. package/server/node_modules/better-sqlite3/deps/sqlite3.gyp +80 -0
  70. package/server/node_modules/better-sqlite3/deps/test_extension.c +21 -0
  71. package/server/node_modules/better-sqlite3/lib/database.js +90 -0
  72. package/server/node_modules/better-sqlite3/lib/index.js +3 -0
  73. package/server/node_modules/better-sqlite3/lib/methods/aggregate.js +43 -0
  74. package/server/node_modules/better-sqlite3/lib/methods/backup.js +67 -0
  75. package/server/node_modules/better-sqlite3/lib/methods/function.js +31 -0
  76. package/server/node_modules/better-sqlite3/lib/methods/inspect.js +7 -0
  77. package/server/node_modules/better-sqlite3/lib/methods/pragma.js +12 -0
  78. package/server/node_modules/better-sqlite3/lib/methods/serialize.js +16 -0
  79. package/server/node_modules/better-sqlite3/lib/methods/table.js +189 -0
  80. package/server/node_modules/better-sqlite3/lib/methods/transaction.js +78 -0
  81. package/server/node_modules/better-sqlite3/lib/methods/wrappers.js +54 -0
  82. package/server/node_modules/better-sqlite3/lib/sqlite-error.js +20 -0
  83. package/server/node_modules/better-sqlite3/lib/util.js +12 -0
  84. package/server/node_modules/better-sqlite3/package.json +54 -0
  85. package/server/node_modules/better-sqlite3/src/better_sqlite3.cpp +2186 -0
  86. package/server/node_modules/better-sqlite3/src/better_sqlite3.hpp +1036 -0
  87. package/server/package.json +31 -0
  88. package/skills/import/SKILL.md +19 -0
  89. package/skills/learn/SKILL.md +17 -0
@@ -0,0 +1,1240 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+
7
+ // src/storage/db.ts
8
+ import BetterSqlite3 from "better-sqlite3";
9
+
10
+ // src/storage/schema.ts
11
+ var schema = `
12
+ PRAGMA foreign_keys=ON;
13
+
14
+ CREATE TABLE IF NOT EXISTS subjects (
15
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ name TEXT NOT NULL,
17
+ slug TEXT NOT NULL UNIQUE,
18
+ language TEXT NOT NULL DEFAULT '',
19
+ source TEXT NOT NULL DEFAULT 'manual'
20
+ CHECK (source IN ('manual','roadmap','pdf')),
21
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS phases (
25
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ subject_id INTEGER NOT NULL REFERENCES subjects(id) ON DELETE CASCADE,
27
+ name TEXT NOT NULL,
28
+ description TEXT NOT NULL DEFAULT '',
29
+ sort_order INTEGER NOT NULL DEFAULT 0
30
+ );
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_phases_subject ON phases(subject_id);
33
+
34
+ CREATE TABLE IF NOT EXISTS topics (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ phase_id INTEGER NOT NULL REFERENCES phases(id) ON DELETE CASCADE,
37
+ name TEXT NOT NULL,
38
+ description TEXT NOT NULL DEFAULT '',
39
+ sort_order INTEGER NOT NULL DEFAULT 0,
40
+ status TEXT NOT NULL DEFAULT 'todo'
41
+ CHECK (status IN ('todo','in_progress','done')),
42
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
43
+ );
44
+
45
+ CREATE INDEX IF NOT EXISTS idx_topics_phase ON topics(phase_id);
46
+ CREATE INDEX IF NOT EXISTS idx_topics_status ON topics(status);
47
+
48
+ CREATE TABLE IF NOT EXISTS entries (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ topic_id INTEGER NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
51
+ kind TEXT NOT NULL CHECK (kind IN ('question','answer','note')),
52
+ content TEXT NOT NULL DEFAULT '',
53
+ session_id TEXT NOT NULL DEFAULT '',
54
+ question_id INTEGER REFERENCES entries(id) ON DELETE SET NULL,
55
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
56
+ );
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_entries_topic ON entries(topic_id);
59
+ CREATE INDEX IF NOT EXISTS idx_entries_session ON entries(session_id);
60
+
61
+ CREATE TABLE IF NOT EXISTS visualizations (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ topic_id INTEGER NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
64
+ title TEXT NOT NULL DEFAULT '',
65
+ steps_json TEXT NOT NULL DEFAULT '[]',
66
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_viz_topic ON visualizations(topic_id);
70
+
71
+ CREATE TABLE IF NOT EXISTS exercises (
72
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
73
+ topic_id INTEGER NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
74
+ title TEXT NOT NULL DEFAULT '',
75
+ type TEXT NOT NULL DEFAULT 'coding'
76
+ CHECK (type IN ('coding','quiz','project','assignment')),
77
+ description TEXT NOT NULL DEFAULT '',
78
+ difficulty TEXT NOT NULL DEFAULT 'medium'
79
+ CHECK (difficulty IN ('easy','medium','hard')),
80
+ est_minutes INTEGER NOT NULL DEFAULT 0,
81
+ source TEXT NOT NULL DEFAULT 'ai'
82
+ CHECK (source IN ('ai','pdf_import')),
83
+ starter_code TEXT NOT NULL DEFAULT '',
84
+ test_content TEXT NOT NULL DEFAULT '',
85
+ quiz_json TEXT NOT NULL DEFAULT '{}',
86
+ file_path TEXT NOT NULL DEFAULT '',
87
+ status TEXT NOT NULL DEFAULT 'pending'
88
+ CHECK (status IN ('pending','in_progress','passed','failed')),
89
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
90
+ );
91
+
92
+ CREATE INDEX IF NOT EXISTS idx_exercises_topic ON exercises(topic_id);
93
+ CREATE INDEX IF NOT EXISTS idx_exercises_status ON exercises(status);
94
+
95
+ CREATE TABLE IF NOT EXISTS exercise_results (
96
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
97
+ exercise_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE,
98
+ test_name TEXT NOT NULL DEFAULT '',
99
+ passed INTEGER NOT NULL DEFAULT 0,
100
+ output TEXT NOT NULL DEFAULT '',
101
+ ran_at TEXT NOT NULL DEFAULT (datetime('now'))
102
+ );
103
+
104
+ CREATE INDEX IF NOT EXISTS idx_results_exercise ON exercise_results(exercise_id);
105
+
106
+ CREATE TABLE IF NOT EXISTS settings (
107
+ key TEXT PRIMARY KEY,
108
+ value TEXT NOT NULL DEFAULT ''
109
+ );
110
+
111
+ -- FTS5 virtual table for full-text search over entries
112
+ CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts
113
+ USING fts5(content, content='entries', content_rowid='id');
114
+
115
+ -- Sync triggers: keep entries_fts up to date with entries
116
+ CREATE TRIGGER IF NOT EXISTS entries_ai
117
+ AFTER INSERT ON entries BEGIN
118
+ INSERT INTO entries_fts(rowid, content) VALUES (new.id, new.content);
119
+ END;
120
+
121
+ CREATE TRIGGER IF NOT EXISTS entries_ad
122
+ AFTER DELETE ON entries BEGIN
123
+ INSERT INTO entries_fts(entries_fts, rowid, content)
124
+ VALUES ('delete', old.id, old.content);
125
+ END;
126
+
127
+ CREATE TRIGGER IF NOT EXISTS entries_au
128
+ AFTER UPDATE ON entries BEGIN
129
+ INSERT INTO entries_fts(entries_fts, rowid, content)
130
+ VALUES ('delete', old.id, old.content);
131
+ INSERT INTO entries_fts(rowid, content) VALUES (new.id, new.content);
132
+ END;
133
+ `;
134
+ var migrations = [];
135
+
136
+ // src/storage/db.ts
137
+ var Database = class {
138
+ db;
139
+ constructor(dbPath) {
140
+ this.db = new BetterSqlite3(dbPath);
141
+ this.db.pragma("journal_mode=WAL");
142
+ this.db.pragma("foreign_keys=ON");
143
+ this.db.exec(schema);
144
+ const currentVersion = this.getSetting("schema_version");
145
+ if (!currentVersion) {
146
+ this.setSetting("schema_version", "1");
147
+ this.setSetting("auto_viz", "true");
148
+ this.setSetting("dashboard_port", "19282");
149
+ }
150
+ const versionNum = parseInt(this.getSetting("schema_version") ?? "1", 10);
151
+ for (let i = versionNum - 1; i < migrations.length; i++) {
152
+ this.db.exec(migrations[i]);
153
+ this.setSetting("schema_version", String(i + 2));
154
+ }
155
+ }
156
+ getSetting(key) {
157
+ const row = this.db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
158
+ return row?.value;
159
+ }
160
+ setSetting(key, value) {
161
+ this.db.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
162
+ }
163
+ listTables() {
164
+ const allRows = this.db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table')").all();
165
+ return allRows.map((r) => r.name);
166
+ }
167
+ /** Expose the raw better-sqlite3 handle for advanced operations. */
168
+ get raw() {
169
+ return this.db;
170
+ }
171
+ close() {
172
+ this.db.close();
173
+ }
174
+ };
175
+
176
+ // src/storage/files.ts
177
+ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
178
+ import { join } from "node:path";
179
+ import { homedir } from "node:os";
180
+ var FileStore = class {
181
+ baseDir;
182
+ constructor(baseDir) {
183
+ this.baseDir = baseDir ?? join(homedir(), ".claude", "learn");
184
+ mkdirSync(join(this.baseDir, "exercises"), { recursive: true });
185
+ }
186
+ get exercisesDir() {
187
+ return join(this.baseDir, "exercises");
188
+ }
189
+ get dataDir() {
190
+ return this.baseDir;
191
+ }
192
+ get dbPath() {
193
+ return join(this.baseDir, "data.db");
194
+ }
195
+ writeExerciseFiles(subjectSlug, exerciseSlug, files) {
196
+ const dir = join(this.exercisesDir, subjectSlug, exerciseSlug);
197
+ mkdirSync(dir, { recursive: true });
198
+ for (const [name, content] of Object.entries(files)) {
199
+ writeFileSync(join(dir, name), content, "utf-8");
200
+ }
201
+ return dir;
202
+ }
203
+ exerciseExists(subjectSlug, exerciseSlug) {
204
+ return existsSync(join(this.exercisesDir, subjectSlug, exerciseSlug));
205
+ }
206
+ readFile(path) {
207
+ return readFileSync(path, "utf-8");
208
+ }
209
+ };
210
+
211
+ // src/services/curriculum.ts
212
+ function slugify(name) {
213
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
214
+ }
215
+ var CurriculumService = class {
216
+ constructor(db2) {
217
+ this.db = db2;
218
+ }
219
+ // ── Subjects ───────────────────────────────────────────────────────────────
220
+ createSubject(name, language = "", source = "manual") {
221
+ const slug = language && !language.includes(" ") && name.toLowerCase() === language.toLowerCase() ? language.toLowerCase() : slugify(name);
222
+ const result = this.db.raw.prepare(
223
+ "INSERT INTO subjects (name, slug, language, source) VALUES (?, ?, ?, ?) RETURNING id"
224
+ ).get(name, slug, language, source);
225
+ return this.getSubject(result.id);
226
+ }
227
+ listSubjects() {
228
+ return this.db.raw.prepare("SELECT * FROM subjects ORDER BY created_at DESC").all();
229
+ }
230
+ getSubject(id) {
231
+ return this.db.raw.prepare("SELECT * FROM subjects WHERE id = ?").get(id);
232
+ }
233
+ findSubjectByName(name) {
234
+ return this.db.raw.prepare("SELECT * FROM subjects WHERE lower(name) = lower(?)").get(name);
235
+ }
236
+ // ── Curriculum import ──────────────────────────────────────────────────────
237
+ importCurriculum(subjectId, phases) {
238
+ const insertPhase = this.db.raw.prepare(
239
+ "INSERT INTO phases (subject_id, name, description, sort_order) VALUES (?, ?, ?, ?) RETURNING id"
240
+ );
241
+ const insertTopic = this.db.raw.prepare(
242
+ "INSERT INTO topics (phase_id, name, description, sort_order) VALUES (?, ?, ?, ?)"
243
+ );
244
+ const run2 = this.db.raw.transaction(() => {
245
+ phases.forEach((phase, phaseIdx) => {
246
+ const phaseRow = insertPhase.get(subjectId, phase.name, phase.description, phaseIdx);
247
+ phase.topics.forEach((topic, topicIdx) => {
248
+ insertTopic.run(phaseRow.id, topic.name, topic.description, topicIdx);
249
+ });
250
+ });
251
+ });
252
+ run2();
253
+ }
254
+ getCurriculum(subjectId) {
255
+ const phases = this.db.raw.prepare(
256
+ "SELECT * FROM phases WHERE subject_id = ? ORDER BY sort_order"
257
+ ).all(subjectId);
258
+ const getTopics = this.db.raw.prepare(
259
+ "SELECT * FROM topics WHERE phase_id = ? ORDER BY sort_order"
260
+ );
261
+ return phases.map((phase) => ({
262
+ ...phase,
263
+ topics: getTopics.all(phase.id)
264
+ }));
265
+ }
266
+ // ── Progress ───────────────────────────────────────────────────────────────
267
+ getProgress(subjectId) {
268
+ const row = this.db.raw.prepare(
269
+ `WITH subject_topics AS (
270
+ SELECT t.id, t.status
271
+ FROM topics t
272
+ JOIN phases p ON p.id = t.phase_id
273
+ WHERE p.subject_id = :sid
274
+ )
275
+ SELECT
276
+ COUNT(*) AS total_topics,
277
+ SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) AS done,
278
+ SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) AS in_progress,
279
+ SUM(CASE WHEN status = 'todo' THEN 1 ELSE 0 END) AS todo,
280
+ (SELECT COUNT(*) FROM entries WHERE topic_id IN (SELECT id FROM subject_topics)) AS total_entries,
281
+ (SELECT COUNT(*) FROM exercises WHERE topic_id IN (SELECT id FROM subject_topics)) AS total_exercises,
282
+ (SELECT COUNT(*) FROM visualizations WHERE topic_id IN (SELECT id FROM subject_topics)) AS total_viz
283
+ FROM subject_topics`
284
+ ).get({ sid: subjectId });
285
+ return {
286
+ total_topics: row?.total_topics ?? 0,
287
+ done: row?.done ?? 0,
288
+ in_progress: row?.in_progress ?? 0,
289
+ todo: row?.todo ?? 0,
290
+ total_entries: row?.total_entries ?? 0,
291
+ total_exercises: row?.total_exercises ?? 0,
292
+ total_viz: row?.total_viz ?? 0
293
+ };
294
+ }
295
+ // ── Topics ─────────────────────────────────────────────────────────────────
296
+ setTopicStatus(topicId, status) {
297
+ this.db.raw.prepare(
298
+ "UPDATE topics SET status = ?, updated_at = datetime('now') WHERE id = ?"
299
+ ).run(status, topicId);
300
+ }
301
+ getTopic(id) {
302
+ return this.db.raw.prepare("SELECT * FROM topics WHERE id = ?").get(id);
303
+ }
304
+ findTopic(subjectId, name) {
305
+ return this.db.raw.prepare(
306
+ `SELECT t.* FROM topics t
307
+ JOIN phases p ON p.id = t.phase_id
308
+ WHERE p.subject_id = ? AND lower(t.name) = lower(?)
309
+ LIMIT 1`
310
+ ).get(subjectId, name);
311
+ }
312
+ };
313
+
314
+ // src/services/qa.ts
315
+ var QAService = class {
316
+ constructor(db2) {
317
+ this.db = db2;
318
+ }
319
+ logEntry(topicId, kind, content, sessionId, questionId) {
320
+ const result = this.db.raw.prepare(
321
+ "INSERT INTO entries (topic_id, kind, content, session_id, question_id) VALUES (?, ?, ?, ?, ?) RETURNING id"
322
+ ).get(topicId, kind, content, sessionId ?? "", questionId ?? null);
323
+ return this.db.raw.prepare("SELECT * FROM entries WHERE id = ?").get(result.id);
324
+ }
325
+ listEntries(topicId) {
326
+ return this.db.raw.prepare(
327
+ "SELECT * FROM entries WHERE topic_id = ? ORDER BY created_at ASC"
328
+ ).all(topicId);
329
+ }
330
+ search(query) {
331
+ return this.db.raw.prepare(
332
+ `SELECT e.id, e.topic_id, e.kind, e.content, e.created_at
333
+ FROM entries e
334
+ JOIN entries_fts ON entries_fts.rowid = e.id
335
+ WHERE entries_fts MATCH ?
336
+ ORDER BY e.created_at ASC
337
+ LIMIT 50`
338
+ ).all(query);
339
+ }
340
+ };
341
+
342
+ // src/services/viz.ts
343
+ var VizService = class {
344
+ constructor(db2) {
345
+ this.db = db2;
346
+ }
347
+ create(topicId, title, steps) {
348
+ const stepsJson = JSON.stringify(steps);
349
+ const result = this.db.raw.prepare(
350
+ "INSERT INTO visualizations (topic_id, title, steps_json) VALUES (?, ?, ?) RETURNING id"
351
+ ).get(topicId, title, stepsJson);
352
+ return this.db.raw.prepare("SELECT * FROM visualizations WHERE id = ?").get(result.id);
353
+ }
354
+ listForTopic(topicId) {
355
+ return this.db.raw.prepare(
356
+ "SELECT * FROM visualizations WHERE topic_id = ? ORDER BY created_at DESC, id DESC"
357
+ ).all(topicId);
358
+ }
359
+ };
360
+
361
+ // src/services/exercises.ts
362
+ import { execFile } from "node:child_process";
363
+ import { promisify } from "node:util";
364
+ var execFileAsync = promisify(execFile);
365
+ function slugify2(name) {
366
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
367
+ }
368
+ function extensionForLanguage(language) {
369
+ switch (language) {
370
+ case "go":
371
+ return ".go";
372
+ case "python":
373
+ return ".py";
374
+ case "rust":
375
+ return ".rs";
376
+ case "javascript":
377
+ case "typescript":
378
+ return ".ts";
379
+ default:
380
+ return ".txt";
381
+ }
382
+ }
383
+ var ExerciseService = class {
384
+ constructor(db2, fileStore2) {
385
+ this.db = db2;
386
+ this.fileStore = fileStore2;
387
+ }
388
+ createExercise(topicId, data) {
389
+ const {
390
+ title,
391
+ type,
392
+ description,
393
+ difficulty = "medium",
394
+ est_minutes = 0,
395
+ source = "ai",
396
+ starter_code = "",
397
+ test_content = "",
398
+ quiz_json = "{}"
399
+ } = data;
400
+ const result = this.db.raw.prepare(
401
+ `INSERT INTO exercises
402
+ (topic_id, title, type, description, difficulty, est_minutes, source, starter_code, test_content, quiz_json)
403
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
404
+ RETURNING id`
405
+ ).get(topicId, title, type, description, difficulty, est_minutes, source, starter_code, test_content, quiz_json);
406
+ const exerciseId = result.id;
407
+ if ((type === "coding" || type === "project") && (starter_code || test_content)) {
408
+ const subject = this.getSubjectForTopic(topicId);
409
+ if (subject) {
410
+ const exerciseSlug = slugify2(title);
411
+ const ext = extensionForLanguage(subject.language);
412
+ const files = {};
413
+ if (starter_code) {
414
+ files[`main${ext}`] = starter_code;
415
+ }
416
+ if (test_content) {
417
+ files[`main_test${ext}`] = test_content;
418
+ }
419
+ files["README.md"] = `# ${title}
420
+
421
+ ${description}`;
422
+ const filePath = this.fileStore.writeExerciseFiles(subject.slug, exerciseSlug, files);
423
+ this.db.raw.prepare("UPDATE exercises SET file_path = ? WHERE id = ?").run(filePath, exerciseId);
424
+ }
425
+ }
426
+ return this.db.raw.prepare("SELECT * FROM exercises WHERE id = ?").get(exerciseId);
427
+ }
428
+ async runTests(exerciseId) {
429
+ const exercise = this.db.raw.prepare("SELECT * FROM exercises WHERE id = ?").get(exerciseId);
430
+ if (!exercise) throw new Error(`Exercise ${exerciseId} not found`);
431
+ if (!exercise.file_path) throw new Error(`Exercise ${exerciseId} has no file_path`);
432
+ const subject = this.getSubjectForTopic(exercise.topic_id);
433
+ if (!subject) throw new Error(`No subject found for exercise ${exerciseId}`);
434
+ const commandMap = {
435
+ go: { command: "go", args: ["test", "-json", "-count=1", "./..."] },
436
+ python: { command: "python3", args: ["-m", "pytest", "--tb=short", "-q", "."] },
437
+ rust: { command: "cargo", args: ["test"] },
438
+ javascript: { command: "npx", args: ["vitest", "run"] },
439
+ typescript: { command: "npx", args: ["vitest", "run"] }
440
+ };
441
+ const config = commandMap[subject.language];
442
+ if (!config) throw new Error(`Unsupported language: ${subject.language}`);
443
+ let stdout = "";
444
+ let stderr = "";
445
+ let exitCode = 0;
446
+ try {
447
+ const result = await execFileAsync(config.command, config.args, {
448
+ cwd: exercise.file_path,
449
+ timeout: 6e4
450
+ });
451
+ stdout = result.stdout;
452
+ stderr = result.stderr;
453
+ } catch (err5) {
454
+ const execErr = err5;
455
+ stdout = execErr.stdout ?? "";
456
+ stderr = execErr.stderr ?? "";
457
+ exitCode = execErr.code ?? 1;
458
+ }
459
+ const results = [];
460
+ if (subject.language === "go") {
461
+ for (const line of stdout.split("\n")) {
462
+ if (!line.trim()) continue;
463
+ try {
464
+ const event = JSON.parse(line);
465
+ if (event.Action === "pass" && event.Test) {
466
+ results.push({ test_name: event.Test, passed: true, output: "" });
467
+ } else if (event.Action === "fail" && event.Test) {
468
+ results.push({ test_name: event.Test, passed: false, output: event.Output ?? "" });
469
+ }
470
+ } catch {
471
+ }
472
+ }
473
+ }
474
+ if (results.length === 0) {
475
+ results.push({
476
+ test_name: "all",
477
+ passed: exitCode === 0,
478
+ output: stdout + stderr
479
+ });
480
+ }
481
+ this.db.raw.prepare("DELETE FROM exercise_results WHERE exercise_id = ?").run(exerciseId);
482
+ const insertResult = this.db.raw.prepare(
483
+ "INSERT INTO exercise_results (exercise_id, test_name, passed, output) VALUES (?, ?, ?, ?)"
484
+ );
485
+ for (const r of results) {
486
+ insertResult.run(exerciseId, r.test_name, r.passed ? 1 : 0, r.output);
487
+ }
488
+ const allPassed = results.every((r) => r.passed);
489
+ this.db.raw.prepare("UPDATE exercises SET status = ? WHERE id = ?").run(allPassed ? "passed" : "failed", exerciseId);
490
+ return this.db.raw.prepare("SELECT * FROM exercise_results WHERE exercise_id = ?").all(exerciseId);
491
+ }
492
+ submitQuiz(exerciseId, answers) {
493
+ const exercise = this.db.raw.prepare("SELECT * FROM exercises WHERE id = ?").get(exerciseId);
494
+ if (!exercise) throw new Error(`Exercise ${exerciseId} not found`);
495
+ const payload = JSON.parse(exercise.quiz_json);
496
+ const questions = payload.questions;
497
+ let correct = 0;
498
+ const results = [];
499
+ for (let i = 0; i < questions.length; i++) {
500
+ const q = questions[i];
501
+ const answer = answers[i];
502
+ let isCorrect = false;
503
+ switch (q.type) {
504
+ case "multiple_choice":
505
+ isCorrect = answer === q.correct;
506
+ break;
507
+ case "true_false":
508
+ isCorrect = answer === q.correct;
509
+ break;
510
+ case "fill_in":
511
+ isCorrect = String(answer).toLowerCase().trim() === String(q.correct).toLowerCase().trim();
512
+ break;
513
+ }
514
+ if (isCorrect) correct++;
515
+ results.push({
516
+ test_name: `Q${i + 1}: ${q.text}`,
517
+ passed: isCorrect,
518
+ output: isCorrect ? "Correct" : `Wrong. Expected: ${q.correct}, Got: ${answer}`
519
+ });
520
+ }
521
+ const score = questions.length > 0 ? correct / questions.length : 0;
522
+ const passed = score >= 0.7;
523
+ this.db.raw.prepare("DELETE FROM exercise_results WHERE exercise_id = ?").run(exerciseId);
524
+ const insertResult = this.db.raw.prepare(
525
+ "INSERT INTO exercise_results (exercise_id, test_name, passed, output) VALUES (?, ?, ?, ?)"
526
+ );
527
+ for (const r of results) {
528
+ insertResult.run(exerciseId, r.test_name, r.passed ? 1 : 0, r.output);
529
+ }
530
+ this.db.raw.prepare("UPDATE exercises SET status = ? WHERE id = ?").run(passed ? "passed" : "failed", exerciseId);
531
+ return { score, total: questions.length, passed, results };
532
+ }
533
+ listForTopic(topicId) {
534
+ return this.db.raw.prepare(
535
+ "SELECT * FROM exercises WHERE topic_id = ? ORDER BY created_at ASC, id ASC"
536
+ ).all(topicId);
537
+ }
538
+ getSubjectForTopic(topicId) {
539
+ return this.db.raw.prepare(
540
+ `SELECT s.* FROM subjects s
541
+ JOIN phases p ON p.subject_id = s.id
542
+ JOIN topics t ON t.phase_id = p.id
543
+ WHERE t.id = ?`
544
+ ).get(topicId);
545
+ }
546
+ };
547
+
548
+ // src/tools/curriculum.ts
549
+ import { z } from "zod";
550
+ function getSession(sessions2, sessionId) {
551
+ const key = sessionId || "_default";
552
+ if (!sessions2.has(key)) {
553
+ sessions2.set(key, { subjectId: null, topicId: null });
554
+ }
555
+ return sessions2.get(key);
556
+ }
557
+ function err(text) {
558
+ return { content: [{ type: "text", text }], isError: true };
559
+ }
560
+ function ok(text) {
561
+ return { content: [{ type: "text", text }] };
562
+ }
563
+ function registerCurriculumTools(server2, svc, sessions2, notify2) {
564
+ server2.tool(
565
+ "learn_create_subject",
566
+ "Create a new subject to study",
567
+ {
568
+ name: z.string().describe("Subject name"),
569
+ language: z.string().optional().describe("Programming language (optional)"),
570
+ source: z.enum(["manual", "roadmap", "pdf"]).optional().describe("Curriculum source")
571
+ },
572
+ async ({ name, language, source }) => {
573
+ const subject = svc.createSubject(name, language, source);
574
+ notify2();
575
+ return ok(`Created subject "${subject.name}" (id=${subject.id}, slug=${subject.slug})`);
576
+ }
577
+ );
578
+ server2.tool(
579
+ "learn_import_curriculum",
580
+ "Import a curriculum (phases + topics) for a subject from JSON",
581
+ {
582
+ subject_id: z.number().describe("Subject ID to import curriculum into"),
583
+ phases_json: z.string().describe("JSON array of phases, each with name, description, and topics array")
584
+ },
585
+ async ({ subject_id, phases_json }) => {
586
+ let phases;
587
+ try {
588
+ phases = JSON.parse(phases_json);
589
+ } catch {
590
+ return err("Invalid JSON in phases_json");
591
+ }
592
+ if (!Array.isArray(phases)) {
593
+ return err("phases_json must be a JSON array");
594
+ }
595
+ svc.importCurriculum(subject_id, phases);
596
+ notify2();
597
+ return ok(`Imported ${phases.length} phase(s) into subject id=${subject_id}`);
598
+ }
599
+ );
600
+ server2.tool(
601
+ "learn_switch_subject",
602
+ "Switch the active subject for the session (by name or numeric ID)",
603
+ {
604
+ subject: z.string().describe("Subject name or numeric ID"),
605
+ session_id: z.string().optional().describe("Session identifier (defaults to _default)")
606
+ },
607
+ async ({ subject, session_id }) => {
608
+ const numId = Number(subject);
609
+ let resolved = isNaN(numId) ? svc.findSubjectByName(subject) : svc.getSubject(numId) ?? svc.findSubjectByName(subject);
610
+ if (!resolved) {
611
+ return err(`Subject not found: "${subject}"`);
612
+ }
613
+ const session = getSession(sessions2, session_id);
614
+ session.subjectId = resolved.id;
615
+ session.topicId = null;
616
+ return ok(`Active subject: "${resolved.name}" (id=${resolved.id})`);
617
+ }
618
+ );
619
+ server2.tool(
620
+ "learn_set_topic",
621
+ "Set the active topic for the session and mark it in_progress",
622
+ {
623
+ topic: z.string().describe("Topic name or numeric ID"),
624
+ session_id: z.string().optional().describe("Session identifier (defaults to _default)")
625
+ },
626
+ async ({ topic, session_id }) => {
627
+ const session = getSession(sessions2, session_id);
628
+ if (session.subjectId === null) {
629
+ return err("No active subject. Use learn_switch_subject first.");
630
+ }
631
+ const numId = Number(topic);
632
+ let resolved = isNaN(numId) ? svc.findTopic(session.subjectId, topic) : svc.getTopic(numId) ?? svc.findTopic(session.subjectId, topic);
633
+ if (!resolved) {
634
+ return err(`Topic not found: "${topic}"`);
635
+ }
636
+ svc.setTopicStatus(resolved.id, "in_progress");
637
+ session.topicId = resolved.id;
638
+ notify2();
639
+ return ok(`Active topic: "${resolved.name}" (id=${resolved.id}, status=in_progress)`);
640
+ }
641
+ );
642
+ server2.tool(
643
+ "learn_mark_done",
644
+ "Mark a topic as done (defaults to the active session topic)",
645
+ {
646
+ topic: z.string().optional().describe("Topic name or numeric ID (uses session topic if omitted)"),
647
+ session_id: z.string().optional().describe("Session identifier (defaults to _default)")
648
+ },
649
+ async ({ topic, session_id }) => {
650
+ const session = getSession(sessions2, session_id);
651
+ let topicId = null;
652
+ if (topic !== void 0) {
653
+ const numId = Number(topic);
654
+ const resolved2 = isNaN(numId) ? session.subjectId !== null ? svc.findTopic(session.subjectId, topic) : void 0 : svc.getTopic(numId);
655
+ if (!resolved2) {
656
+ return err(`Topic not found: "${topic}"`);
657
+ }
658
+ topicId = resolved2.id;
659
+ } else {
660
+ topicId = session.topicId;
661
+ }
662
+ if (topicId === null) {
663
+ return err("No topic specified and no active topic in session.");
664
+ }
665
+ const resolved = svc.getTopic(topicId);
666
+ if (!resolved) {
667
+ return err(`Topic id=${topicId} not found.`);
668
+ }
669
+ svc.setTopicStatus(topicId, "done");
670
+ notify2();
671
+ return ok(`Topic "${resolved.name}" marked as done.`);
672
+ }
673
+ );
674
+ server2.tool(
675
+ "learn_get_progress",
676
+ "Get progress statistics for the active subject",
677
+ {
678
+ session_id: z.string().optional().describe("Session identifier (defaults to _default)")
679
+ },
680
+ async ({ session_id }) => {
681
+ const session = getSession(sessions2, session_id);
682
+ if (session.subjectId === null) {
683
+ return err("No active subject. Use learn_switch_subject first.");
684
+ }
685
+ const progress = svc.getProgress(session.subjectId);
686
+ return ok(JSON.stringify(progress, null, 2));
687
+ }
688
+ );
689
+ server2.tool(
690
+ "learn_get_curriculum",
691
+ "Get the full curriculum (phases + topics) for the active subject",
692
+ {
693
+ session_id: z.string().optional().describe("Session identifier (defaults to _default)")
694
+ },
695
+ async ({ session_id }) => {
696
+ const session = getSession(sessions2, session_id);
697
+ if (session.subjectId === null) {
698
+ return err("No active subject. Use learn_switch_subject first.");
699
+ }
700
+ const curriculum = svc.getCurriculum(session.subjectId);
701
+ return ok(JSON.stringify(curriculum, null, 2));
702
+ }
703
+ );
704
+ }
705
+
706
+ // src/tools/qa.ts
707
+ import { z as z2 } from "zod";
708
+ function getSession2(sessions2, sessionId) {
709
+ const key = sessionId || "_default";
710
+ if (!sessions2.has(key)) {
711
+ sessions2.set(key, { subjectId: null, topicId: null });
712
+ }
713
+ return sessions2.get(key);
714
+ }
715
+ function err2(text) {
716
+ return { content: [{ type: "text", text }], isError: true };
717
+ }
718
+ function ok2(text) {
719
+ return { content: [{ type: "text", text }] };
720
+ }
721
+ function registerQATools(server2, svc, sessions2, notify2) {
722
+ server2.tool(
723
+ "learn_log_question",
724
+ "Log a question for the active topic",
725
+ {
726
+ content: z2.string().describe("The question text"),
727
+ session_id: z2.string().optional().describe("Session identifier (defaults to _default)")
728
+ },
729
+ async ({ content, session_id }) => {
730
+ const session = getSession2(sessions2, session_id);
731
+ if (session.topicId === null) {
732
+ return err2("No active topic. Use learn_set_topic first.");
733
+ }
734
+ const entry = svc.logEntry(session.topicId, "question", content, session_id);
735
+ notify2();
736
+ return ok2(`Logged question (id=${entry.id})`);
737
+ }
738
+ );
739
+ server2.tool(
740
+ "learn_log_answer",
741
+ "Log an answer or note for the active topic",
742
+ {
743
+ content: z2.string().describe("The answer or note text"),
744
+ question_id: z2.number().optional().describe("ID of the question this answers (optional)"),
745
+ kind: z2.enum(["answer", "note"]).optional().describe("Entry kind: answer or note (defaults to answer)"),
746
+ session_id: z2.string().optional().describe("Session identifier (defaults to _default)")
747
+ },
748
+ async ({ content, question_id, kind, session_id }) => {
749
+ const session = getSession2(sessions2, session_id);
750
+ if (session.topicId === null) {
751
+ return err2("No active topic. Use learn_set_topic first.");
752
+ }
753
+ const entryKind = kind ?? "answer";
754
+ const entry = svc.logEntry(session.topicId, entryKind, content, session_id, question_id);
755
+ notify2();
756
+ return ok2(`Logged ${entryKind} (id=${entry.id})`);
757
+ }
758
+ );
759
+ server2.tool(
760
+ "learn_search",
761
+ "Full-text search across all entries",
762
+ {
763
+ query: z2.string().describe("Search query")
764
+ },
765
+ async ({ query }) => {
766
+ const results = svc.search(query);
767
+ if (results.length === 0) {
768
+ return ok2("No results found.");
769
+ }
770
+ return ok2(JSON.stringify(results, null, 2));
771
+ }
772
+ );
773
+ }
774
+
775
+ // src/tools/viz.ts
776
+ import { z as z3 } from "zod";
777
+ function getSession3(sessions2, sessionId) {
778
+ const key = sessionId || "_default";
779
+ if (!sessions2.has(key)) {
780
+ sessions2.set(key, { subjectId: null, topicId: null });
781
+ }
782
+ return sessions2.get(key);
783
+ }
784
+ function err3(text) {
785
+ return { content: [{ type: "text", text }], isError: true };
786
+ }
787
+ function ok3(text) {
788
+ return { content: [{ type: "text", text }] };
789
+ }
790
+ function registerVizTools(server2, svc, sessions2, notify2) {
791
+ server2.tool(
792
+ "learn_create_viz",
793
+ "Create a step-by-step HTML visualization for the active topic",
794
+ {
795
+ title: z3.string().describe("Title for the visualization"),
796
+ steps: z3.string().describe("JSON array of steps, each with { html: string, description: string }"),
797
+ session_id: z3.string().optional().describe("Session identifier (defaults to _default)")
798
+ },
799
+ async ({ title, steps, session_id }) => {
800
+ const session = getSession3(sessions2, session_id);
801
+ if (session.topicId === null) {
802
+ return err3("No active topic. Use learn_set_topic first.");
803
+ }
804
+ let parsedSteps;
805
+ try {
806
+ parsedSteps = JSON.parse(steps);
807
+ } catch {
808
+ return err3("Invalid JSON in steps parameter");
809
+ }
810
+ if (!Array.isArray(parsedSteps)) {
811
+ return err3("steps must be a JSON array");
812
+ }
813
+ const viz = svc.create(session.topicId, title, parsedSteps);
814
+ notify2();
815
+ return ok3(`Created visualization "${viz.title}" (id=${viz.id})`);
816
+ }
817
+ );
818
+ server2.tool(
819
+ "learn_get_viz",
820
+ "Get all visualizations for the active topic",
821
+ {
822
+ session_id: z3.string().optional().describe("Session identifier (defaults to _default)")
823
+ },
824
+ async ({ session_id }) => {
825
+ const session = getSession3(sessions2, session_id);
826
+ if (session.topicId === null) {
827
+ return err3("No active topic. Use learn_set_topic first.");
828
+ }
829
+ const vizList = svc.listForTopic(session.topicId);
830
+ return ok3(JSON.stringify(vizList, null, 2));
831
+ }
832
+ );
833
+ }
834
+
835
+ // src/tools/exercises.ts
836
+ import { z as z4 } from "zod";
837
+ function getSession4(sessions2, sessionId) {
838
+ const key = sessionId || "_default";
839
+ if (!sessions2.has(key)) {
840
+ sessions2.set(key, { subjectId: null, topicId: null });
841
+ }
842
+ return sessions2.get(key);
843
+ }
844
+ function err4(text) {
845
+ return { content: [{ type: "text", text }], isError: true };
846
+ }
847
+ function ok4(text) {
848
+ return { content: [{ type: "text", text }] };
849
+ }
850
+ function registerExerciseTools(server2, svc, sessions2, notify2) {
851
+ server2.tool(
852
+ "learn_create_exercise",
853
+ "Create an exercise (coding, quiz, project, assignment) for the active topic",
854
+ {
855
+ title: z4.string().describe("Exercise title"),
856
+ type: z4.enum(["coding", "quiz", "project", "assignment"]).describe("Exercise type"),
857
+ description: z4.string().describe("Exercise description / instructions"),
858
+ difficulty: z4.enum(["easy", "medium", "hard"]).optional().describe("Difficulty level"),
859
+ est_minutes: z4.number().optional().describe("Estimated time in minutes"),
860
+ source: z4.enum(["ai", "pdf_import"]).optional().describe("Source of the exercise"),
861
+ starter_code: z4.string().optional().describe("Starter code for coding/project exercises"),
862
+ test_content: z4.string().optional().describe("Test code for coding/project exercises"),
863
+ quiz_json: z4.string().optional().describe("JSON string of QuizPayload for quiz exercises"),
864
+ session_id: z4.string().optional().describe("Session identifier (defaults to _default)")
865
+ },
866
+ async ({ title, type, description, difficulty, est_minutes, source, starter_code, test_content, quiz_json, session_id }) => {
867
+ const session = getSession4(sessions2, session_id);
868
+ if (session.topicId === null) {
869
+ return err4("No active topic. Use learn_set_topic first.");
870
+ }
871
+ const exercise = svc.createExercise(session.topicId, {
872
+ title,
873
+ type,
874
+ description,
875
+ difficulty,
876
+ est_minutes,
877
+ source,
878
+ starter_code,
879
+ test_content,
880
+ quiz_json
881
+ });
882
+ notify2();
883
+ return ok4(`Created exercise "${exercise.title}" (id=${exercise.id}, type=${exercise.type})`);
884
+ }
885
+ );
886
+ server2.tool(
887
+ "learn_run_tests",
888
+ "Run tests for a coding/project exercise and return results",
889
+ {
890
+ exercise_id: z4.number().describe("ID of the exercise to run tests for")
891
+ },
892
+ async ({ exercise_id }) => {
893
+ try {
894
+ const results = await svc.runTests(exercise_id);
895
+ notify2();
896
+ return ok4(JSON.stringify(results, null, 2));
897
+ } catch (error) {
898
+ const msg = error instanceof Error ? error.message : String(error);
899
+ return err4(`Failed to run tests: ${msg}`);
900
+ }
901
+ }
902
+ );
903
+ server2.tool(
904
+ "learn_get_exercises",
905
+ "List all exercises for the active topic",
906
+ {
907
+ session_id: z4.string().optional().describe("Session identifier (defaults to _default)")
908
+ },
909
+ async ({ session_id }) => {
910
+ const session = getSession4(sessions2, session_id);
911
+ if (session.topicId === null) {
912
+ return err4("No active topic. Use learn_set_topic first.");
913
+ }
914
+ const exercises = svc.listForTopic(session.topicId);
915
+ return ok4(JSON.stringify(exercises, null, 2));
916
+ }
917
+ );
918
+ }
919
+
920
+ // src/dashboard/server.ts
921
+ import http from "node:http";
922
+ import { readFileSync as readFileSync2 } from "node:fs";
923
+ import { fileURLToPath } from "node:url";
924
+ import { dirname, join as join2 } from "node:path";
925
+
926
+ // src/dashboard/api.ts
927
+ function writeJSON(res, data, status = 200) {
928
+ res.writeHead(status, { "Content-Type": "application/json" });
929
+ res.end(JSON.stringify(data));
930
+ }
931
+ function writeError(res, status, message) {
932
+ writeJSON(res, { error: message }, status);
933
+ }
934
+ function parseBody(req) {
935
+ return new Promise((resolve, reject) => {
936
+ const chunks = [];
937
+ req.on("data", (chunk) => chunks.push(chunk));
938
+ req.on("end", () => {
939
+ try {
940
+ resolve(JSON.parse(Buffer.concat(chunks).toString()));
941
+ } catch (err5) {
942
+ reject(new Error("Invalid JSON body"));
943
+ }
944
+ });
945
+ req.on("error", reject);
946
+ });
947
+ }
948
+ function extractId(url, prefix) {
949
+ if (!url.startsWith(prefix)) return null;
950
+ const rest = url.slice(prefix.length);
951
+ const segment = rest.split("/")[0];
952
+ const num = Number(segment);
953
+ return Number.isFinite(num) && num > 0 ? num : null;
954
+ }
955
+ function handleSubjects(curriculumSvc2) {
956
+ return (_req, res) => {
957
+ const subjects = curriculumSvc2.listSubjects();
958
+ const result = subjects.map((s) => ({
959
+ ...s,
960
+ progress: curriculumSvc2.getProgress(s.id)
961
+ }));
962
+ writeJSON(res, result);
963
+ };
964
+ }
965
+ function handlePhases(curriculumSvc2) {
966
+ return (req, res) => {
967
+ const id = extractId(req.url ?? "", "/api/subjects/");
968
+ if (id === null) {
969
+ writeError(res, 400, "Invalid subject ID");
970
+ return;
971
+ }
972
+ const phases = curriculumSvc2.getCurriculum(id);
973
+ writeJSON(res, phases);
974
+ };
975
+ }
976
+ function handleTopic(curriculumSvc2, qaSvc2) {
977
+ return (req, res) => {
978
+ const id = extractId(req.url ?? "", "/api/topics/");
979
+ if (id === null) {
980
+ writeError(res, 400, "Invalid topic ID");
981
+ return;
982
+ }
983
+ const topic = curriculumSvc2.getTopic(id);
984
+ if (!topic) {
985
+ writeError(res, 404, "Topic not found");
986
+ return;
987
+ }
988
+ const entries = qaSvc2.listEntries(id);
989
+ writeJSON(res, { ...topic, entries });
990
+ };
991
+ }
992
+ function handleTopicViz(vizSvc2) {
993
+ return (req, res) => {
994
+ const id = extractId(req.url ?? "", "/api/topics/");
995
+ if (id === null) {
996
+ writeError(res, 400, "Invalid topic ID");
997
+ return;
998
+ }
999
+ const vizList = vizSvc2.listForTopic(id);
1000
+ writeJSON(res, vizList);
1001
+ };
1002
+ }
1003
+ function handleTopicExercises(exerciseSvc2) {
1004
+ return (req, res) => {
1005
+ const id = extractId(req.url ?? "", "/api/topics/");
1006
+ if (id === null) {
1007
+ writeError(res, 400, "Invalid topic ID");
1008
+ return;
1009
+ }
1010
+ const exercises = exerciseSvc2.listForTopic(id);
1011
+ writeJSON(res, exercises);
1012
+ };
1013
+ }
1014
+ function handleRunTests(exerciseSvc2) {
1015
+ return async (req, res) => {
1016
+ const id = extractId(req.url ?? "", "/api/exercises/");
1017
+ if (id === null) {
1018
+ writeError(res, 400, "Invalid exercise ID");
1019
+ return;
1020
+ }
1021
+ try {
1022
+ const results = await exerciseSvc2.runTests(id);
1023
+ writeJSON(res, results);
1024
+ } catch (err5) {
1025
+ const msg = err5 instanceof Error ? err5.message : String(err5);
1026
+ writeError(res, 500, msg);
1027
+ }
1028
+ };
1029
+ }
1030
+ function handleSubmitQuiz(exerciseSvc2) {
1031
+ return async (req, res) => {
1032
+ const id = extractId(req.url ?? "", "/api/exercises/");
1033
+ if (id === null) {
1034
+ writeError(res, 400, "Invalid exercise ID");
1035
+ return;
1036
+ }
1037
+ try {
1038
+ const body = await parseBody(req);
1039
+ if (!Array.isArray(body?.answers)) {
1040
+ writeError(res, 400, 'Request body must have an "answers" array');
1041
+ return;
1042
+ }
1043
+ const result = exerciseSvc2.submitQuiz(id, body.answers);
1044
+ writeJSON(res, result);
1045
+ } catch (err5) {
1046
+ const msg = err5 instanceof Error ? err5.message : String(err5);
1047
+ writeError(res, 500, msg);
1048
+ }
1049
+ };
1050
+ }
1051
+ function handleSearch(qaSvc2) {
1052
+ return (req, res) => {
1053
+ const url = new URL(req.url ?? "", "http://localhost");
1054
+ const query = url.searchParams.get("q") ?? "";
1055
+ if (!query) {
1056
+ writeJSON(res, []);
1057
+ return;
1058
+ }
1059
+ try {
1060
+ const results = qaSvc2.search(query);
1061
+ writeJSON(res, results);
1062
+ } catch (err5) {
1063
+ const msg = err5 instanceof Error ? err5.message : String(err5);
1064
+ writeError(res, 500, msg);
1065
+ }
1066
+ };
1067
+ }
1068
+
1069
+ // src/dashboard/server.ts
1070
+ var __filename = fileURLToPath(import.meta.url);
1071
+ var __dirname = dirname(__filename);
1072
+ var staticDir = join2(__dirname, "static");
1073
+ function loadStatic(name) {
1074
+ try {
1075
+ return readFileSync2(join2(staticDir, name), "utf-8");
1076
+ } catch {
1077
+ return "";
1078
+ }
1079
+ }
1080
+ var indexHtml = loadStatic("index.html");
1081
+ var appJs = loadStatic("app.js");
1082
+ var stylesCss = loadStatic("styles.css");
1083
+ var STATIC_FILES = {
1084
+ "/": { content: indexHtml, contentType: "text/html; charset=utf-8" },
1085
+ "/index.html": { content: indexHtml, contentType: "text/html; charset=utf-8" },
1086
+ "/app.js": { content: appJs, contentType: "application/javascript; charset=utf-8" },
1087
+ "/styles.css": { content: stylesCss, contentType: "text/css; charset=utf-8" }
1088
+ };
1089
+ var DashboardServer = class {
1090
+ constructor(curriculumSvc2, qaSvc2, vizSvc2, exerciseSvc2, port2) {
1091
+ this.curriculumSvc = curriculumSvc2;
1092
+ this.qaSvc = qaSvc2;
1093
+ this.vizSvc = vizSvc2;
1094
+ this.exerciseSvc = exerciseSvc2;
1095
+ this.port = port2;
1096
+ }
1097
+ sseClients = /* @__PURE__ */ new Set();
1098
+ httpServer = null;
1099
+ start() {
1100
+ this.httpServer = http.createServer((req, res) => this.handleRequest(req, res));
1101
+ this.httpServer.listen(this.port, "127.0.0.1", () => {
1102
+ console.error(`Dashboard running at http://127.0.0.1:${this.port}`);
1103
+ });
1104
+ }
1105
+ stop() {
1106
+ if (this.httpServer) {
1107
+ this.httpServer.close();
1108
+ this.httpServer = null;
1109
+ }
1110
+ }
1111
+ notify() {
1112
+ const data = JSON.stringify({ type: "update", ts: (/* @__PURE__ */ new Date()).toISOString() });
1113
+ const message = `data: ${data}
1114
+
1115
+ `;
1116
+ for (const client of this.sseClients) {
1117
+ client.write(message);
1118
+ }
1119
+ }
1120
+ // ── Request routing ────────────────────────────────────────────────────
1121
+ handleRequest(req, res) {
1122
+ const url = req.url ?? "/";
1123
+ const method = req.method ?? "GET";
1124
+ if (method === "POST") {
1125
+ const origin = req.headers.origin ?? "";
1126
+ if (origin && !origin.startsWith("http://localhost") && !origin.startsWith("http://127.0.0.1")) {
1127
+ res.writeHead(403, { "Content-Type": "application/json" });
1128
+ res.end(JSON.stringify({ error: "Forbidden: invalid origin" }));
1129
+ return;
1130
+ }
1131
+ }
1132
+ if (url === "/api/events" && method === "GET") {
1133
+ this.handleSSE(req, res);
1134
+ return;
1135
+ }
1136
+ if (url.startsWith("/api/")) {
1137
+ this.routeAPI(method, url, req, res);
1138
+ return;
1139
+ }
1140
+ this.serveStatic(url, res);
1141
+ }
1142
+ // ── SSE ────────────────────────────────────────────────────────────────
1143
+ handleSSE(_req, res) {
1144
+ res.writeHead(200, {
1145
+ "Content-Type": "text/event-stream",
1146
+ "Cache-Control": "no-cache",
1147
+ "Connection": "keep-alive",
1148
+ "Access-Control-Allow-Origin": "*"
1149
+ });
1150
+ const connected = JSON.stringify({ type: "connected", ts: (/* @__PURE__ */ new Date()).toISOString() });
1151
+ res.write(`data: ${connected}
1152
+
1153
+ `);
1154
+ this.sseClients.add(res);
1155
+ res.on("close", () => {
1156
+ this.sseClients.delete(res);
1157
+ });
1158
+ }
1159
+ // ── API router ─────────────────────────────────────────────────────────
1160
+ routeAPI(method, url, req, res) {
1161
+ const path = url.split("?")[0];
1162
+ if (method === "GET" && path === "/api/subjects") {
1163
+ handleSubjects(this.curriculumSvc)(req, res);
1164
+ return;
1165
+ }
1166
+ if (method === "GET" && /^\/api\/subjects\/\d+\/phases$/.test(path)) {
1167
+ handlePhases(this.curriculumSvc)(req, res);
1168
+ return;
1169
+ }
1170
+ if (method === "GET" && /^\/api\/topics\/\d+\/viz$/.test(path)) {
1171
+ handleTopicViz(this.vizSvc)(req, res);
1172
+ return;
1173
+ }
1174
+ if (method === "GET" && /^\/api\/topics\/\d+\/exercises$/.test(path)) {
1175
+ handleTopicExercises(this.exerciseSvc)(req, res);
1176
+ return;
1177
+ }
1178
+ if (method === "GET" && /^\/api\/topics\/\d+$/.test(path)) {
1179
+ handleTopic(this.curriculumSvc, this.qaSvc)(req, res);
1180
+ return;
1181
+ }
1182
+ if (method === "POST" && /^\/api\/exercises\/\d+\/run$/.test(path)) {
1183
+ handleRunTests(this.exerciseSvc)(req, res);
1184
+ return;
1185
+ }
1186
+ if (method === "POST" && /^\/api\/exercises\/\d+\/submit$/.test(path)) {
1187
+ handleSubmitQuiz(this.exerciseSvc)(req, res);
1188
+ return;
1189
+ }
1190
+ if (method === "GET" && path === "/api/search") {
1191
+ handleSearch(this.qaSvc)(req, res);
1192
+ return;
1193
+ }
1194
+ writeJSON(res, { error: "Not found" }, 404);
1195
+ }
1196
+ // ── Static file serving ────────────────────────────────────────────────
1197
+ serveStatic(url, res) {
1198
+ const file = STATIC_FILES[url];
1199
+ if (file) {
1200
+ res.writeHead(200, { "Content-Type": file.contentType });
1201
+ res.end(file.content);
1202
+ return;
1203
+ }
1204
+ const index = STATIC_FILES["/"];
1205
+ if (index) {
1206
+ res.writeHead(200, { "Content-Type": index.contentType });
1207
+ res.end(index.content);
1208
+ return;
1209
+ }
1210
+ res.writeHead(404, { "Content-Type": "text/plain" });
1211
+ res.end("Not found");
1212
+ }
1213
+ };
1214
+
1215
+ // src/index.ts
1216
+ var fileStore = new FileStore();
1217
+ var db = new Database(fileStore.dbPath);
1218
+ var sessions = /* @__PURE__ */ new Map();
1219
+ var curriculumSvc = new CurriculumService(db);
1220
+ var qaSvc = new QAService(db);
1221
+ var vizSvc = new VizService(db);
1222
+ var exerciseSvc = new ExerciseService(db, fileStore);
1223
+ var port = Number(db.getSetting("dashboard_port") ?? "19282");
1224
+ var dashboard = new DashboardServer(curriculumSvc, qaSvc, vizSvc, exerciseSvc, port);
1225
+ var notify = () => dashboard.notify();
1226
+ var server = new McpServer({ name: "study-dash", version: "0.1.0" });
1227
+ registerCurriculumTools(server, curriculumSvc, sessions, notify);
1228
+ registerQATools(server, qaSvc, sessions, notify);
1229
+ registerVizTools(server, vizSvc, sessions, notify);
1230
+ registerExerciseTools(server, exerciseSvc, sessions, notify);
1231
+ dashboard.start();
1232
+ async function run() {
1233
+ const transport = new StdioServerTransport();
1234
+ await server.connect(transport);
1235
+ console.error(`study-dash MCP server running, dashboard at http://127.0.0.1:${port}`);
1236
+ }
1237
+ run().catch((err5) => {
1238
+ console.error("Fatal:", err5);
1239
+ process.exit(1);
1240
+ });