dsh-plugin-lookatstudy 0.8.1

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.
package/lib/index.mjs ADDED
@@ -0,0 +1,4447 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { basename, dirname, join, relative, sep } from "node:path";
3
+ import z from "@deepseek-ai/schemastery";
4
+ import { homedir } from "node:os";
5
+ import { randomBytes } from "node:crypto";
6
+ import { defineTool } from "@deepseek-ai/dsh-tools";
7
+ import { readFile, readdir } from "node:fs/promises";
8
+ import https from "node:https";
9
+ //#region src/config.ts
10
+ /**
11
+ * Plugin configuration schema (Schemastery) for dsh-plugin-lookatstudy.
12
+ * @module dsh-plugin-lookatstudy/config
13
+ */
14
+ /** Schemastery configuration validated at plugin load. */
15
+ const Config = z.object({
16
+ mode: z.union([
17
+ "direct",
18
+ "guide",
19
+ "practice"
20
+ ]).default("guide"),
21
+ statePath: z.string().default("")
22
+ });
23
+ //#endregion
24
+ //#region src/markdown.ts
25
+ /**
26
+ * Server-side markdown → HTML for the study workbench's 讲解 view. Escapes
27
+ * every HTML character first, then renders a pragmatic GFM subset (headings,
28
+ * fenced code, inline code, bold/italic, links, lists, blockquotes, tables,
29
+ * hr, paragraphs). Lesson bodies are imported teaching material, so raw HTML
30
+ * never passes through.
31
+ * @module dsh-plugin-lookatstudy/markdown
32
+ */
33
+ /** Escape all HTML-significant characters. */
34
+ function escapeHtml(text) {
35
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
36
+ }
37
+ /** Render inline markup (code, bold, italic, links) over escaped text. */
38
+ function inline(escaped) {
39
+ return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noreferrer\">$1</a>");
40
+ }
41
+ /** True when the line opens a GFM table row (pipes with a delimiter row next). */
42
+ function isTableRow(line) {
43
+ return line.trim().startsWith("|") && line.trim().endsWith("|") && line.includes("|", 1);
44
+ }
45
+ function isDelimiterRow(line) {
46
+ const trimmed = line.trim();
47
+ if (!trimmed.startsWith("|") || !trimmed.endsWith("|")) return false;
48
+ const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
49
+ return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c));
50
+ }
51
+ /** Split a table row into trimmed cells (leading/trailing pipes removed). */
52
+ function rowCells(line) {
53
+ return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
54
+ }
55
+ /**
56
+ * Render markdown text to sanitized HTML.
57
+ * @param md - markdown source (lesson body).
58
+ * @returns HTML string safe to inject into the page.
59
+ */
60
+ function renderMarkdown(md) {
61
+ const lines = escapeHtml(md).split(/\r?\n/);
62
+ const out = [];
63
+ let i = 0;
64
+ const flushParagraph = (buffer) => {
65
+ if (buffer.length > 0) out.push(`<p>${inline(buffer.join(" "))}</p>`);
66
+ };
67
+ let paragraph = [];
68
+ while (i < lines.length) {
69
+ const line = lines[i];
70
+ const fence = /^```(\w*)\s*$/.exec(line.trim());
71
+ if (fence) {
72
+ flushParagraph(paragraph);
73
+ paragraph = [];
74
+ const lang = fence[1] ?? "";
75
+ const code = [];
76
+ i++;
77
+ while (i < lines.length && lines[i].trim() !== "```") {
78
+ code.push(lines[i]);
79
+ i++;
80
+ }
81
+ i++;
82
+ out.push(`<pre><code${lang === "" ? "" : ` class="lang-${lang}"`}>${code.join("\n")}</code></pre>`);
83
+ continue;
84
+ }
85
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
86
+ if (heading) {
87
+ flushParagraph(paragraph);
88
+ paragraph = [];
89
+ const level = heading[1].length;
90
+ out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
91
+ i++;
92
+ continue;
93
+ }
94
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
95
+ flushParagraph(paragraph);
96
+ paragraph = [];
97
+ out.push("<hr>");
98
+ i++;
99
+ continue;
100
+ }
101
+ if (/^\s*&gt;\s?/.test(line)) {
102
+ flushParagraph(paragraph);
103
+ paragraph = [];
104
+ const quote = [];
105
+ while (i < lines.length && /^\s*&gt;\s?/.test(lines[i])) {
106
+ quote.push(lines[i].replace(/^\s*&gt;\s?/, ""));
107
+ i++;
108
+ }
109
+ out.push(`<blockquote><p>${inline(quote.join(" "))}</p></blockquote>`);
110
+ continue;
111
+ }
112
+ if (/^\s*[-*+]\s+/.test(line)) {
113
+ flushParagraph(paragraph);
114
+ paragraph = [];
115
+ const items = [];
116
+ while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
117
+ items.push(`<li>${inline(lines[i].replace(/^\s*[-*+]\s+/, ""))}</li>`);
118
+ i++;
119
+ }
120
+ out.push(`<ul>${items.join("")}</ul>`);
121
+ continue;
122
+ }
123
+ if (/^\s*\d+\.\s+/.test(line)) {
124
+ flushParagraph(paragraph);
125
+ paragraph = [];
126
+ const items = [];
127
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
128
+ items.push(`<li>${inline(lines[i].replace(/^\s*\d+\.\s+/, ""))}</li>`);
129
+ i++;
130
+ }
131
+ out.push(`<ol>${items.join("")}</ol>`);
132
+ continue;
133
+ }
134
+ if (isTableRow(line) && i + 1 < lines.length && isDelimiterRow(lines[i + 1])) {
135
+ flushParagraph(paragraph);
136
+ paragraph = [];
137
+ const headers = rowCells(line);
138
+ i += 2;
139
+ const rows = [];
140
+ while (i < lines.length && isTableRow(lines[i])) {
141
+ const cells = rowCells(lines[i]);
142
+ rows.push(`<tr>${cells.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`);
143
+ i++;
144
+ }
145
+ out.push(`<table><thead><tr>${headers.map((h) => `<th>${inline(h)}</th>`).join("")}</tr></thead><tbody>${rows.join("")}</tbody></table>`);
146
+ continue;
147
+ }
148
+ if (line.trim() === "") {
149
+ flushParagraph(paragraph);
150
+ paragraph = [];
151
+ i++;
152
+ continue;
153
+ }
154
+ paragraph.push(line.trim());
155
+ i++;
156
+ }
157
+ flushParagraph(paragraph);
158
+ return out.join("\n");
159
+ }
160
+ //#endregion
161
+ //#region src/vendor/sm2.ts
162
+ function computeSm2(prev, quality, now = /* @__PURE__ */ new Date()) {
163
+ let { easeFactor, intervalDays, repetitions } = prev;
164
+ if (quality < 3) {
165
+ repetitions = 0;
166
+ intervalDays = 1;
167
+ } else {
168
+ repetitions += 1;
169
+ if (repetitions === 1) intervalDays = 1;
170
+ else if (repetitions === 2) intervalDays = 6;
171
+ else intervalDays = Math.round(intervalDays * easeFactor);
172
+ }
173
+ const q = quality;
174
+ const delta = .1 - (5 - q) * (.08 + (5 - q) * .02);
175
+ easeFactor = Math.max(1.3, Math.min(3, easeFactor + delta));
176
+ const dueAt = new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1e3);
177
+ return {
178
+ easeFactor,
179
+ intervalDays,
180
+ repetitions,
181
+ dueAt: dueAt.toISOString()
182
+ };
183
+ }
184
+ //#endregion
185
+ //#region src/vendor/bkt.ts
186
+ /** 文献默认参数(ROADMAP R1:先验用文献默认,数据多后再调) */
187
+ const BKT_DEFAULTS = {
188
+ pInit: .5,
189
+ pTransit: .1,
190
+ pSlip: .1,
191
+ pGuess: .2
192
+ };
193
+ const clamp01 = (x) => Math.max(0, Math.min(1, x));
194
+ /**
195
+ * 单次观测后的掌握度更新。
196
+ *
197
+ * @param prev 更新前的 P(L)。null/undefined → 用 params.pInit
198
+ * @param correct 这次观测是否答对
199
+ * @param params BKT 四参数(默认文献值)
200
+ * @returns 新的 P(L),已 clamp 到 [0,1]
201
+ */
202
+ function updateMastery(prev, correct, params = BKT_DEFAULTS) {
203
+ const pL = clamp01(prev ?? params.pInit);
204
+ const { pTransit, pSlip, pGuess } = params;
205
+ const pObsGivenL = correct ? 1 - pSlip : pSlip;
206
+ const pObsGivenNotL = correct ? pGuess : 1 - pGuess;
207
+ const pObs = pObsGivenL * pL + pObsGivenNotL * (1 - pL);
208
+ if (pObs === 0) return pL;
209
+ const pLGivenObs = pObsGivenL * pL / pObs;
210
+ return clamp01(pLGivenObs + pTransit * (1 - pLGivenObs));
211
+ }
212
+ /**
213
+ * 把 mastery 概率映射成 crown level(1-5)给 UI 用。
214
+ * < 0.3 → 1, <0.5 → 2, <0.7 → 3, <0.9 → 4, ≥0.9 → 5。null → 0。
215
+ */
216
+ function masteryToCrown(mastery) {
217
+ if (mastery == null) return 0;
218
+ if (mastery < .3) return 1;
219
+ if (mastery < .5) return 2;
220
+ if (mastery < .7) return 3;
221
+ if (mastery < .9) return 4;
222
+ return 5;
223
+ }
224
+ //#endregion
225
+ //#region src/state.ts
226
+ /**
227
+ * Learning state: courses → sections → lessons with mastery-driven gating,
228
+ * per-concept (KC) BKT tracking aggregated as the weakest concept, SM-2
229
+ * spaced repetition, pending mastery proposals, friction log, learner
230
+ * memory, and Cornell-style notes. Persisted as one JSON file; every
231
+ * mutation is saved synchronously.
232
+ * @module dsh-plugin-lookatstudy/state
233
+ */
234
+ const DAY_MS = 864e5;
235
+ /** Mastery at or above this graduates the lesson automatically (LookatStudy MASTERED_MASTERY_THRESHOLD). */
236
+ const MASTERED_THRESHOLD = .9;
237
+ /** Concepts below this mastery are flagged weak (LookatStudy kcContext). */
238
+ const WEAK_CONCEPT_THRESHOLD = .7;
239
+ const FRICTION_CAP = 10;
240
+ /** Fresh empty state for a first run. */
241
+ function emptyState() {
242
+ return {
243
+ version: 2,
244
+ courses: [],
245
+ mode: "guide",
246
+ focus: null,
247
+ memoryGlobal: null,
248
+ memoryPatterns: {},
249
+ proposals: [],
250
+ lessonSessions: {}
251
+ };
252
+ }
253
+ /**
254
+ * Resolve the state-file location: explicit config path wins, otherwise
255
+ * `$DSH_HOME ?? ~/.dsh` under a plugin-named subdirectory.
256
+ * @param configured - Config `statePath` (empty means default).
257
+ * @returns absolute state-file path.
258
+ */
259
+ function resolveStatePath(configured) {
260
+ if (configured !== "") return configured;
261
+ return join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), "lookatstudy-plugin", "state.json");
262
+ }
263
+ /**
264
+ * Load persisted state; a missing file yields empty state, a corrupt file fails loud.
265
+ * v1 → v2 migration: `completed` lessons become `mastered`, lessons gain `kind`
266
+ * (default `study`). Newer files than this code knows are rejected.
267
+ * @param path - state-file path.
268
+ * @returns the loaded state.
269
+ */
270
+ function loadState(path) {
271
+ if (!existsSync(path)) return emptyState();
272
+ let parsed;
273
+ try {
274
+ parsed = JSON.parse(readFileSync(path, "utf8"));
275
+ } catch (error) {
276
+ throw new Error(`lookatstudy-plugin: state file is not valid JSON: ${path} (${String(error)})`);
277
+ }
278
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.courses)) throw new Error(`lookatstudy-plugin: state file has an unexpected shape: ${path}`);
279
+ const raw = parsed;
280
+ if (raw.version !== void 0 && raw.version > 2) throw new Error(`lookatstudy-plugin: state file version ${raw.version} is newer than this plugin supports: ${path}`);
281
+ const courses = raw.courses.map((course) => ({
282
+ ...course,
283
+ sections: course.sections.map((section) => ({
284
+ ...section,
285
+ lessons: section.lessons.map((lesson) => ({
286
+ ...lesson,
287
+ kind: lesson.kind ?? "study",
288
+ status: lesson.status === "completed" ? "mastered" : lesson.status
289
+ }))
290
+ }))
291
+ }));
292
+ for (const course of courses) course.sections.forEach((section, si) => {
293
+ const hasExam = section.lessons.some((l) => l.kind === "exam");
294
+ const studyCount = section.lessons.filter((l) => l.kind === "study").length;
295
+ if (!hasExam && studyCount >= 2) section.lessons.push({
296
+ ...freshLesson(`${section.title} · 章节测验`, `${section.anchor}#exam`, "", "exam"),
297
+ id: `${course.id}:${si}:${section.lessons.length}`,
298
+ status: "available"
299
+ });
300
+ });
301
+ return {
302
+ version: 2,
303
+ courses,
304
+ mode: raw.mode ?? "guide",
305
+ focus: raw.focus ?? null,
306
+ memoryGlobal: raw.memoryGlobal ?? null,
307
+ memoryPatterns: raw.memoryPatterns ?? {},
308
+ proposals: raw.proposals ?? [],
309
+ lessonSessions: raw.lessonSessions ?? {}
310
+ };
311
+ }
312
+ /**
313
+ * Persist state atomically (write a sibling temp file, then rename).
314
+ * @param path - state-file path.
315
+ * @param state - state to persist.
316
+ */
317
+ function saveState(path, state) {
318
+ mkdirSync(dirname(path), { recursive: true });
319
+ const tmp = `${path}.tmp`;
320
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
321
+ renameSync(tmp, path);
322
+ }
323
+ /**
324
+ * Slugify a course title into an id prefix: lowercase alphanumerics joined by `-`.
325
+ * @param title - course title.
326
+ * @returns slug, at least `course`.
327
+ */
328
+ function slugify(title) {
329
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
330
+ return slug === "" ? "course" : slug;
331
+ }
332
+ function freshLesson(title, anchor, body, kind = "study") {
333
+ return {
334
+ id: "",
335
+ title,
336
+ anchor,
337
+ body,
338
+ kind,
339
+ status: "locked",
340
+ concepts: null,
341
+ conceptMastery: null,
342
+ mastery: null,
343
+ attempts: 0,
344
+ correctCount: 0,
345
+ lastAnsweredAt: null,
346
+ completedAt: null,
347
+ sm2: null,
348
+ dueAt: null,
349
+ friction: [],
350
+ memory: null,
351
+ notes: []
352
+ };
353
+ }
354
+ /**
355
+ * Import a parsed course. Idempotent: the id is the title slug, so importing
356
+ * the same source again returns the existing course unchanged (LookatStudy's
357
+ * pasted-markdown contract, applied to every source). Study lessons are gated
358
+ * (first available, rest locked); every study section with ≥2 lessons also
359
+ * gets a 章节测验 exam node (available in state, gated on sibling mastery in
360
+ * the UI — LookatStudy's rule).
361
+ * @param state - state to mutate.
362
+ * @param parsed - course tree from an importer.
363
+ * @param source - import origin.
364
+ * @param sourceRef - markdown/folder/repo reference for display.
365
+ * @returns the imported (or pre-existing) course.
366
+ */
367
+ function importCourse(state, parsed, source, sourceRef) {
368
+ const id = slugify(parsed.title);
369
+ const existing = state.courses.find((c) => c.id === id);
370
+ if (existing) return existing;
371
+ const course = {
372
+ id,
373
+ title: parsed.title,
374
+ source,
375
+ sourceRef,
376
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
377
+ sections: parsed.sections.map((section) => {
378
+ const lessons = section.lessons.map((lesson) => freshLesson(lesson.title, lesson.anchor, lesson.body, lesson.world === "practice" ? "practice" : "study"));
379
+ if (section.world !== "practice" && lessons.filter((l) => l.kind === "study").length >= 2) lessons.push(freshLesson(`${section.title} · 章节测验`, `${section.anchor}#exam`, section.examBody ?? "", "exam"));
380
+ return {
381
+ title: section.title,
382
+ anchor: section.anchor,
383
+ lessons
384
+ };
385
+ })
386
+ };
387
+ let first = true;
388
+ for (let si = 0; si < course.sections.length; si++) {
389
+ const lessons = course.sections[si].lessons;
390
+ for (let li = 0; li < lessons.length; li++) {
391
+ const lesson = lessons[li];
392
+ lesson.id = `${id}:${si}:${li}`;
393
+ if (lesson.kind === "exam" || lesson.kind === "practice") lesson.status = "available";
394
+ else if (first) {
395
+ lesson.status = "available";
396
+ first = false;
397
+ }
398
+ }
399
+ }
400
+ state.courses.push(course);
401
+ return course;
402
+ }
403
+ /**
404
+ * Drop a course from state; unknown ids fail loud.
405
+ * @param state - state to mutate.
406
+ * @param courseId - course to remove.
407
+ */
408
+ function deleteCourse(state, courseId) {
409
+ const i = state.courses.findIndex((c) => c.id === courseId);
410
+ if (i < 0) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
411
+ state.courses.splice(i, 1);
412
+ state.proposals = state.proposals.filter((p) => !p.lessonId.startsWith(`${courseId}:`));
413
+ }
414
+ /**
415
+ * Locate a course; unknown ids fail loud.
416
+ * @param state - state to search.
417
+ * @param courseId - course id.
418
+ * @returns the course.
419
+ */
420
+ function findCourse(state, courseId) {
421
+ const course = state.courses.find((c) => c.id === courseId);
422
+ if (!course) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
423
+ return course;
424
+ }
425
+ /**
426
+ * Locate a lesson by its hierarchical id; unknown ids fail loud.
427
+ * @param state - state to search.
428
+ * @param lessonId - lesson id (`courseId:sectionIndex:lessonIndex`).
429
+ * @returns course/section/lesson references.
430
+ */
431
+ function findLesson(state, lessonId) {
432
+ const parts = lessonId.split(":");
433
+ const li = parts.pop();
434
+ const si = parts.pop();
435
+ const course = findCourse(state, parts.join(":"));
436
+ const sectionIndex = Number.parseInt(si ?? "", 10);
437
+ const lessonIndex = Number.parseInt(li ?? "", 10);
438
+ if (Number.isInteger(sectionIndex) && Number.isInteger(lessonIndex)) {
439
+ const section = course.sections[sectionIndex];
440
+ const lesson = section?.lessons[lessonIndex];
441
+ if (lesson && lesson.id === lessonId) return {
442
+ course,
443
+ section,
444
+ lesson
445
+ };
446
+ }
447
+ throw new Error(`lookatstudy-plugin: unknown lesson id ${JSON.stringify(lessonId)}`);
448
+ }
449
+ /**
450
+ * Find the next STUDY lesson after the given one in flat course order
451
+ * (practice/exam nodes never gate the path).
452
+ * @param course - course to walk.
453
+ * @param lessonId - current lesson id.
454
+ * @returns the next study lesson, or null at the end of the path.
455
+ */
456
+ function nextLesson(course, lessonId) {
457
+ const flat = course.sections.flatMap((s) => s.lessons);
458
+ const i = flat.findIndex((l) => l.id === lessonId);
459
+ for (let j = i + 1; j < flat.length; j++) if (flat[j].kind === "study") return flat[j];
460
+ return null;
461
+ }
462
+ /**
463
+ * LookatStudy's dual-track unlock, fired whenever the current lesson reaches
464
+ * mastery ≥0.5 (which includes the 0.5 seed from the first attempt): unlock
465
+ * (1) the next locked study lesson later in the same section AND (2) the
466
+ * first study lesson of the next section. Only `locked` nodes ever change;
467
+ * nothing re-locks.
468
+ * @param ref - the lesson that reached the threshold.
469
+ * @returns the lessons unlocked by this call.
470
+ */
471
+ function unlockAfter(ref) {
472
+ const unlocked = [];
473
+ const lessons = ref.section.lessons;
474
+ const li = lessons.indexOf(ref.lesson);
475
+ for (let i = li + 1; i < lessons.length; i++) {
476
+ const next = lessons[i];
477
+ if (next.kind !== "study") continue;
478
+ if (next.status === "locked") {
479
+ next.status = "available";
480
+ unlocked.push({
481
+ id: next.id,
482
+ title: next.title
483
+ });
484
+ }
485
+ break;
486
+ }
487
+ const si = ref.course.sections.indexOf(ref.section);
488
+ const nextSection = ref.course.sections[si + 1];
489
+ if (nextSection !== void 0) {
490
+ const first = nextSection.lessons.find((l) => l.kind === "study");
491
+ if (first !== void 0 && first.status === "locked") {
492
+ first.status = "available";
493
+ unlocked.push({
494
+ id: first.id,
495
+ title: first.title
496
+ });
497
+ }
498
+ }
499
+ return unlocked;
500
+ }
501
+ /** Recompute lesson mastery as the weakest concept once KCs exist. */
502
+ function aggregateMastery(lesson) {
503
+ if (lesson.concepts === null || lesson.conceptMastery === null) return;
504
+ const values = lesson.concepts.map((_, i) => lesson.conceptMastery[i] ?? .5);
505
+ lesson.mastery = Math.min(...values);
506
+ }
507
+ /**
508
+ * Graduate a lesson: mark mastered, seed its SM-2 schedule if absent (first
509
+ * review due tomorrow), and run the dual-track unlock.
510
+ */
511
+ function graduate(lesson, course, now) {
512
+ if (lesson.status !== "mastered") {
513
+ lesson.status = "mastered";
514
+ lesson.completedAt = now.toISOString();
515
+ if (lesson.sm2 === null) {
516
+ lesson.sm2 = {
517
+ easeFactor: 2.5,
518
+ intervalDays: 1,
519
+ repetitions: 0
520
+ };
521
+ lesson.dueAt = new Date(now.getTime() + DAY_MS).toISOString();
522
+ }
523
+ }
524
+ const si = course.sections.findIndex((s) => s.lessons.includes(lesson));
525
+ return unlockAfter({
526
+ course,
527
+ section: course.sections[si],
528
+ lesson
529
+ });
530
+ }
531
+ /** Whether every study lesson of the course is mastered. */
532
+ function courseComplete(course) {
533
+ return course.sections.every((s) => s.lessons.every((l) => l.kind !== "study" || l.status === "mastered"));
534
+ }
535
+ /** Describe the path effects of a mastery change (early unlock, graduation). */
536
+ function applyProgression(ref, now) {
537
+ const before = ref.lesson.status;
538
+ const graduated = ref.lesson.mastery !== null && ref.lesson.mastery >= .9;
539
+ let unlocked = [];
540
+ if (graduated && before !== "mastered") unlocked = graduate(ref.lesson, ref.course, now);
541
+ else if (ref.lesson.mastery !== null && ref.lesson.mastery >= .5) unlocked = unlockAfter(ref);
542
+ return {
543
+ graduated: graduated && before !== "mastered",
544
+ unlocked,
545
+ nextDue: ref.lesson.dueAt,
546
+ courseComplete: courseComplete(ref.course)
547
+ };
548
+ }
549
+ /**
550
+ * Open a lesson for study (LookatStudy markNodeAttempted): locked lessons
551
+ * fail loud; the first open of an `available` lesson marks it `in_progress`,
552
+ * seeds mastery at the BKT prior (0.5), and — because 0.5 already meets the
553
+ * unlock threshold — runs the dual-track unlock, so merely starting a lesson
554
+ * lights up the next ones.
555
+ * @param state - state to mutate.
556
+ * @param lessonId - lesson to open.
557
+ * @param now - current time.
558
+ * @returns the lesson ref, whether this open started it, and what unlocked.
559
+ */
560
+ function attemptLesson(state, lessonId, now) {
561
+ const ref = findLesson(state, lessonId);
562
+ if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; complete earlier lessons first`);
563
+ if (ref.lesson.status !== "available") return {
564
+ ref,
565
+ started: false,
566
+ unlocked: []
567
+ };
568
+ ref.lesson.status = "in_progress";
569
+ ref.lesson.lastAnsweredAt = now.toISOString();
570
+ if (ref.lesson.mastery === null) ref.lesson.mastery = .5;
571
+ return {
572
+ ref,
573
+ started: true,
574
+ unlocked: unlockAfter(ref)
575
+ };
576
+ }
577
+ /**
578
+ * Record one graded answer against a lesson: attribute it to one knowledge
579
+ * component when named, update BKT (per-KC, aggregated as the weakest),
580
+ * nudge the SM-2 schedule when one exists, and apply mastery-driven
581
+ * progression (early unlock at 0.5, graduation at 0.9). Locked lessons fail
582
+ * loud — open the lesson first (study_lesson does).
583
+ * @param state - state to mutate.
584
+ * @param lessonId - lesson to update.
585
+ * @param correct - whether the learner answered correctly.
586
+ * @param concept - concept title the question tested, when attributable.
587
+ * @param now - current time.
588
+ * @returns mastery transition, KC attribution, and progression effects.
589
+ */
590
+ function recordAnswer(state, lessonId, correct, concept, now) {
591
+ const ref = findLesson(state, lessonId);
592
+ if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; open it with study_lesson first`);
593
+ if (ref.lesson.status === "available") ref.lesson.status = "in_progress";
594
+ const kcIndex = concept === void 0 ? void 0 : ref.lesson.concepts?.findIndex((c) => c.title === concept);
595
+ if (concept !== void 0 && (ref.lesson.concepts === null || kcIndex === void 0 || kcIndex < 0)) throw new Error(`lookatstudy-plugin: unknown concept ${JSON.stringify(concept)} on lesson ${JSON.stringify(lessonId)} — define concepts with study_define_concepts first`);
596
+ const prev = ref.lesson.mastery;
597
+ if (ref.lesson.concepts !== null && kcIndex !== void 0) {
598
+ const masteries = ref.lesson.conceptMastery ?? {};
599
+ masteries[kcIndex] = updateMastery(masteries[kcIndex], correct);
600
+ ref.lesson.conceptMastery = masteries;
601
+ aggregateMastery(ref.lesson);
602
+ } else if (ref.lesson.concepts !== null) {
603
+ const masteries = ref.lesson.conceptMastery ?? {};
604
+ ref.lesson.concepts.forEach((_, i) => {
605
+ masteries[i] = updateMastery(masteries[i], correct);
606
+ });
607
+ ref.lesson.conceptMastery = masteries;
608
+ aggregateMastery(ref.lesson);
609
+ } else ref.lesson.mastery = updateMastery(prev, correct);
610
+ ref.lesson.attempts += 1;
611
+ if (correct) ref.lesson.correctCount += 1;
612
+ ref.lesson.lastAnsweredAt = now.toISOString();
613
+ if (ref.lesson.sm2 !== null) {
614
+ const result = computeSm2(ref.lesson.sm2, correct ? 5 : 2, now);
615
+ ref.lesson.sm2 = {
616
+ easeFactor: result.easeFactor,
617
+ intervalDays: result.intervalDays,
618
+ repetitions: result.repetitions
619
+ };
620
+ ref.lesson.dueAt = result.dueAt;
621
+ }
622
+ const progression = applyProgression(ref, now);
623
+ return {
624
+ ref,
625
+ concept: kcIndex === void 0 ? null : {
626
+ title: concept,
627
+ mastery: ref.lesson.conceptMastery[kcIndex]
628
+ },
629
+ prevMastery: prev ?? 0,
630
+ newMastery: ref.lesson.mastery ?? 0,
631
+ crown: masteryToCrown(ref.lesson.mastery),
632
+ mastered: (ref.lesson.mastery ?? 0) >= MASTERED_THRESHOLD,
633
+ progression
634
+ };
635
+ }
636
+ /**
637
+ * Complete a lesson explicitly (the manual path; mastery graduation is the
638
+ * automatic one). Locked lessons fail loud.
639
+ * @param state - state to mutate.
640
+ * @param lessonId - lesson to complete.
641
+ * @param now - current time.
642
+ * @returns completion result including the unlocked lessons.
643
+ */
644
+ function completeLesson(state, lessonId, now) {
645
+ const ref = findLesson(state, lessonId);
646
+ if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; complete earlier lessons first`);
647
+ return {
648
+ ref,
649
+ unlocked: graduate(ref.lesson, ref.course, now),
650
+ dueAt: ref.lesson.dueAt ?? new Date(now.getTime() + DAY_MS).toISOString(),
651
+ courseComplete: courseComplete(ref.course)
652
+ };
653
+ }
654
+ /**
655
+ * Define (or replace) a lesson's knowledge components — the independently
656
+ * quizzable units per-KC mastery tracks. Existing per-KC mastery resets.
657
+ * @param state - state to mutate.
658
+ * @param lessonId - lesson to describe.
659
+ * @param concepts - 2–7 short concepts.
660
+ */
661
+ function defineConcepts(state, lessonId, concepts) {
662
+ const ref = findLesson(state, lessonId);
663
+ if (concepts.length < 2 || concepts.length > 7) throw new Error(`lookatstudy-plugin: define 2–7 concepts (got ${concepts.length})`);
664
+ for (const def of concepts) if (def.title.trim() === "" || def.description.trim() === "") throw new Error("lookatstudy-plugin: every concept needs a non-empty title and description");
665
+ ref.lesson.concepts = concepts.map((c) => ({
666
+ title: c.title.trim(),
667
+ description: c.description.trim()
668
+ }));
669
+ ref.lesson.conceptMastery = {};
670
+ aggregateMastery(ref.lesson);
671
+ }
672
+ /**
673
+ * Log one silent friction event (confusion / block / frustration).
674
+ * @param state - state to mutate.
675
+ * @param lessonId - lesson it happened on, when attributable.
676
+ * @param category - friction category.
677
+ * @param summary - optional one-line description.
678
+ * @param now - current time.
679
+ */
680
+ function addFriction(state, lessonId, category, summary, now) {
681
+ const entry = {
682
+ category,
683
+ summary,
684
+ at: now.toISOString()
685
+ };
686
+ if (lessonId === null) return;
687
+ const ref = findLesson(state, lessonId);
688
+ ref.lesson.friction.push(entry);
689
+ if (ref.lesson.friction.length > FRICTION_CAP) ref.lesson.friction.splice(0, ref.lesson.friction.length - FRICTION_CAP);
690
+ }
691
+ /**
692
+ * Set a memory slot. The tutor merges mentally before writing (read the
693
+ * current slot, then send the merged 1–3 sentence text).
694
+ * @param state - state to mutate.
695
+ * @param category - which slot.
696
+ * @param lessonId - lesson for the `lesson` slot.
697
+ * @param content - merged slot content.
698
+ * @returns the previous content, for the tutor's merge flow.
699
+ */
700
+ function setMemory(state, category, content, lessonId) {
701
+ if (category === "global") {
702
+ const prev = state.memoryGlobal;
703
+ state.memoryGlobal = content;
704
+ return prev;
705
+ }
706
+ if (category === "pattern") {
707
+ const course = findCourse(state, lessonId === void 0 ? "" : lessonId.slice(0, lessonId.lastIndexOf(":")));
708
+ const prev = state.memoryPatterns[course.id] ?? null;
709
+ state.memoryPatterns[course.id] = content;
710
+ return prev;
711
+ }
712
+ if (lessonId === void 0) throw new Error("lookatstudy-plugin: the lesson memory slot needs a lessonId");
713
+ const ref = findLesson(state, lessonId);
714
+ const prev = ref.lesson.memory;
715
+ ref.lesson.memory = content;
716
+ return prev;
717
+ }
718
+ /**
719
+ * Add one notebook entry to a lesson's Cornell zones.
720
+ * @param state - state to mutate.
721
+ * @param lessonId - lesson the note belongs to (required: notes anchor to material).
722
+ * @param zone - Cornell zone.
723
+ * @param title - short entry title.
724
+ * @param text - entry body (markdown for the understand zone).
725
+ * @param source - where the content came from.
726
+ * @param quote - verbatim source quote for record-zone notes.
727
+ * @param now - current time.
728
+ * @returns the created note.
729
+ */
730
+ function addNote(state, lessonId, zone, title, text, source, quote, now) {
731
+ const ref = findLesson(state, lessonId);
732
+ const note = {
733
+ id: `${lessonId}:n${ref.lesson.notes.length}`,
734
+ zone,
735
+ title,
736
+ text,
737
+ source,
738
+ quote,
739
+ at: now.toISOString()
740
+ };
741
+ ref.lesson.notes.push(note);
742
+ return note;
743
+ }
744
+ /**
745
+ * Propose early mastery graduation for the learner to accept or reject in chat.
746
+ * @param state - state to mutate.
747
+ * @param lessonId - lesson judged mastered.
748
+ * @param rationale - why the tutor believes it is mastered.
749
+ * @param now - current time.
750
+ * @returns the pending proposal.
751
+ */
752
+ function proposeMastery(state, lessonId, rationale, now) {
753
+ if (findLesson(state, lessonId).lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked`);
754
+ const pending = state.proposals.find((p) => p.lessonId === lessonId && p.status === "pending");
755
+ if (pending) return pending;
756
+ const proposal = {
757
+ id: `prop-${randomBytes(3).toString("hex")}`,
758
+ lessonId,
759
+ rationale,
760
+ status: "pending",
761
+ createdAt: now.toISOString()
762
+ };
763
+ state.proposals.push(proposal);
764
+ return proposal;
765
+ }
766
+ /**
767
+ * Resolve a pending proposal: acceptance floors every concept (and the
768
+ * lesson) to 0.95 and graduates; rejection changes nothing.
769
+ * @param state - state to mutate.
770
+ * @param proposalId - proposal to resolve.
771
+ * @param accept - learner's decision.
772
+ * @param now - current time.
773
+ * @returns the resolved proposal.
774
+ */
775
+ function resolveProposal(state, proposalId, accept, now) {
776
+ const proposal = state.proposals.find((p) => p.id === proposalId);
777
+ if (!proposal) throw new Error(`lookatstudy-plugin: unknown proposal id ${JSON.stringify(proposalId)}`);
778
+ if (proposal.status !== "pending") throw new Error(`lookatstudy-plugin: proposal ${JSON.stringify(proposalId)} is already ${proposal.status}`);
779
+ if (accept) {
780
+ const ref = findLesson(state, proposal.lessonId);
781
+ if (ref.lesson.concepts !== null && ref.lesson.conceptMastery !== null) {
782
+ for (let i = 0; i < ref.lesson.concepts.length; i++) ref.lesson.conceptMastery[i] = Math.max(ref.lesson.conceptMastery[i] ?? 0, .95);
783
+ aggregateMastery(ref.lesson);
784
+ } else ref.lesson.mastery = Math.max(ref.lesson.mastery ?? 0, .95);
785
+ graduate(ref.lesson, ref.course, now);
786
+ if (ref.lesson.sm2 !== null) {
787
+ const review = computeSm2(ref.lesson.sm2, 5, now);
788
+ ref.lesson.sm2 = {
789
+ easeFactor: review.easeFactor,
790
+ intervalDays: review.intervalDays,
791
+ repetitions: review.repetitions
792
+ };
793
+ ref.lesson.dueAt = review.dueAt;
794
+ }
795
+ }
796
+ proposal.status = accept ? "applied" : "rejected";
797
+ return proposal;
798
+ }
799
+ /**
800
+ * Record an SM-2 review grade and advance the schedule.
801
+ * @param state - state to mutate.
802
+ * @param lessonId - lesson being reviewed.
803
+ * @param quality - SM-2 quality grade 0–5.
804
+ * @param now - current time.
805
+ * @returns the advanced schedule.
806
+ */
807
+ function recordReview(state, lessonId, quality, now) {
808
+ const ref = findLesson(state, lessonId);
809
+ if (!ref.lesson.sm2) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no review schedule; complete it first`);
810
+ const result = computeSm2(ref.lesson.sm2, quality, now);
811
+ ref.lesson.sm2 = {
812
+ easeFactor: result.easeFactor,
813
+ intervalDays: result.intervalDays,
814
+ repetitions: result.repetitions
815
+ };
816
+ ref.lesson.dueAt = result.dueAt;
817
+ return {
818
+ ref,
819
+ intervalDays: result.intervalDays,
820
+ repetitions: result.repetitions,
821
+ easeFactor: result.easeFactor,
822
+ dueAt: result.dueAt
823
+ };
824
+ }
825
+ /**
826
+ * List mastered lessons whose SM-2 review is due, oldest first.
827
+ * @param state - state to scan.
828
+ * @param courseId - restrict to one course when provided.
829
+ * @param now - current time.
830
+ * @returns due items across the requested scope.
831
+ */
832
+ function dueReviews(state, courseId, now) {
833
+ const courses = courseId ? [findCourse(state, courseId)] : state.courses;
834
+ const due = [];
835
+ for (const course of courses) for (const lesson of course.sections.flatMap((s) => s.lessons)) {
836
+ if (lesson.status !== "mastered" || lesson.dueAt === null) continue;
837
+ if (Date.parse(lesson.dueAt) > now.getTime()) continue;
838
+ due.push({
839
+ lessonId: lesson.id,
840
+ courseId: course.id,
841
+ courseTitle: course.title,
842
+ lessonTitle: lesson.title,
843
+ dueAt: lesson.dueAt,
844
+ overdueDays: Math.floor((now.getTime() - Date.parse(lesson.dueAt)) / DAY_MS)
845
+ });
846
+ }
847
+ due.sort((a, b) => Date.parse(a.dueAt) - Date.parse(b.dueAt));
848
+ return due;
849
+ }
850
+ /**
851
+ * Summarize every course: counts, average mastery, due reviews, and the
852
+ * current (first not-yet-mastered study) lesson.
853
+ * @param state - state to summarize.
854
+ * @param now - current time.
855
+ * @returns one summary per course, in import order.
856
+ */
857
+ function courseSummaries(state, now) {
858
+ return state.courses.map((course) => {
859
+ const lessons = course.sections.flatMap((s) => s.lessons);
860
+ const answered = lessons.filter((l) => l.mastery !== null);
861
+ const due = dueReviews({
862
+ ...emptyState(),
863
+ courses: [course]
864
+ }, course.id, now);
865
+ const current = lessons.find((l) => l.kind === "study" && l.status !== "mastered") ?? null;
866
+ const avg = answered.length === 0 ? null : answered.reduce((sum, l) => sum + (l.mastery ?? 0), 0) / answered.length;
867
+ return {
868
+ courseId: course.id,
869
+ title: course.title,
870
+ source: course.source,
871
+ createdAt: course.createdAt,
872
+ total: lessons.length,
873
+ mastered: lessons.filter((l) => l.status === "mastered").length,
874
+ available: lessons.filter((l) => l.status === "available").length,
875
+ avgMasteryPct: avg === null ? null : Math.round(avg * 100),
876
+ dueCount: due.length,
877
+ currentLessonId: current?.id ?? null
878
+ };
879
+ });
880
+ }
881
+ /**
882
+ * Teaching-strategy band for a mastery level (LookatStudy learner-model bands).
883
+ * @param mastery - lesson mastery, null before any answer.
884
+ * @returns the strategy instruction for the tutor.
885
+ */
886
+ function strategyBand(mastery) {
887
+ if (mastery === null || mastery < .1) return "先建立直觉再讲细节:用类比引入概念,分步骤引导,不堆术语。";
888
+ if (mastery < .4) return "用提问检验理解,发现误解时立即纠正,多给实际例子。";
889
+ if (mastery < .7) return "深化理解:对比相似概念的区别,考察边界情况,可以出有迷惑性的问题。";
890
+ return "综合应用阶段:让学习者尝试用自己的话教回来(费曼技巧),考虑提议标记掌握。";
891
+ }
892
+ /**
893
+ * Project a lesson's concepts with mastery and weak flags.
894
+ * @param lesson - lesson to project.
895
+ * @returns concept views in definition order, or null before concepts exist.
896
+ */
897
+ function conceptViews(lesson) {
898
+ if (lesson.concepts === null) return null;
899
+ return lesson.concepts.map((c, i) => {
900
+ const mastery = lesson.conceptMastery?.[i] ?? .5;
901
+ return {
902
+ title: c.title,
903
+ masteryPct: Math.round(mastery * 100),
904
+ weak: mastery < WEAK_CONCEPT_THRESHOLD,
905
+ tested: lesson.conceptMastery !== null && i in lesson.conceptMastery ? 1 : 0
906
+ };
907
+ });
908
+ }
909
+ /** The four consolidation starters attached to a lesson (LookatStudy templates). */
910
+ function starterPrompts(lessonTitle) {
911
+ return [
912
+ {
913
+ label: "🔬 深入这点",
914
+ message: `帮我深入讲讲「${lessonTitle}」刚才那个核心点——展开它的结构、细节和容易忽略的边界。`,
915
+ effect: "none"
916
+ },
917
+ {
918
+ label: "💡 举个例子",
919
+ message: `给我一个「${lessonTitle}」的实际例子或用法,让我更具体地理解。`,
920
+ effect: "none"
921
+ },
922
+ {
923
+ label: "📝 考考我",
924
+ message: `出一道关于「${lessonTitle}」的应用题考考我,看我是否真懂了——我答完请判断对错。`,
925
+ effect: "mastery"
926
+ },
927
+ {
928
+ label: "🤔 我没太懂",
929
+ message: `关于「${lessonTitle}」,我有地方不太懂,帮我理一理——先问我是哪里不清楚。`,
930
+ effect: "friction"
931
+ }
932
+ ];
933
+ }
934
+ /**
935
+ * Compose the learner snapshot for the focused lesson (or course-wide when
936
+ * no focus): strategy band, weak concepts, recent friction, memory slots,
937
+ * due count, pending proposal. The tutor persona's volatile tail.
938
+ * @param state - state to read.
939
+ * @param now - current time.
940
+ * @returns the snapshot value.
941
+ */
942
+ function learnerSnapshot(state, now) {
943
+ let ref = state.focus === null ? null : tryFindLesson(state, state.focus.lessonId);
944
+ if (ref === null && state.courses.length > 0) {
945
+ const lessons = state.courses[0].sections.flatMap((s) => s.lessons);
946
+ const current = lessons.find((l) => l.kind === "study" && l.status === "in_progress") ?? lessons.find((l) => l.kind === "study" && l.status === "available") ?? null;
947
+ ref = current === null ? null : {
948
+ course: state.courses[0],
949
+ section: state.courses[0].sections.find((s) => s.lessons.includes(current)),
950
+ lesson: current
951
+ };
952
+ }
953
+ return {
954
+ focus: ref === null ? null : {
955
+ lessonId: ref.lesson.id,
956
+ courseTitle: ref.course.title,
957
+ lessonTitle: ref.lesson.title,
958
+ masteryPct: ref.lesson.mastery === null ? null : Math.round(ref.lesson.mastery * 100),
959
+ status: ref.lesson.status
960
+ },
961
+ strategy: ref === null ? null : strategyBand(ref.lesson.mastery),
962
+ concepts: ref === null ? null : conceptViews(ref.lesson),
963
+ friction: ref === null ? [] : ref.lesson.friction.slice(-5),
964
+ memoryGlobal: state.memoryGlobal,
965
+ memoryLesson: ref?.lesson.memory ?? null,
966
+ memoryPattern: ref === null ? null : state.memoryPatterns[ref.course.id] ?? null,
967
+ dueCount: dueReviews(state, void 0, now).length,
968
+ pendingProposal: state.proposals.find((p) => p.status === "pending") ?? null
969
+ };
970
+ }
971
+ /** findLesson that returns null instead of throwing (snapshot focus may be stale). */
972
+ function tryFindLesson(state, lessonId) {
973
+ try {
974
+ return findLesson(state, lessonId);
975
+ } catch {
976
+ return null;
977
+ }
978
+ }
979
+ //#endregion
980
+ //#region src/dashboard.ts
981
+ /**
982
+ * The study tab's HTTP API under `/lookatstudy/api/*`: the polled state feed
983
+ * and the tab's write actions (focus, mode, lesson-session binding, course
984
+ * deletion, study-workspace path), reading the same live plugin state the
985
+ * tutor tools write. The v0.3 standalone workbench page and its reverse
986
+ * message channel were removed once the in-client study tab superseded them.
987
+ * @module dsh-plugin-lookatstudy/dashboard
988
+ */
989
+ /**
990
+ * Assemble the whole workbench state (pure read; the lesson HTML is rendered
991
+ * server-side from the sanitized markdown pipeline).
992
+ * @param state - live learning state.
993
+ * @param now - current time.
994
+ * @returns the page's data contract.
995
+ */
996
+ function workbenchState(state, now) {
997
+ const focusId = state.focus?.lessonId ?? null;
998
+ const dueIds = new Set(dueReviews(state, void 0, now).map((d) => d.lessonId));
999
+ const courses = state.courses.map((course) => {
1000
+ const lessons = course.sections.flatMap((s) => s.lessons);
1001
+ const answered = lessons.filter((l) => l.mastery !== null);
1002
+ return {
1003
+ courseId: course.id,
1004
+ title: course.title,
1005
+ mastered: lessons.filter((l) => l.status === "mastered").length,
1006
+ total: lessons.length,
1007
+ avgMasteryPct: answered.length === 0 ? null : Math.round(answered.reduce((sum, l) => sum + (l.mastery ?? 0), 0) / answered.length * 100),
1008
+ sections: course.sections.map((section, index) => ({
1009
+ title: section.title,
1010
+ index,
1011
+ lessons: section.lessons.map((lesson) => ({
1012
+ id: lesson.id,
1013
+ title: lesson.title,
1014
+ kind: lesson.kind,
1015
+ status: lesson.status,
1016
+ masteryPct: lesson.mastery === null ? null : Math.round(lesson.mastery * 100),
1017
+ weakConcepts: (conceptViews(lesson) ?? []).filter((c) => c.weak).length,
1018
+ frictionCount: lesson.friction.length,
1019
+ due: dueIds.has(lesson.id),
1020
+ focus: lesson.id === focusId
1021
+ }))
1022
+ }))
1023
+ };
1024
+ });
1025
+ let lesson = null;
1026
+ if (focusId !== null) try {
1027
+ const ref = findLesson(state, focusId);
1028
+ lesson = {
1029
+ lessonId: ref.lesson.id,
1030
+ courseTitle: ref.course.title,
1031
+ sectionTitle: ref.section.title,
1032
+ title: ref.lesson.title,
1033
+ status: ref.lesson.status,
1034
+ masteryPct: ref.lesson.mastery === null ? null : Math.round(ref.lesson.mastery * 100),
1035
+ strategy: strategyBand(ref.lesson.mastery),
1036
+ concepts: conceptViews(ref.lesson) ?? [],
1037
+ starters: starterPrompts(ref.lesson.title).map((s) => ({
1038
+ label: s.label,
1039
+ message: s.message
1040
+ })),
1041
+ notes: ref.lesson.notes.map((n) => ({
1042
+ id: n.id,
1043
+ zone: n.zone,
1044
+ title: n.title,
1045
+ text: n.text,
1046
+ source: n.source,
1047
+ quote: n.quote
1048
+ })),
1049
+ html: renderMarkdown(ref.lesson.body)
1050
+ };
1051
+ } catch {
1052
+ lesson = null;
1053
+ }
1054
+ const due = dueReviews(state, void 0, now);
1055
+ return {
1056
+ mode: state.mode,
1057
+ courses,
1058
+ focusLessonId: focusId,
1059
+ lesson,
1060
+ dueCount: due.length,
1061
+ due: due.map((d) => ({
1062
+ lessonId: d.lessonId,
1063
+ lessonTitle: d.lessonTitle,
1064
+ courseTitle: d.courseTitle,
1065
+ overdueDays: d.overdueDays
1066
+ })),
1067
+ pendingProposals: state.proposals.filter((p) => p.status === "pending").map((p) => {
1068
+ try {
1069
+ return {
1070
+ id: p.id,
1071
+ lessonTitle: findLesson(state, p.lessonId).lesson.title,
1072
+ rationale: p.rationale
1073
+ };
1074
+ } catch {
1075
+ return {
1076
+ id: p.id,
1077
+ lessonTitle: p.lessonId,
1078
+ rationale: p.rationale
1079
+ };
1080
+ }
1081
+ }),
1082
+ memory: (() => {
1083
+ const snap = learnerSnapshot(state, now);
1084
+ return {
1085
+ global: snap.memoryGlobal,
1086
+ lesson: snap.memoryLesson,
1087
+ pattern: snap.memoryPattern
1088
+ };
1089
+ })(),
1090
+ lessonSessions: state.lessonSessions
1091
+ };
1092
+ }
1093
+ const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
1094
+ function sendJson(res, status, value) {
1095
+ res.writeHead(status, JSON_HEADERS).end(JSON.stringify(value));
1096
+ }
1097
+ /**
1098
+ * Read one JSON body, answering 400 on malformed or oversized input so the
1099
+ * handler never throws into the HTTP layer.
1100
+ * @returns the parsed value, or undefined when the response is already sent.
1101
+ */
1102
+ async function readJsonBodySafe(req, res) {
1103
+ try {
1104
+ return await readJsonBody(req);
1105
+ } catch (error) {
1106
+ sendJson(res, 400, {
1107
+ ok: false,
1108
+ error: error instanceof Error ? error.message : "bad request"
1109
+ });
1110
+ return;
1111
+ }
1112
+ }
1113
+ /** Read one JSON request body with a hard 64 kB cap; malformed bodies reject. */
1114
+ function readJsonBody(req) {
1115
+ return new Promise((resolve, reject) => {
1116
+ const chunks = [];
1117
+ req.on("data", (chunk) => {
1118
+ chunks.push(chunk);
1119
+ if (chunks.reduce((n, c) => n + c.length, 0) > 65536) {
1120
+ reject(/* @__PURE__ */ new Error("request body too large"));
1121
+ return;
1122
+ }
1123
+ });
1124
+ req.on("end", () => {
1125
+ try {
1126
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
1127
+ } catch {
1128
+ reject(/* @__PURE__ */ new Error("request body is not valid JSON"));
1129
+ }
1130
+ });
1131
+ });
1132
+ }
1133
+ /**
1134
+ * Register the study tab's API routes under `/lookatstudy/api/*`: the polling
1135
+ * state feed plus the tab's write actions.
1136
+ * @param webServer - the composed webserver's route registry.
1137
+ * @param deps - store plus the study-workspace directory.
1138
+ * @returns the disposer removing every route.
1139
+ */
1140
+ function registerDashboard(webServer, deps) {
1141
+ const disposeRoutes = webServer.register({
1142
+ kind: "prefix",
1143
+ path: "/lookatstudy",
1144
+ handler: async (req, res) => {
1145
+ const pathname = new URL(req.url ?? "/", "http://x").pathname;
1146
+ if (req.method === "GET" && pathname === "/lookatstudy/api/state") {
1147
+ sendJson(res, 200, workbenchState(deps.store.get(), /* @__PURE__ */ new Date()));
1148
+ return;
1149
+ }
1150
+ if (req.method === "POST" && pathname === "/lookatstudy/api/focus") {
1151
+ const body = await readJsonBodySafe(req, res);
1152
+ if (body === void 0) return;
1153
+ if (typeof body.lessonId !== "string") {
1154
+ sendJson(res, 400, {
1155
+ ok: false,
1156
+ error: "lessonId (string) required"
1157
+ });
1158
+ return;
1159
+ }
1160
+ try {
1161
+ const ref = findLesson(deps.store.get(), body.lessonId);
1162
+ deps.store.get().focus = { lessonId: ref.lesson.id };
1163
+ deps.store.save();
1164
+ sendJson(res, 200, { ok: true });
1165
+ } catch (error) {
1166
+ sendJson(res, 404, {
1167
+ ok: false,
1168
+ error: error instanceof Error ? error.message : String(error)
1169
+ });
1170
+ }
1171
+ return;
1172
+ }
1173
+ if (req.method === "GET" && pathname === "/lookatstudy/api/study-workspace") {
1174
+ sendJson(res, 200, {
1175
+ ok: true,
1176
+ path: deps.studyAreaPath
1177
+ });
1178
+ return;
1179
+ }
1180
+ if (req.method === "POST" && pathname === "/lookatstudy/api/course/delete") {
1181
+ const body = await readJsonBodySafe(req, res);
1182
+ if (body === void 0) return;
1183
+ if (typeof body.courseId !== "string") {
1184
+ sendJson(res, 400, {
1185
+ ok: false,
1186
+ error: "courseId (string) required"
1187
+ });
1188
+ return;
1189
+ }
1190
+ try {
1191
+ const course = findCourse(deps.store.get(), body.courseId);
1192
+ deleteCourse(deps.store.get(), course.id);
1193
+ if (deps.store.get().focus?.lessonId.startsWith(`${course.id}:`)) deps.store.get().focus = null;
1194
+ deps.store.save();
1195
+ sendJson(res, 200, { ok: true });
1196
+ } catch (error) {
1197
+ sendJson(res, 404, {
1198
+ ok: false,
1199
+ error: error instanceof Error ? error.message : String(error)
1200
+ });
1201
+ }
1202
+ return;
1203
+ }
1204
+ if (req.method === "POST" && pathname === "/lookatstudy/api/lesson-session") {
1205
+ const body = await readJsonBodySafe(req, res);
1206
+ if (body === void 0) return;
1207
+ if (typeof body.lessonId !== "string" || typeof body.sessionId !== "string") {
1208
+ sendJson(res, 400, {
1209
+ ok: false,
1210
+ error: "lessonId and sessionId (strings) required"
1211
+ });
1212
+ return;
1213
+ }
1214
+ deps.store.get().lessonSessions[body.lessonId] = body.sessionId;
1215
+ deps.store.save();
1216
+ sendJson(res, 200, { ok: true });
1217
+ return;
1218
+ }
1219
+ if (req.method === "POST" && pathname === "/lookatstudy/api/mode") {
1220
+ const body = await readJsonBodySafe(req, res);
1221
+ if (body === void 0) return;
1222
+ if (body.mode !== "direct" && body.mode !== "guide" && body.mode !== "practice") {
1223
+ sendJson(res, 400, {
1224
+ ok: false,
1225
+ error: "mode must be direct | guide | practice"
1226
+ });
1227
+ return;
1228
+ }
1229
+ deps.store.get().mode = body.mode;
1230
+ deps.store.save();
1231
+ sendJson(res, 200, {
1232
+ ok: true,
1233
+ mode: body.mode
1234
+ });
1235
+ return;
1236
+ }
1237
+ sendJson(res, 404, {
1238
+ ok: false,
1239
+ error: "not found"
1240
+ });
1241
+ }
1242
+ });
1243
+ return () => {
1244
+ disposeRoutes();
1245
+ };
1246
+ }
1247
+ //#endregion
1248
+ //#region src/vendor/markdown-course.ts
1249
+ /**
1250
+ * GitHub 风格的 anchor 生成:小写、去一组标点(保留中文等 unicode)、每个空格单独转 -。
1251
+ * 注意:**不合并多 -**("A & B" → 去 & 留两空格 → "a--b",与 GitHub slugger 一致)。
1252
+ * 与 seed.ts 的锚点对齐。
1253
+ */
1254
+ function titleToAnchor(title) {
1255
+ return title.toLowerCase().trim().replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, "").replace(/ /g, "-").replace(/^-|-$/g, "");
1256
+ }
1257
+ /**
1258
+ * 清洗课时/章节标题 — 去 emoji、多余空格、markdown 格式符号。
1259
+ * "🛠 The Modern FDE Stack" → "The Modern FDE Stack"
1260
+ * "## [Pre-lecture quiz](url)" → "Pre-lecture quiz"
1261
+ */
1262
+ function cleanTitle(raw) {
1263
+ return raw.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^#+\s*/, "").trim().replace(/\s+/g, " ").replace(/^[·\-\.\s]+|[·\-\.\s]+$/g, "").trim();
1264
+ }
1265
+ /**
1266
+ * 解析 markdown 为课程树。
1267
+ * 容错:H3 出现在任何 H2 之前 → 归到一个 "(前言)" section。
1268
+ */
1269
+ function parseMarkdownToCourse(md) {
1270
+ const lines = md.split(/\r?\n/);
1271
+ const sections = [];
1272
+ let title = "(untitled)";
1273
+ let currentSection = null;
1274
+ let bodyBuffer = [];
1275
+ let inCodeFence = false;
1276
+ const flushLessonBody = () => {
1277
+ if (currentSection && currentSection.lessons.length > 0) currentSection.lessons[currentSection.lessons.length - 1].body = bodyBuffer.join("\n").trim();
1278
+ bodyBuffer = [];
1279
+ };
1280
+ for (const line of lines) {
1281
+ if (/^(\s*)(```|~~~)/.test(line)) {
1282
+ inCodeFence = !inCodeFence;
1283
+ bodyBuffer.push(line);
1284
+ continue;
1285
+ }
1286
+ if (inCodeFence) {
1287
+ bodyBuffer.push(line);
1288
+ continue;
1289
+ }
1290
+ if (/^#\s+/.test(line) && title === "(untitled)") {
1291
+ title = cleanTitle(line.replace(/^#\s+/, "").trim());
1292
+ continue;
1293
+ }
1294
+ if (/^##\s+/.test(line)) {
1295
+ flushLessonBody();
1296
+ const sectionTitle = cleanTitle(line.replace(/^##\s+/, "").trim());
1297
+ currentSection = {
1298
+ title: sectionTitle,
1299
+ anchor: titleToAnchor(sectionTitle),
1300
+ lessons: []
1301
+ };
1302
+ sections.push(currentSection);
1303
+ continue;
1304
+ }
1305
+ if (/^###\s+/.test(line)) {
1306
+ flushLessonBody();
1307
+ const lessonTitle = cleanTitle(line.replace(/^###\s+/, "").trim());
1308
+ if (!currentSection) {
1309
+ currentSection = {
1310
+ title: "(前言)",
1311
+ anchor: titleToAnchor("前言"),
1312
+ lessons: []
1313
+ };
1314
+ sections.push(currentSection);
1315
+ }
1316
+ currentSection.lessons.push({
1317
+ title: lessonTitle,
1318
+ anchor: titleToAnchor(lessonTitle),
1319
+ body: ""
1320
+ });
1321
+ continue;
1322
+ }
1323
+ bodyBuffer.push(line);
1324
+ }
1325
+ flushLessonBody();
1326
+ return {
1327
+ title,
1328
+ sections
1329
+ };
1330
+ }
1331
+ //#endregion
1332
+ //#region src/vendor/local-folder-scanner.ts
1333
+ /**
1334
+ * 本地文件夹通用扫描器 —— 把任意课程文件夹(如 Coursera 下载包)递归扫描成文档清单。
1335
+ *
1336
+ * 设计原则:通用,不硬编码某一种文件夹结构。
1337
+ * - 扫描文档类:.txt/.md/.mdx/.markdown/.html/.htm/.pdf/.ipynb/.rst/.rmd/.org/.adoc/.asciidoc
1338
+ * - 扫描代码类:.py/.js/.ts/.go/.rs/.java/.c/.cpp/.rb/.sh/.lua/.sql/.r/.jl/.dart/... (30+ 语言, code-parser 转 markdown)
1339
+ * - 图片文件:.png/.jpg/.jpeg/.gif/.webp/.svg/.bmp/.avif/.ico/.tiff/.heic(多模态 flag on 时收集)
1340
+ * - 中文优先去重(同内容 .zh-CN 和 .en 只留中文)
1341
+ * - 按文件名 NN_ 前缀排序
1342
+ * - HTML 去标签转纯文本(<co-content> 富文本质量足够)
1343
+ * - PDF 用 pdf-renderer 提取文字 + 图片(纯文字/纯图片/混合自动分类)
1344
+ *
1345
+ * 纯函数为主(htmlToText/标题推断/去重/图片引用解析),便于 verify 脚本测。
1346
+ * scanFolder 本身用 fs(异步),verify 用临时目录造文件测。
1347
+ */
1348
+ /** 支持的扩展名 → kind 映射 */
1349
+ const EXT_KIND = {
1350
+ txt: "txt",
1351
+ md: "md",
1352
+ mdx: "md",
1353
+ markdown: "md",
1354
+ html: "html",
1355
+ htm: "html",
1356
+ pdf: "pdf",
1357
+ pptx: "pptx",
1358
+ ipynb: "ipynb",
1359
+ rst: "rst",
1360
+ rmd: "rmd",
1361
+ org: "org",
1362
+ adoc: "adoc",
1363
+ asciidoc: "adoc",
1364
+ py: "code",
1365
+ js: "code",
1366
+ jsx: "code",
1367
+ ts: "code",
1368
+ tsx: "code",
1369
+ mjs: "code",
1370
+ cjs: "code",
1371
+ go: "code",
1372
+ rs: "code",
1373
+ java: "code",
1374
+ kt: "code",
1375
+ kts: "code",
1376
+ scala: "code",
1377
+ c: "code",
1378
+ h: "code",
1379
+ cpp: "code",
1380
+ cc: "code",
1381
+ cxx: "code",
1382
+ hpp: "code",
1383
+ cs: "code",
1384
+ rb: "code",
1385
+ php: "code",
1386
+ swift: "code",
1387
+ sh: "code",
1388
+ bash: "code",
1389
+ zsh: "code",
1390
+ ps1: "code",
1391
+ lua: "code",
1392
+ r: "code",
1393
+ jl: "code",
1394
+ dart: "code",
1395
+ clj: "code",
1396
+ ex: "code",
1397
+ exs: "code",
1398
+ erl: "code",
1399
+ hs: "code",
1400
+ ml: "code",
1401
+ fs: "code",
1402
+ sql: "code",
1403
+ pl: "code",
1404
+ elm: "code"
1405
+ };
1406
+ /** 图片扩展名 → MIME 映射 */
1407
+ const IMAGE_EXT_MIME = {
1408
+ png: "image/png",
1409
+ jpg: "image/jpeg",
1410
+ jpeg: "image/jpeg",
1411
+ gif: "image/gif",
1412
+ webp: "image/webp",
1413
+ svg: "image/svg+xml",
1414
+ bmp: "image/bmp",
1415
+ avif: "image/avif",
1416
+ ico: "image/x-icon",
1417
+ tiff: "image/tiff",
1418
+ tif: "image/tiff",
1419
+ heic: "image/heic"
1420
+ };
1421
+ /** 排除的目录(非教学内容) */
1422
+ const EXCLUDED_DIRS = new Set([
1423
+ "node_modules",
1424
+ ".git",
1425
+ ".svn",
1426
+ "dist",
1427
+ "build",
1428
+ "__pycache__",
1429
+ ".DS_Store",
1430
+ "translations",
1431
+ ".venv",
1432
+ "venv",
1433
+ "env",
1434
+ "vendor",
1435
+ "target",
1436
+ "out",
1437
+ "coverage",
1438
+ ".next",
1439
+ ".nuxt",
1440
+ ".gradle",
1441
+ ".idea",
1442
+ ".vscode",
1443
+ ".cache",
1444
+ ".pytest_cache",
1445
+ ".mypy_cache",
1446
+ ".turbo",
1447
+ ".svelte-kit",
1448
+ "bin",
1449
+ "obj",
1450
+ "__pypackages__",
1451
+ ".docusaurus"
1452
+ ]);
1453
+ /** HTML 转纯文本:去 script/style,标签转段落,<li> 加 •,decode 常见实体。纯函数,可测。 */
1454
+ function htmlToText(html) {
1455
+ let s = html;
1456
+ s = s.replace(/<script[\s\S]*?<\/script>/gi, "");
1457
+ s = s.replace(/<style[\s\S]*?<\/style>/gi, "");
1458
+ s = s.replace(/<head[\s\S]*?<\/head>/gi, "");
1459
+ s = s.replace(/<\/(p|div|section|article|h[1-6]|li|tr|br)>/gi, "\n");
1460
+ s = s.replace(/<br\s*\/?>/gi, "\n");
1461
+ s = s.replace(/<li[^>]*>/gi, "• ");
1462
+ s = s.replace(/<\/td>/gi, " ");
1463
+ s = s.replace(/<\/th>/gi, " ");
1464
+ s = s.replace(/<[^>]+>/g, "");
1465
+ s = s.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&hellip;/g, "…").replace(/&mdash;/g, "—");
1466
+ s = s.replace(/[ \t]+/g, " ");
1467
+ s = s.replace(/\n[ \t]+/g, "\n");
1468
+ s = s.replace(/\n{3,}/g, "\n\n");
1469
+ return s.trim();
1470
+ }
1471
+ /** 从文件名推断语言(用于中文优先去重)。 */
1472
+ function detectLang(filename) {
1473
+ const lower = filename.toLowerCase();
1474
+ if (/\.zh[-_]?cn\./.test(lower) || /\.zh[-_]?hans\./.test(lower) || /\.zh[-_]?tw\./.test(lower) || /\.zh[-_]?hant\./.test(lower) || /\.zh\./.test(lower)) return "zh";
1475
+ if (/\.en[-_]?us\./.test(lower) || /\.en[-_]?gb\./.test(lower) || /\.en\./.test(lower)) return "en";
1476
+ if (/\.ja\./.test(lower) || /\.ko\./.test(lower) || /\.de\./.test(lower) || /\.fr\./.test(lower) || /\.es\./.test(lower) || /\.pt[-_]?br\./.test(lower) || /\.pt\./.test(lower) || /\.it\./.test(lower) || /\.ru\./.test(lower) || /\.ar\./.test(lower)) return "other";
1477
+ return "other";
1478
+ }
1479
+ /** 从路径推断标题:
1480
+ * 07_derivatives-and-tangents.zh-CN.txt → "Derivatives And Tangents"
1481
+ * 01_lesson-1-intro/README.md → "Lesson 1 Intro"
1482
+ * 去数字前缀 + 扩展名 + 语言后缀,- _ 转空格,首字母大写。纯函数,可测。 */
1483
+ function inferTitle(relPath) {
1484
+ const filename = basename(relPath);
1485
+ let name = filename.replace(/\.(txt|md|mdx|markdown|html?|pdf|ipynb|rst|rmd|org|adoc|asciidoc|py|js|jsx|ts|tsx|mjs|cjs|go|rs|java|kt|kts|scala|c|h|cpp|cc|cxx|hpp|cs|rb|php|swift|sh|bash|zsh|ps1|lua|r|jl|dart|clj|ex|exs|erl|hs|ml|fs|sql|pl|elm)$/i, "");
1486
+ name = name.replace(/\.(zh[-_]?cn|zh[-_]?hans|zh|en[-_]?us|en)$/i, "");
1487
+ if (/^(readme|index)$/i.test(name)) {
1488
+ const parts = relPath.split("/").filter(Boolean);
1489
+ const parent = parts[parts.length - 2];
1490
+ if (parent) name = parent;
1491
+ }
1492
+ name = name.replace(/^(\d+[_-]\s*)/, "");
1493
+ name = name.replace(/[-_]+/g, " ").trim();
1494
+ if (/^[a-z]/.test(name)) name = name.charAt(0).toUpperCase() + name.slice(1);
1495
+ return name || filename;
1496
+ }
1497
+ /** 算 basename 的去重 key(去掉语言后缀 + 扩展名)。
1498
+ * 06_motivation.en.txt 和 06_motivation.zh-CN.txt → key "06_motivation" */
1499
+ function dedupKey(relPath) {
1500
+ const dir = dirname(relPath).toLowerCase();
1501
+ let name = basename(relPath).replace(/\.(txt|md|mdx|markdown|html?|pdf|ipynb|rst|rmd|org|adoc|asciidoc|py|js|jsx|ts|tsx|mjs|cjs|go|rs|java|kt|kts|scala|c|h|cpp|cc|cxx|hpp|cs|rb|php|swift|sh|bash|zsh|ps1|lua|r|jl|dart|clj|ex|exs|erl|hs|ml|fs|sql|pl|elm)$/i, "");
1502
+ name = name.replace(/\.(zh[-_]?cn|zh[-_]?hans|zh|en[-_]?us|en)$/i, "");
1503
+ return (dir === "." ? "" : dir + "/") + name.toLowerCase();
1504
+ }
1505
+ /**
1506
+ * 递归扫描一个目录,返回所有文本类文档(可选:同时收集图片)。
1507
+ * 中文优先去重:同 dedupKey 的多语言文件只保留中文(.zh 优先于 .en/other)。
1508
+ * 按相对路径排序(保持目录顺序 + 文件名 NN_ 前缀)。
1509
+ *
1510
+ * @param rootDir 根目录绝对路径
1511
+ * @param onProgress 可选进度回调(已扫文件数,当前路径)
1512
+ * @param options.collectImages true 时同时收集图片文件 + markdown 图片引用(多模态 flag)
1513
+ * @returns 文档数组,或 { docs, images }(collectImages=true 时)
1514
+ */
1515
+ async function scanFolder(rootDir, onProgress, options) {
1516
+ const allFiles = [];
1517
+ await walkDir(rootDir, rootDir, allFiles);
1518
+ allFiles.sort((a, b) => naturalPathCompare(a.relPath, b.relPath));
1519
+ const docFiles = allFiles.filter((f) => !f.isImage);
1520
+ const imageFiles = allFiles.filter((f) => f.isImage);
1521
+ const docs = [];
1522
+ let count = 0;
1523
+ for (const f of docFiles) {
1524
+ onProgress?.(++count, f.relPath);
1525
+ const kind = EXT_KIND[f.relPath.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? ""];
1526
+ if (!kind) continue;
1527
+ try {
1528
+ const content = await readFileWithKind(f.absPath, kind);
1529
+ if (!content || content.trim().length < 5) continue;
1530
+ const lang = detectLang(f.relPath);
1531
+ docs.push({
1532
+ path: f.relPath,
1533
+ title: inferTitle(f.relPath),
1534
+ content,
1535
+ lang,
1536
+ kind
1537
+ });
1538
+ } catch {}
1539
+ }
1540
+ const dedupedDocs = dedupByLang(docs);
1541
+ if (!options?.collectImages) return dedupedDocs;
1542
+ const fileImages = imageFiles.map((f) => {
1543
+ const ext = f.relPath.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "";
1544
+ return {
1545
+ path: f.relPath,
1546
+ absPath: f.absPath,
1547
+ title: inferImageTitle(f.relPath),
1548
+ mime: IMAGE_EXT_MIME[ext] ?? "image/png",
1549
+ source: "image_file",
1550
+ altText: inferImageTitle(f.relPath)
1551
+ };
1552
+ });
1553
+ const refImages = [];
1554
+ for (const doc of dedupedDocs) {
1555
+ if (doc.kind === "txt" || doc.kind === "html") continue;
1556
+ const refs = extractImageRefs(doc.content);
1557
+ for (const ref of refs) {
1558
+ const resolvedPath = resolveImageRef(ref.refPath, doc.path);
1559
+ const ext = resolvedPath.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "";
1560
+ refImages.push({
1561
+ path: resolvedPath,
1562
+ absPath: join(rootDir, resolvedPath),
1563
+ title: ref.alt || inferImageTitle(resolvedPath),
1564
+ mime: IMAGE_EXT_MIME[ext] ?? "image/png",
1565
+ source: "markdown_ref",
1566
+ altText: ref.alt || inferImageTitle(resolvedPath)
1567
+ });
1568
+ }
1569
+ }
1570
+ const dedupedFileAndRefImages = dedupImages(fileImages, refImages);
1571
+ const pdfImages = [];
1572
+ for (const doc of dedupedDocs) {
1573
+ if (doc.kind !== "pdf") continue;
1574
+ try {
1575
+ const { processPdf } = await import("../lib/pdf-renderer.js");
1576
+ const result = await processPdf(await readFile(join(rootDir, doc.path)));
1577
+ for (const img of result.images) pdfImages.push({
1578
+ path: `${doc.path}#page${img.pageNumber}.png`,
1579
+ absPath: "",
1580
+ title: `${doc.title} - 图(第${img.pageNumber}页)`,
1581
+ mime: img.mimeType,
1582
+ source: "pdf_page",
1583
+ altText: `${doc.title} 第${img.pageNumber}页`,
1584
+ buffer: img.buffer,
1585
+ pageNumber: img.pageNumber
1586
+ });
1587
+ } catch {}
1588
+ }
1589
+ const pptxImages = [];
1590
+ for (const doc of dedupedDocs) {
1591
+ if (doc.kind !== "pptx") continue;
1592
+ try {
1593
+ const { parsePptx } = await import("../lib/pptx-parser.js");
1594
+ const result = await parsePptx(await readFile(join(rootDir, doc.path)));
1595
+ for (const img of result.images) pptxImages.push({
1596
+ path: `${doc.path}#slide${img.slideNumber}.png`,
1597
+ absPath: "",
1598
+ title: `${doc.title} - 图(第${img.slideNumber}页)`,
1599
+ mime: img.mimeType,
1600
+ source: "pdf_page",
1601
+ altText: `${doc.title} 第${img.slideNumber}页`,
1602
+ buffer: img.buffer,
1603
+ pageNumber: img.slideNumber
1604
+ });
1605
+ } catch {}
1606
+ }
1607
+ const notebookImages = [];
1608
+ for (const doc of dedupedDocs) {
1609
+ if (!doc.path.toLowerCase().endsWith(".ipynb")) continue;
1610
+ try {
1611
+ const { parseNotebook } = await import("./notebook-parser-ChbZBIKJ.mjs");
1612
+ const nbResult = parseNotebook(await readFile(join(rootDir, doc.path), "utf8"));
1613
+ for (const img of nbResult.images) {
1614
+ const buf = Buffer.from(img.base64, "base64");
1615
+ notebookImages.push({
1616
+ path: `${doc.path}#cell${img.cellIndex}.png`,
1617
+ absPath: "",
1618
+ title: `${doc.title} - 输出图(cell ${img.cellIndex})`,
1619
+ mime: img.mimeType,
1620
+ source: "image_file",
1621
+ altText: img.altText,
1622
+ buffer: buf
1623
+ });
1624
+ }
1625
+ } catch {}
1626
+ }
1627
+ return {
1628
+ docs: dedupedDocs,
1629
+ images: [
1630
+ ...dedupedFileAndRefImages,
1631
+ ...pdfImages,
1632
+ ...pptxImages,
1633
+ ...notebookImages
1634
+ ]
1635
+ };
1636
+ }
1637
+ /**
1638
+ * 同语言类别内部去重(保留双语配对)。
1639
+ *
1640
+ * 历史:旧版是跨语言的"中文优先"(同 dedupKey 只留 zh)——那是翻译管线诞生前的
1641
+ * hack,xxx.en.txt / xxx.zh-CN.txt 成对时英文原稿被直接丢掉,双语信息在扫描层
1642
+ * 就没了,翻译管线永远拿不到配对。现在分类层(excludeSuffixTranslations 规则
1643
+ * 分流 + LLM translation 角色)负责把成对双语分流为 原文+翻译,所以扫描器必须
1644
+ * 把配对双方都保留,只合并同一语言类别内部的真重复(如 08.en.txt vs 08.en.md)。
1645
+ */
1646
+ function dedupByLang(docs) {
1647
+ const byKey = /* @__PURE__ */ new Map();
1648
+ for (const d of docs) {
1649
+ const key = `${dedupKey(d.path)}|${d.lang}`;
1650
+ if (!byKey.has(key)) byKey.set(key, d);
1651
+ }
1652
+ return docs.filter((d) => byKey.get(`${dedupKey(d.path)}|${d.lang}`) === d);
1653
+ }
1654
+ /**
1655
+ * 从 markdown 内容里提取图片引用 ![alt](path)。
1656
+ * 纯函数,便于测试。
1657
+ *
1658
+ * 解析规则:
1659
+ * - 匹配 ![可选alt](路径) 格式
1660
+ * - 去掉路径里的锚点和查询参数后缀
1661
+ * - 只保留图片扩展名(.png/.jpg/.jpeg/.gif/.webp/.svg/.bmp)
1662
+ * - 跳过 http(s) 绝对 URL(这些是外部资源,本地没有文件)
1663
+ * - 跳过 data: URL
1664
+ */
1665
+ function extractImageRefs(md) {
1666
+ const refs = [];
1667
+ const seen = /* @__PURE__ */ new Set();
1668
+ const mdPattern = /!\[([^\]]*)\]\(([^)]+)\)/g;
1669
+ let m;
1670
+ while ((m = mdPattern.exec(md)) !== null) {
1671
+ const alt = m[1].trim();
1672
+ let url = m[2].trim();
1673
+ const titleMatch = url.match(/\s+"[^"]*"$/);
1674
+ if (titleMatch) url = url.slice(0, titleMatch.index).trim();
1675
+ url = url.split("#")[0];
1676
+ if (!url || url.startsWith("http://") || url.startsWith("https://") || url.startsWith("data:")) continue;
1677
+ if (!((url.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "") in IMAGE_EXT_MIME)) continue;
1678
+ const key = alt + "|" + url;
1679
+ if (seen.has(key)) continue;
1680
+ seen.add(key);
1681
+ refs.push({
1682
+ alt,
1683
+ refPath: url
1684
+ });
1685
+ }
1686
+ const htmlPattern = /<img\s+[^>]*>/gi;
1687
+ let hm;
1688
+ while ((hm = htmlPattern.exec(md)) !== null) {
1689
+ const tag = hm[0];
1690
+ const url = (tag.match(/src=['"]([^'"]+)['"]/i)?.[1] ?? "").trim().split("#")[0];
1691
+ const alt = (tag.match(/alt=['"]([^'"]*)['"]/i)?.[1] ?? "").trim();
1692
+ if (!url || url.startsWith("http://") || url.startsWith("https://") || url.startsWith("data:")) continue;
1693
+ if (!((url.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "") in IMAGE_EXT_MIME)) continue;
1694
+ const key = alt + "|" + url;
1695
+ if (seen.has(key)) continue;
1696
+ seen.add(key);
1697
+ refs.push({
1698
+ alt: alt || (url.split("/").pop() ?? url),
1699
+ refPath: url
1700
+ });
1701
+ }
1702
+ return refs;
1703
+ }
1704
+ /**
1705
+ * 把 markdown 图片引用解析成相对于扫描根目录的路径。
1706
+ * 处理 ./ ../ 等相对引用。
1707
+ *
1708
+ * @param refPath markdown 里的原始引用(如 ./img.png)
1709
+ * @param docRelPath 引用所在文档的相对路径(如 ch1/lesson1/notes.md)
1710
+ * @returns 相对根目录的标准化路径(如 ch1/lesson1/img.png),用 / 分隔
1711
+ *
1712
+ * 纯函数,便于测试。
1713
+ */
1714
+ function resolveImageRef(refPath, docRelPath) {
1715
+ const docDir = dirname(docRelPath).replace(/\\/g, "/");
1716
+ const normalized = refPath.replace(/\\/g, "/").replace(/^\.\//, "");
1717
+ const parts = docDir === "." ? [] : docDir.split("/").filter(Boolean);
1718
+ const refParts = normalized.split("/");
1719
+ for (const p of refParts) if (p === "..") parts.pop();
1720
+ else if (p !== "." && p !== "") parts.push(p);
1721
+ return parts.join("/");
1722
+ }
1723
+ /** 从图片文件名推断 alt 文本(去扩展名 + 数字前缀) */
1724
+ function inferImageTitle(filename) {
1725
+ let name = basename(filename);
1726
+ name = name.replace(/\.(png|jpe?g|gif|webp|svg|bmp)$/i, "");
1727
+ name = name.replace(/^(\d+[_-]\s*)/, "");
1728
+ name = name.replace(/[-_]+/g, " ").trim();
1729
+ if (/^[a-z]/.test(name)) name = name.charAt(0).toUpperCase() + name.slice(1);
1730
+ return name || basename(filename);
1731
+ }
1732
+ /**
1733
+ * 把独立图片文件 + markdown 引用合并去重。
1734
+ * 去重规则:按相对根目录路径归一。同一图既被 .md 引用又是独立文件 → 只留一份(image_file 优先,因为它肯定存在)。
1735
+ *
1736
+ * 纯函数,便于测试。
1737
+ */
1738
+ function dedupImages(fileImages, refImages) {
1739
+ const seen = /* @__PURE__ */ new Map();
1740
+ for (const img of fileImages) if (!seen.has(img.path)) seen.set(img.path, img);
1741
+ for (const img of refImages) if (!seen.has(img.path)) seen.set(img.path, img);
1742
+ return Array.from(seen.values());
1743
+ }
1744
+ async function walkDir(root, current, acc) {
1745
+ let entries;
1746
+ try {
1747
+ entries = await readdir(current, { withFileTypes: true });
1748
+ } catch {
1749
+ return;
1750
+ }
1751
+ for (const entry of entries) {
1752
+ if (EXCLUDED_DIRS.has(entry.name)) continue;
1753
+ const abs = join(current, entry.name);
1754
+ if (entry.isDirectory()) await walkDir(root, abs, acc);
1755
+ else if (entry.isFile()) {
1756
+ const ext = entry.name.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "";
1757
+ if (ext in EXT_KIND) {
1758
+ const rel = relative(root, abs).split(sep).join("/");
1759
+ acc.push({
1760
+ absPath: abs,
1761
+ relPath: rel,
1762
+ isImage: false
1763
+ });
1764
+ } else if (ext in IMAGE_EXT_MIME) {
1765
+ const rel = relative(root, abs).split(sep).join("/");
1766
+ acc.push({
1767
+ absPath: abs,
1768
+ relPath: rel,
1769
+ isImage: true
1770
+ });
1771
+ }
1772
+ }
1773
+ }
1774
+ }
1775
+ async function readFileWithKind(absPath, kind) {
1776
+ if (kind === "pdf") {
1777
+ const buf = await readFile(absPath);
1778
+ const { parsePdfText } = await import("../lib/pdf-text.js");
1779
+ return parsePdfText(buf);
1780
+ }
1781
+ if (kind === "pptx") {
1782
+ const buf = await readFile(absPath);
1783
+ const { parsePptx } = await import("../lib/pptx-parser.js");
1784
+ return (await parsePptx(buf)).markdown;
1785
+ }
1786
+ if (kind === "ipynb") {
1787
+ const raw = await readFile(absPath, "utf8");
1788
+ const { parseNotebook } = await import("./notebook-parser-ChbZBIKJ.mjs");
1789
+ return parseNotebook(raw).markdown;
1790
+ }
1791
+ if (kind === "rst" || kind === "rmd" || kind === "org" || kind === "adoc") {
1792
+ const raw = await readFile(absPath, "utf8");
1793
+ const parser = {
1794
+ rst: "rst-parser",
1795
+ rmd: "rmd-parser",
1796
+ org: "org-parser",
1797
+ adoc: "adoc-parser"
1798
+ }[kind];
1799
+ if (parser) try {
1800
+ const mod = await import(`./${parser}.js`);
1801
+ return (mod.parseRst ?? mod.parseRmd ?? mod.parseOrg ?? mod.parseAdoc)(raw).markdown;
1802
+ } catch {
1803
+ return raw;
1804
+ }
1805
+ return raw;
1806
+ }
1807
+ if (kind === "code") {
1808
+ const raw = await readFile(absPath, "utf8");
1809
+ const ext = absPath.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "";
1810
+ try {
1811
+ const { parseCode } = await import("./code-parser-BOOk9IWV.mjs");
1812
+ return parseCode(raw, ext).markdown;
1813
+ } catch {
1814
+ return "```\n" + raw + "\n```";
1815
+ }
1816
+ }
1817
+ const raw = await readFile(absPath, "utf8");
1818
+ return kind === "html" ? htmlToText(raw) : raw;
1819
+ }
1820
+ /** 路径自然排序:按段拆分,数字段按数值比较(02_ 在 10_ 前,不是字典序)。 */
1821
+ function naturalPathCompare(a, b) {
1822
+ const pa = a.split("/");
1823
+ const pb = b.split("/");
1824
+ for (let i = 0; i < Math.min(pa.length, pb.length); i++) {
1825
+ const na = pa[i].match(/^(\d+)/)?.[1];
1826
+ const nb = pb[i].match(/^(\d+)/)?.[1];
1827
+ if (na && nb && na !== nb) return Number(na) - Number(nb);
1828
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
1829
+ }
1830
+ return pa.length - pb.length;
1831
+ }
1832
+ //#endregion
1833
+ //#region src/vendor/file-classifier.ts
1834
+ /** 仓库元数据文件名(忽略大小写,匹配文件名 stem) */
1835
+ const META_FILE_NAMES = new Set([
1836
+ "license",
1837
+ "licence",
1838
+ "contributing",
1839
+ "code_of_conduct",
1840
+ "security",
1841
+ "changelog",
1842
+ "authors",
1843
+ "maintainers",
1844
+ "pull_request_template",
1845
+ "issue_template",
1846
+ "support",
1847
+ "citation"
1848
+ ]);
1849
+ /** 配套练习目录/文件名关键词(路径含这些子串即判定) */
1850
+ const LAB_KEYWORDS = [
1851
+ "/lab/",
1852
+ "/labs/",
1853
+ "/exercise/",
1854
+ "/exercises/",
1855
+ "/assignment/",
1856
+ "/assignments/",
1857
+ "/quiz/",
1858
+ "/quizzes/",
1859
+ "/homework/",
1860
+ "/practice/",
1861
+ "/solution/",
1862
+ "labs/",
1863
+ "exercises/",
1864
+ "assignments/"
1865
+ ];
1866
+ /** 示例代码目录关键词(路径含这些子串即判定,含根目录开头) */
1867
+ const EXAMPLE_KEYWORDS = [
1868
+ "/examples/",
1869
+ "/example/",
1870
+ "/demo/",
1871
+ "/demos/",
1872
+ "/samples/",
1873
+ "/sample/",
1874
+ "examples/",
1875
+ "example/",
1876
+ "demo/",
1877
+ "demos/",
1878
+ "samples/"
1879
+ ];
1880
+ /**
1881
+ * 判断一个文件是否是 section-intro:它是某个 section 的 README.md,
1882
+ * 且同 section 下有**更深一级的 README.md lesson**(不是 lab/notebook)。
1883
+ *
1884
+ * 例:
1885
+ * `lessons/3-NN/README.md` 是 section-intro ← 因为有 `lessons/3-NN/03-Perceptron/README.md`
1886
+ * `lessons/3-NN/03-Perceptron/README.md` 不是 ← 虽然 03-Perceptron/ 下有 lab/README.md,
1887
+ * 但 lab 不是 lesson,不能用来判定 lesson 是 intro
1888
+ */
1889
+ function isSectionIntro(path, siblingPaths) {
1890
+ const parts = path.split("/").filter(Boolean);
1891
+ const last = parts[parts.length - 1];
1892
+ if (!last || !(/^readme/i.test(last) || last === "index.md")) return false;
1893
+ const myDepth = parts.length;
1894
+ if (myDepth < 3) return false;
1895
+ const prefix = parts.slice(0, -1).join("/");
1896
+ return siblingPaths.some((sib) => {
1897
+ if (sib === path) return false;
1898
+ const sibLower = sib.toLowerCase();
1899
+ if (sibLower.includes("/lab/") || sibLower.includes("/exercise/") || sibLower.includes("/assignment/")) return false;
1900
+ if (sibLower.endsWith(".ipynb")) return false;
1901
+ const sibParts = sib.split("/").filter(Boolean);
1902
+ return sibParts.slice(0, -1).join("/").startsWith(prefix + "/") && sibParts.length > myDepth;
1903
+ });
1904
+ }
1905
+ /**
1906
+ * 主分类函数:first-match-wins 级联规则。
1907
+ *
1908
+ * @param path 文件路径(相对 repo 根,/ 分隔)
1909
+ * @param md 文件正文(已转成 markdown)
1910
+ * @param context 分类上下文(siblingPaths = 同批次所有文件路径)
1911
+ */
1912
+ function classifyFile(path, _md, context) {
1913
+ const lowerPath = path.toLowerCase();
1914
+ const parts = path.split("/").filter(Boolean);
1915
+ const stem = (parts[parts.length - 1] ?? path).replace(/\.[^.]+$/, "").toLowerCase();
1916
+ if (lowerPath.includes("translations/") || lowerPath.includes("translated_images/")) return {
1917
+ role: "translation",
1918
+ confidence: "high",
1919
+ keepAsLesson: false,
1920
+ world: null,
1921
+ reason: "路径含 translations/,是翻译副本"
1922
+ };
1923
+ if (META_FILE_NAMES.has(stem)) return {
1924
+ role: "meta",
1925
+ confidence: "high",
1926
+ keepAsLesson: false,
1927
+ world: null,
1928
+ reason: `文件名 ${stem} 是仓库元数据`
1929
+ };
1930
+ if (lowerPath.endsWith(".ipynb")) return {
1931
+ role: "uncertain",
1932
+ confidence: "low",
1933
+ keepAsLesson: true,
1934
+ world: null,
1935
+ reason: ".ipynb notebook——可能是主课程(fast.ai/d2l 风格)也可能是补充代码,交给 LLM 判断"
1936
+ };
1937
+ for (const kw of LAB_KEYWORDS) if (lowerPath.includes(kw)) return {
1938
+ role: "uncertain",
1939
+ confidence: "low",
1940
+ keepAsLesson: true,
1941
+ world: null,
1942
+ reason: `路径含 ${kw}——可能是配套练习也可能是课时正文,交给 LLM 判断`
1943
+ };
1944
+ for (const kw of EXAMPLE_KEYWORDS) if (lowerPath.includes(kw)) return {
1945
+ role: "uncertain",
1946
+ confidence: "low",
1947
+ keepAsLesson: true,
1948
+ world: null,
1949
+ reason: `路径含 ${kw}——可能是示例代码也可能是课时正文,交给 LLM 判断`
1950
+ };
1951
+ if (isSectionIntro(path, context.siblingPaths)) return {
1952
+ role: "section-intro",
1953
+ confidence: "high",
1954
+ keepAsLesson: false,
1955
+ world: "study",
1956
+ reason: "章节介绍页(同 section 有更深的 lesson 文件)"
1957
+ };
1958
+ return {
1959
+ role: "uncertain",
1960
+ confidence: "low",
1961
+ keepAsLesson: true,
1962
+ world: null,
1963
+ reason: "规则未命中高置信度分类,交给 LLM 判断"
1964
+ };
1965
+ }
1966
+ //#endregion
1967
+ //#region src/vendor/repo-fetcher.ts
1968
+ /**
1969
+ * 仓库导入器 —— 从学习型 GitHub 仓库构建课程结构。
1970
+ *
1971
+ * 核心策略:不依赖文件列表 API(api.github.com / api.jsdelivr.net 在很多网络环境下不可达),
1972
+ * 而是从 README.md 的 markdown 内部链接发现课程结构。
1973
+ *
1974
+ * 学习仓库的 README 通常有完整的课程大纲,链接指向每个课时:
1975
+ * - 形态 A(课程型): 链接指向 lessons/N-Topic/README.md + .ipynb
1976
+ * - 形态 B(单文件型): README 本身是超长文档,无子文件链接
1977
+ *
1978
+ * 数据源: cdn.jsdelivr.net/gh/{owner}/{repo}@{branch}/{path}(全球 CDN,无速率限制,
1979
+ * 在大多数网络环境下可用,包括 raw.githubusercontent.com 被墙的情况)
1980
+ *
1981
+ * 纯函数设计: fetchFn 由调用方注入(生产用 global fetch,测试用 mock)。
1982
+ */
1983
+ /** CDN URL 构造 */
1984
+ function cdnUrl(owner, repo, branch, path) {
1985
+ return `https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${path.replace(/^\.\//, "").replace(/^\//, "")}`;
1986
+ }
1987
+ /** 代码文件扩展名(代码即教学内容) */
1988
+ const CODE_EXTENSIONS = [
1989
+ ".py",
1990
+ ".js",
1991
+ ".jsx",
1992
+ ".ts",
1993
+ ".tsx",
1994
+ ".mjs",
1995
+ ".cjs",
1996
+ ".go",
1997
+ ".rs",
1998
+ ".java",
1999
+ ".kt",
2000
+ ".kts",
2001
+ ".scala",
2002
+ ".c",
2003
+ ".h",
2004
+ ".cpp",
2005
+ ".cc",
2006
+ ".cxx",
2007
+ ".hpp",
2008
+ ".cs",
2009
+ ".rb",
2010
+ ".php",
2011
+ ".swift",
2012
+ ".sh",
2013
+ ".bash",
2014
+ ".zsh",
2015
+ ".ps1",
2016
+ ".lua",
2017
+ ".r",
2018
+ ".jl",
2019
+ ".dart",
2020
+ ".clj",
2021
+ ".ex",
2022
+ ".exs",
2023
+ ".erl",
2024
+ ".hs",
2025
+ ".ml",
2026
+ ".fs",
2027
+ ".sql",
2028
+ ".pl",
2029
+ ".elm"
2030
+ ];
2031
+ /**
2032
+ * 从 README 的 markdown 链接提取内部文件引用。
2033
+ * 只看相对路径(非 http/锚点),且指向 .md/.ipynb 文件。
2034
+ */
2035
+ function extractInternalLinks(readmeMd) {
2036
+ const linkPattern = /\[([^\]]*)\]\(([^)]+)\)/g;
2037
+ const seen = /* @__PURE__ */ new Set();
2038
+ const files = [];
2039
+ let m;
2040
+ while ((m = linkPattern.exec(readmeMd)) !== null) {
2041
+ const title = m[1].trim();
2042
+ let href = m[2].trim();
2043
+ href = href.split("#")[0];
2044
+ if (!href || href.startsWith("http") || href.startsWith("mailto:")) continue;
2045
+ href = href.replace(/^\.\//, "");
2046
+ let kind = "other";
2047
+ if (href.endsWith(".md") || href.endsWith(".mdx")) kind = "md";
2048
+ else if (href.endsWith(".ipynb")) kind = "ipynb";
2049
+ else if (href.endsWith(".rst")) kind = "rst";
2050
+ else if (href.endsWith(".rmd")) kind = "rmd";
2051
+ else if (href.endsWith(".org")) kind = "org";
2052
+ else if (href.endsWith(".adoc") || href.endsWith(".asciidoc")) kind = "adoc";
2053
+ else if (CODE_EXTENSIONS.some((ext) => href.endsWith(ext))) kind = "code";
2054
+ else continue;
2055
+ if (seen.has(href)) continue;
2056
+ seen.add(href);
2057
+ files.push({
2058
+ path: href,
2059
+ title: title || href,
2060
+ kind
2061
+ });
2062
+ }
2063
+ return files;
2064
+ }
2065
+ /**
2066
+ * 过滤:只保留像课时文件的(排除 translations/、lab/、translations、LICENSE 等)
2067
+ */
2068
+ function filterLessonFiles(files) {
2069
+ return files.filter((f) => {
2070
+ const p = f.path.toLowerCase();
2071
+ if (p.includes("translations/")) return false;
2072
+ if (p.endsWith("license.md") || p.endsWith("contributing.md") || p.endsWith("code_of_conduct.md")) return false;
2073
+ return true;
2074
+ });
2075
+ }
2076
+ /**
2077
+ * 规则高置信度检测:仓库是否已用编号目录组织好课程结构。
2078
+ *
2079
+ * 判定依据:文件路径里有 ≥3 个不同的编号顶层目录(如 lessons/1-Intro/,
2080
+ * lessons/2-Symbolic/, lessons/3-NeuralNetworks/)。编号前缀 = 作者刻意组织。
2081
+ *
2082
+ * 这是确定性判断(规则管),不交给 LLM。
2083
+ * 命中 → pattern: "well-organized",下游只判 world 不重组章节。
2084
+ */
2085
+ function detectWellOrganized(files) {
2086
+ const topicDirs = /* @__PURE__ */ new Set();
2087
+ const ORGANIZED_PREFIXES = /^(week|unit|part|topic|lecture|session|day|step)(\d|[-_])/i;
2088
+ for (const f of files) {
2089
+ const parts = f.path.split("/").filter(Boolean);
2090
+ for (const part of parts) {
2091
+ if (part.includes(".")) continue;
2092
+ if (part.match(/^(\d+[-_])/i)) {
2093
+ topicDirs.add(part.toLowerCase());
2094
+ break;
2095
+ }
2096
+ if (ORGANIZED_PREFIXES.test(part)) {
2097
+ topicDirs.add(part.toLowerCase());
2098
+ break;
2099
+ }
2100
+ }
2101
+ }
2102
+ return topicDirs.size >= 3;
2103
+ }
2104
+ /**
2105
+ * 检测仓库形态。
2106
+ *
2107
+ * 原则:规则管确定性,不确定的给 LLM 兜底(通过下游 analyzeCourseStructure)。
2108
+ *
2109
+ * - well-organized: README 链接 ≥1 个且路径有编号/组织目录(数字/week/unit/topic) → 保留原始结构
2110
+ * - course: README 链接里有 ≥1 个课程文件(.md/.ipynb/.py 等) → LLM 重组
2111
+ * - single-file: 无子文件链接但 README 有实质教学正文(prose >1000 字)
2112
+ * - docs-rich: README 无链接但文件树可能有内容 → 不急着拒绝,让 fetchRepoInventory 用文件树补全
2113
+ * - unsupported: awesome-list(外链占比>60%且正文极少)
2114
+ */
2115
+ function detectRepoPattern(readmeMd) {
2116
+ const lessonLinks = filterLessonFiles(extractInternalLinks(readmeMd)).filter((f) => f.kind !== "other");
2117
+ if (lessonLinks.length >= 1) {
2118
+ if (detectWellOrganized(lessonLinks)) return {
2119
+ pattern: "well-organized",
2120
+ reason: `README 含 ${lessonLinks.length} 个文件,路径有编号目录组织,判定为已组织好的课程仓库`,
2121
+ lessonFiles: lessonLinks
2122
+ };
2123
+ return {
2124
+ pattern: "course",
2125
+ reason: `README 含 ${lessonLinks.length} 个内部课程文件链接,判定为课程型仓库`,
2126
+ lessonFiles: lessonLinks
2127
+ };
2128
+ }
2129
+ const proseChars = readmeMd.replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "").replace(/^---[\s\S]*?---/m, "").replace(/\s/g, "").length;
2130
+ if (proseChars > 1e3) return {
2131
+ pattern: "single-file",
2132
+ reason: `README 无子文件链接,但实质正文 ${proseChars} 字,判定为单文件型`,
2133
+ readmeLength: readmeMd.length
2134
+ };
2135
+ const externalLinks = (readmeMd.match(/\]\(https?:\/\//g) || []).length;
2136
+ const totalLinks = (readmeMd.match(/\]\(/g) || []).length;
2137
+ if (totalLinks > 10 && externalLinks / totalLinks > .6 && proseChars < 500) return {
2138
+ pattern: "unsupported",
2139
+ reason: `README 外链占比 ${(externalLinks / totalLinks * 100).toFixed(0)}%,实质正文仅 ${proseChars} 字,疑似 awesome-list 资源索引(非课程)`
2140
+ };
2141
+ return {
2142
+ pattern: "docs-rich",
2143
+ reason: `README 无课程文件链接,实质正文 ${proseChars} 字 → 将用文件树补全课程文件`
2144
+ };
2145
+ }
2146
+ /**
2147
+ * 并发拉取多个 markdown 文件(5 并发,防 CDN 过载)。
2148
+ *
2149
+ * @param files 要拉取的文件列表
2150
+ * @param owner repo owner
2151
+ * @param repo repo name
2152
+ * @param branch 分支名
2153
+ * @param fetchFn 注入的 fetch 函数
2154
+ * @param onProgress 进度回调 (done, total, currentPath)
2155
+ */
2156
+ async function fetchMarkdownContents(files, owner, repo, branch, fetchFn, onProgress) {
2157
+ const ok = [];
2158
+ const failed = [];
2159
+ const CONCURRENCY = 5;
2160
+ let done = 0;
2161
+ for (let i = 0; i < files.length; i += CONCURRENCY) {
2162
+ const batch = files.slice(i, i + CONCURRENCY);
2163
+ const results = await Promise.allSettled(batch.map(async (f) => {
2164
+ const r = await fetchFn(cdnUrl(owner, repo, branch, f.path));
2165
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
2166
+ const text = await r.text();
2167
+ if (f.path.toLowerCase().endsWith(".ipynb")) try {
2168
+ const { parseNotebook } = await import("./notebook-parser-ChbZBIKJ.mjs");
2169
+ const nbResult = parseNotebook(text);
2170
+ return {
2171
+ path: f.path,
2172
+ title: f.title,
2173
+ md: nbResult.markdown
2174
+ };
2175
+ } catch {
2176
+ return {
2177
+ path: f.path,
2178
+ title: f.title,
2179
+ md: text
2180
+ };
2181
+ }
2182
+ const lowerPath = f.path.toLowerCase();
2183
+ if (lowerPath.endsWith(".rst") || lowerPath.endsWith(".rmd") || lowerPath.endsWith(".org") || lowerPath.endsWith(".adoc") || lowerPath.endsWith(".asciidoc")) {
2184
+ const parserName = {
2185
+ ".rst": "rst-parser",
2186
+ ".rmd": "rmd-parser",
2187
+ ".org": "org-parser",
2188
+ ".adoc": "adoc-parser",
2189
+ ".asciidoc": "adoc-parser"
2190
+ }[lowerPath.match(/\.[^.]+$/)?.[0] ?? ""];
2191
+ if (parserName) try {
2192
+ const mod = await import(`./${parserName}.js`);
2193
+ const fn = mod.parseRst ?? mod.parseRmd ?? mod.parseOrg ?? mod.parseAdoc;
2194
+ return {
2195
+ path: f.path,
2196
+ title: f.title,
2197
+ md: fn(text).markdown
2198
+ };
2199
+ } catch {
2200
+ return {
2201
+ path: f.path,
2202
+ title: f.title,
2203
+ md: text
2204
+ };
2205
+ }
2206
+ }
2207
+ if (CODE_EXTENSIONS.some((ext) => lowerPath.endsWith(ext))) {
2208
+ const ext = lowerPath.split(".").pop() ?? "";
2209
+ try {
2210
+ const { parseCode } = await import("./code-parser-BOOk9IWV.mjs");
2211
+ return {
2212
+ path: f.path,
2213
+ title: f.title,
2214
+ md: parseCode(text, ext).markdown
2215
+ };
2216
+ } catch {
2217
+ return {
2218
+ path: f.path,
2219
+ title: f.title,
2220
+ md: "```\n" + text + "\n```"
2221
+ };
2222
+ }
2223
+ }
2224
+ return {
2225
+ path: f.path,
2226
+ title: f.title,
2227
+ md: text
2228
+ };
2229
+ }));
2230
+ for (let j = 0; j < results.length; j++) {
2231
+ done++;
2232
+ const file = batch[j];
2233
+ const result = results[j];
2234
+ if (file) onProgress?.(done, files.length, file.path);
2235
+ if (result && result.status === "fulfilled") ok.push(result.value);
2236
+ else if (result && result.status === "rejected") failed.push({
2237
+ path: file?.path ?? "(unknown)",
2238
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason)
2239
+ });
2240
+ }
2241
+ }
2242
+ return {
2243
+ ok,
2244
+ failed
2245
+ };
2246
+ }
2247
+ /**
2248
+ * 把课程型仓库的多个课时文件合并成 ParsedCourse 结构。
2249
+ *
2250
+ * v3 改进:集成 file-classifier 规则引擎。
2251
+ * - 先对每个文件调 classifyFile 判定角色(lesson/notebook/lab/section-intro/uncertain 等)
2252
+ * - keepAsLesson=false 的文件(translation/meta/notebook/lab/example/section-intro)不进 lesson 列表
2253
+ * - section-intro 的正文追加到同 section 摘要(作为章节概述)
2254
+ * - uncertain 的文件进 lesson 列表但标 uncertain=true,后续 LLM 结构化时优先判断 keep/skip
2255
+ *
2256
+ * 分组策略保留 v2 的"第一个非通用目录"启发式(减少碎片)。
2257
+ *
2258
+ * 每个文件的内部 H2/H3 → 该 section 下的 lessons;无 H2/H3 则整个文件作一个 lesson。
2259
+ */
2260
+ function buildCourseFromFiles(courseTitle, files) {
2261
+ const allPaths = files.map((f) => f.path);
2262
+ for (const file of files) if (!file.classification) file.classification = classifyFile(file.path, file.md, { siblingPaths: allPaths });
2263
+ const groupMap = /* @__PURE__ */ new Map();
2264
+ const groupOrder = [];
2265
+ const GENERIC_DIRS = new Set([
2266
+ "lessons",
2267
+ "docs",
2268
+ "doc",
2269
+ "src",
2270
+ "content",
2271
+ "modules",
2272
+ "chapters",
2273
+ "tutorials",
2274
+ "guide",
2275
+ "week",
2276
+ "unit",
2277
+ "part",
2278
+ "topic",
2279
+ "lecture",
2280
+ "session",
2281
+ "day",
2282
+ "step"
2283
+ ]);
2284
+ /**
2285
+ * 计算文件的 section 分组键(和 lesson 用同一个逻辑)。
2286
+ */
2287
+ function sectionKeyOf(path) {
2288
+ const parts = path.split("/").filter(Boolean);
2289
+ const dirParts = parts[parts.length - 1]?.match(/^readme/i) || parts[parts.length - 1] === "index.md" ? parts.slice(0, -1) : parts;
2290
+ const specificDir = dirParts.find((p) => !GENERIC_DIRS.has(p.toLowerCase()) && !/\.(md|mdx)$/i.test(p));
2291
+ if (dirParts.length >= 2 && specificDir) {
2292
+ const gk = specificDir.replace(/\.md$/i, "");
2293
+ return {
2294
+ groupKey: gk,
2295
+ sectionTitle: gk
2296
+ };
2297
+ } else if (dirParts.length === 1) return {
2298
+ groupKey: path,
2299
+ sectionTitle: dirParts[0].replace(/\.md$/i, "")
2300
+ };
2301
+ return {
2302
+ groupKey: path,
2303
+ sectionTitle: parts[parts.length - 1] ?? path
2304
+ };
2305
+ }
2306
+ const sortedFiles = [...files].sort((a, b) => a.path.localeCompare(b.path));
2307
+ for (const file of sortedFiles) {
2308
+ const classification = file.classification;
2309
+ const { groupKey, sectionTitle } = sectionKeyOf(file.path);
2310
+ if (!groupMap.has(groupKey)) {
2311
+ groupMap.set(groupKey, {
2312
+ sectionTitle,
2313
+ orderKey: file.path,
2314
+ lessons: []
2315
+ });
2316
+ if (!groupOrder.includes(groupKey)) groupOrder.push(groupKey);
2317
+ }
2318
+ const group = groupMap.get(groupKey);
2319
+ if (!classification.keepAsLesson) {
2320
+ if (classification.role === "section-intro") group.pendingIntro = file.md;
2321
+ continue;
2322
+ }
2323
+ const lowerP = file.path.toLowerCase();
2324
+ const isNotebook = lowerP.endsWith(".ipynb");
2325
+ const isLab = /\/lab\//.test(lowerP) || /\/labs\//.test(lowerP) || /\/exercise/.test(lowerP);
2326
+ const isExample = /\/examples?\//.test(lowerP) || /\/demos?\//.test(lowerP);
2327
+ if (isNotebook || isLab || isExample) {
2328
+ const h1Match = file.md.match(/^#\s+(.+)$/m);
2329
+ const lessonTitle = h1Match ? h1Match[1].trim() : file.title;
2330
+ group.lessons.push({
2331
+ title: lessonTitle,
2332
+ anchor: file.path.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
2333
+ body: file.md,
2334
+ uncertain: true,
2335
+ sourceFilePath: file.path,
2336
+ world: null
2337
+ });
2338
+ continue;
2339
+ }
2340
+ const parsed = parseMarkdownToCourse(file.md);
2341
+ const parsedLessonCount = parsed.sections.reduce((sum, s) => sum + s.lessons.length, 0);
2342
+ const isUncertain = classification.role === "uncertain";
2343
+ const fileWorld = classification.world;
2344
+ const lessonCandidates = parsedLessonCount > 0 ? parsed.sections.filter((s) => s.lessons.length > 0).flatMap((s) => s.lessons.map((l) => ({
2345
+ title: l.title,
2346
+ anchor: l.title.toLowerCase().replace(/\s+/g, "-"),
2347
+ body: l.body,
2348
+ uncertain: isUncertain,
2349
+ sourceFilePath: file.path,
2350
+ world: fileWorld
2351
+ }))) : (() => {
2352
+ const h1Match = file.md.match(/^#\s+(.+)$/m);
2353
+ const lessonTitle = h1Match ? h1Match[1].trim() : file.title;
2354
+ return [{
2355
+ title: lessonTitle,
2356
+ anchor: lessonTitle.toLowerCase().replace(/\s+/g, "-"),
2357
+ body: file.md,
2358
+ uncertain: isUncertain,
2359
+ sourceFilePath: file.path,
2360
+ world: fileWorld
2361
+ }];
2362
+ })();
2363
+ group.lessons.push(...lessonCandidates);
2364
+ }
2365
+ for (const key of groupOrder) {
2366
+ const g = groupMap.get(key);
2367
+ if (g.pendingIntro && g.lessons.length > 0) g.lessons[0].body = `> **📖 章节概述**\n>\n> ${g.pendingIntro.replace(/\n/g, "\n> ")}\n\n---\n\n${g.lessons[0].body}`;
2368
+ }
2369
+ return {
2370
+ title: courseTitle,
2371
+ sections: groupOrder.filter((key) => groupMap.get(key).lessons.length > 0).map((key) => {
2372
+ const g = groupMap.get(key);
2373
+ const practiceCount = g.lessons.filter((l) => l.world === "practice").length;
2374
+ const studyCount = g.lessons.filter((l) => l.world === "study").length;
2375
+ return {
2376
+ title: g.sectionTitle,
2377
+ anchor: g.sectionTitle.toLowerCase().replace(/\s+/g, "-"),
2378
+ world: practiceCount > 0 && studyCount === 0 ? "practice" : "study",
2379
+ lessons: g.lessons
2380
+ };
2381
+ })
2382
+ };
2383
+ }
2384
+ /** 从 .md 路径列表构造 DiscoveredFile[](复用 filterLessonFiles 排除规则 + 标题推断)。 */
2385
+ function pathsToDiscoveredFiles(paths) {
2386
+ const files = [];
2387
+ const seen = /* @__PURE__ */ new Set();
2388
+ for (const p of paths) {
2389
+ const lower = p.toLowerCase();
2390
+ if (seen.has(p)) continue;
2391
+ let kind = "other";
2392
+ if (lower.endsWith(".md") || lower.endsWith(".mdx")) kind = "md";
2393
+ else if (lower.endsWith(".ipynb")) kind = "ipynb";
2394
+ else if (lower.endsWith(".rst")) kind = "rst";
2395
+ else if (lower.endsWith(".rmd")) kind = "rmd";
2396
+ else if (lower.endsWith(".org")) kind = "org";
2397
+ else if (lower.endsWith(".adoc") || lower.endsWith(".asciidoc")) kind = "adoc";
2398
+ else if (CODE_EXTENSIONS.some((ext) => lower.endsWith(ext))) kind = "code";
2399
+ else continue;
2400
+ if (lower.includes("node_modules/") || lower.startsWith(".git/") || lower.includes("translations/")) continue;
2401
+ if (lower.endsWith("license.md") || lower.endsWith("contributing.md") || lower.endsWith("code_of_conduct.md")) continue;
2402
+ seen.add(p);
2403
+ const parts = p.split("/").filter(Boolean);
2404
+ const last = parts[parts.length - 1] ?? p;
2405
+ const title = last.replace(/\.(md|mdx|ipynb|rst|rmd|org|adoc|asciidoc|py|js|jsx|ts|tsx|go|rs|java|c|cpp|rb|sh|sql|lua|r|jl|dart|scala|kt|cs|php|swift|hs|clj|ex|erl|ml|fs|pl|elm)$/i, "").replace(/^readme$/i, parts[parts.length - 2] ?? last);
2406
+ files.push({
2407
+ path: p,
2408
+ title,
2409
+ kind
2410
+ });
2411
+ }
2412
+ return files;
2413
+ }
2414
+ /**
2415
+ * 主方式:GitHub Tree API 一次拿全仓文件树。
2416
+ * https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1
2417
+ * 返回 { tree: [{ path, type }] }。筛 blob + .md/.ipynb。
2418
+ * 网络失败/限流 → 抛错(由调用方降级)。
2419
+ */
2420
+ /**
2421
+ * 用 Node 的 https 模块拉取(可单独控制 SSL 验证)。
2422
+ * GitHub Tree API 的证书链在部分环境(Node 内置 CA)验证失败(中间证书缺失),
2423
+ * 对这一个获取公开文件树的请求用 rejectUnauthorized:false 绕过。
2424
+ * 风险可控:获取的是公开文件路径列表(无敏感数据),且只用于此请求。
2425
+ */
2426
+ function httpsGet(url, opts = {}) {
2427
+ return new Promise((resolve) => {
2428
+ const req = https.get(url, {
2429
+ headers: {
2430
+ "User-Agent": "lookatstudy-import",
2431
+ ...opts.headers
2432
+ },
2433
+ rejectUnauthorized: opts.rejectUnauthorized ?? true,
2434
+ timeout: 2e4
2435
+ }, (res) => {
2436
+ let body = "";
2437
+ res.on("data", (d) => {
2438
+ body += d.toString();
2439
+ });
2440
+ res.on("end", () => resolve({
2441
+ ok: res.statusCode === 200,
2442
+ status: res.statusCode,
2443
+ body
2444
+ }));
2445
+ });
2446
+ req.on("error", (e) => resolve({
2447
+ ok: false,
2448
+ error: e.message
2449
+ }));
2450
+ req.on("timeout", () => {
2451
+ req.destroy();
2452
+ resolve({
2453
+ ok: false,
2454
+ error: "timeout"
2455
+ });
2456
+ });
2457
+ });
2458
+ }
2459
+ async function fetchRepoFileTree(owner, repo, branch, _fetchFn) {
2460
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
2461
+ try {
2462
+ const r = await httpsGet(apiUrl, { rejectUnauthorized: false });
2463
+ console.error(`[import] GitHub Tree API: HTTP ${r.status ?? r.error}`);
2464
+ if (r.ok && r.body) {
2465
+ const paths = (JSON.parse(r.body).tree ?? []).filter((n) => n.type === "blob").map((n) => n.path);
2466
+ if (paths.length > 0) return {
2467
+ paths,
2468
+ source: "github-tree-api"
2469
+ };
2470
+ }
2471
+ } catch (e) {
2472
+ console.error(`[import] GitHub Tree API 异常: ${e instanceof Error ? e.message : e}`);
2473
+ }
2474
+ return {
2475
+ paths: [],
2476
+ source: "none"
2477
+ };
2478
+ }
2479
+ /** 文件数上限(防爆,和 IPC handler 一致) */
2480
+ const MAX_FILES = 500;
2481
+ /**
2482
+ * 从 GitHub 仓库构建课程结构 —— 纯编排函数。
2483
+ *
2484
+ * 流程: fetch README → detectRepoPattern → 发现文件树 → fetchMarkdownContents
2485
+ * → classifyFile(在 buildCourseFromFiles 内)→ buildCourseFromFiles
2486
+ *
2487
+ * 不落库、不发进度事件(onProgress 回调只传消息字符串,由调用方决定怎么用)。
2488
+ *
2489
+ * @param owner GitHub owner
2490
+ * @param repo GitHub repo
2491
+ * @param branch 起始分支(README 先试 main 再试 master)
2492
+ * @param fetchFn 注入的 fetch(生产用 global fetch,测试用 mock)
2493
+ * @param onProgress 进度回调(可选)
2494
+ */
2495
+ async function importRepoToParsedCourse(owner, repo, branch, fetchFn, onProgress) {
2496
+ const send = (msg) => onProgress?.(msg);
2497
+ send("正在拉取 README…");
2498
+ const branches = branch === "master" ? ["master", "main"] : ["main", "master"];
2499
+ let readmeMd = null;
2500
+ let readmeBranch = branch;
2501
+ for (const br of branches) try {
2502
+ const r = await fetchFn(cdnUrl(owner, repo, br, "README.md"));
2503
+ if (r.ok) {
2504
+ readmeMd = await r.text();
2505
+ readmeBranch = br;
2506
+ break;
2507
+ }
2508
+ } catch {}
2509
+ if (!readmeMd) throw new Error(`无法拉取 README(试过分支: ${branches.join(", ")})`);
2510
+ send(`README 拉取成功(${readmeMd.length} 字符,分支 ${readmeBranch})`);
2511
+ const detection = detectRepoPattern(readmeMd);
2512
+ if (detection.pattern === "unsupported") throw new Error(`仓库不支持: ${detection.reason}`);
2513
+ if (detection.pattern === "single-file") return {
2514
+ course: parseMarkdownToCourse(readmeMd),
2515
+ detection,
2516
+ fetchedFiles: [],
2517
+ readmeBranch,
2518
+ readmeMd
2519
+ };
2520
+ let lessonFiles = filterLessonFiles(detection.lessonFiles ?? []);
2521
+ const readmeLinkCount = lessonFiles.length;
2522
+ if (readmeLinkCount < 5) try {
2523
+ send("README 链接较少,扫描文件树补充…");
2524
+ const tree = await fetchRepoFileTree(owner, repo, readmeBranch, fetchFn);
2525
+ if (tree.paths.length > 0) {
2526
+ const treeLessonFiles = filterLessonFiles(pathsToDiscoveredFiles(tree.paths)).filter((f) => f.kind !== "other");
2527
+ if (treeLessonFiles.length > lessonFiles.length) {
2528
+ lessonFiles = treeLessonFiles;
2529
+ send(`文件树发现 ${lessonFiles.length} 个课时文件(来源: ${tree.source})`);
2530
+ }
2531
+ }
2532
+ } catch {
2533
+ send("文件树拉取失败,使用 README 链接发现");
2534
+ }
2535
+ else send(`README 链接发现 ${readmeLinkCount} 个课时文件(人工策展,优先使用)`);
2536
+ if (lessonFiles.length === 0) {
2537
+ send("未发现课时文件,降级为单文件导入");
2538
+ return {
2539
+ course: parseMarkdownToCourse(readmeMd),
2540
+ detection: {
2541
+ ...detection,
2542
+ pattern: "single-file",
2543
+ reason: "无课时文件,降级"
2544
+ },
2545
+ fetchedFiles: [],
2546
+ readmeBranch,
2547
+ readmeMd
2548
+ };
2549
+ }
2550
+ if (lessonFiles.length > MAX_FILES) {
2551
+ send(`文件数 ${lessonFiles.length} 超过上限 ${MAX_FILES},截断`);
2552
+ if (readmeLinkCount > 0 && readmeLinkCount < MAX_FILES) {
2553
+ const readmePaths = new Set(filterLessonFiles(detection.lessonFiles ?? []).map((f) => f.path));
2554
+ const fromReadme = lessonFiles.filter((f) => readmePaths.has(f.path));
2555
+ const fromTree = lessonFiles.filter((f) => !readmePaths.has(f.path)).slice(0, MAX_FILES - fromReadme.length);
2556
+ lessonFiles = [...fromReadme, ...fromTree];
2557
+ } else lessonFiles = lessonFiles.slice(0, MAX_FILES);
2558
+ }
2559
+ send(`检测到课程型仓库(${lessonFiles.length} 个文件),开始拉取…`);
2560
+ const fetchResult = await fetchMarkdownContents(lessonFiles, owner, repo, readmeBranch, fetchFn, (done, total, path) => send(`拉取 ${done}/${total}: ${path}`));
2561
+ if (fetchResult.ok.length === 0) throw new Error(`检测到 ${lessonFiles.length} 个课时文件,但全部拉取失败。可能是网络受限。请稍后重试或改用「粘贴 Markdown」方式手动导入。`);
2562
+ const h1Match = readmeMd.match(/^#\s+(.+)$/m);
2563
+ const course = buildCourseFromFiles(h1Match ? h1Match[1].trim() : repo, fetchResult.ok);
2564
+ send(`解析完成:${course.sections.length} 章节,构建课程…`);
2565
+ return {
2566
+ course,
2567
+ detection,
2568
+ fetchedFiles: fetchResult.ok,
2569
+ readmeBranch,
2570
+ readmeMd
2571
+ };
2572
+ }
2573
+ //#endregion
2574
+ //#region src/cards.ts
2575
+ /**
2576
+ * Status glyph for one lesson line on the map (LookatStudy's map icons).
2577
+ * @param kind - lesson kind.
2578
+ * @param status - lesson status.
2579
+ * @returns the glyph prefix.
2580
+ */
2581
+ function statusGlyph(kind, status) {
2582
+ if (kind === "exam") return "🎯";
2583
+ if (status === "mastered") return "👑";
2584
+ if (status === "in_progress") return "📖";
2585
+ if (status === "available") return "⭐";
2586
+ return "🔒";
2587
+ }
2588
+ /**
2589
+ * Display lines for an import result.
2590
+ * @param value - import tool value.
2591
+ * @returns card lines.
2592
+ */
2593
+ function importLines(value) {
2594
+ const lines = [`📘 ${value.title}`, `${value.sections} sections · ${value.lessons} lessons · id ${value.courseId}`];
2595
+ if (value.firstLessonId !== null) lines.push(`Start at “${value.firstLessonTitle}” (${value.firstLessonId})`);
2596
+ return lines;
2597
+ }
2598
+ /**
2599
+ * Display lines for the skill-tree map.
2600
+ * @param value - map tool value.
2601
+ * @returns card lines.
2602
+ */
2603
+ function mapLines(value) {
2604
+ const lines = [`🗺 ${value.title} — ${value.counts.mastered}/${value.counts.total} mastered`];
2605
+ for (const section of value.tree) {
2606
+ lines.push(`▍${section.title}`);
2607
+ for (const lesson of section.lessons) {
2608
+ const mastery = lesson.masteryPct === null ? "" : ` · ${lesson.masteryPct}%${lesson.crown >= 4 ? " 👑" : ""}`;
2609
+ const weak = lesson.weakConcepts > 0 ? ` · ⚡${lesson.weakConcepts}` : "";
2610
+ const friction = lesson.frictionCount > 0 ? ` · 😣${lesson.frictionCount}` : "";
2611
+ lines.push(` ${statusGlyph(lesson.kind, lesson.status)} ${lesson.title}${mastery}${weak}${friction}`);
2612
+ }
2613
+ }
2614
+ return lines;
2615
+ }
2616
+ /**
2617
+ * Display line for a graded answer.
2618
+ * @param value - answer tool value.
2619
+ * @returns single feedback line.
2620
+ */
2621
+ function answerLine(value) {
2622
+ const mark = value.correct ? "✓ correct" : "✗ incorrect";
2623
+ const crown = value.mastered ? " · 👑 mastered" : "";
2624
+ return `${mark} — mastery ${value.prevMasteryPct}% → ${value.newMasteryPct}% (crown ${value.crown})${crown}`;
2625
+ }
2626
+ /**
2627
+ * Display lines for the due-review list.
2628
+ * @param value - due tool value.
2629
+ * @returns card lines.
2630
+ */
2631
+ function dueLines(value) {
2632
+ if (value.total === 0) return ["🎉 No reviews due — everything is scheduled ahead."];
2633
+ const lines = [`🔁 ${value.total} due`];
2634
+ for (const item of value.due) {
2635
+ const overdue = item.overdueDays > 0 ? ` · ${item.overdueDays}d overdue` : "";
2636
+ lines.push(` ⏰ ${item.lessonTitle} — ${item.courseTitle}${overdue}`);
2637
+ }
2638
+ return lines;
2639
+ }
2640
+ /**
2641
+ * Display line for a recorded review grade.
2642
+ * @param value - review tool value.
2643
+ * @returns single schedule line.
2644
+ */
2645
+ function reviewLine(value) {
2646
+ return `🔁 quality ${value.quality}/5 — next review in ${value.intervalDays}d (${value.repetitions} in a row), due ${value.dueAt.slice(0, 10)}`;
2647
+ }
2648
+ /**
2649
+ * Display lines for a completed lesson.
2650
+ * @param value - complete tool value.
2651
+ * @returns card lines.
2652
+ */
2653
+ function completeLines(value) {
2654
+ const lines = [`🎓 Mastered “${value.lessonTitle}”`];
2655
+ for (const title of value.unlockedLessonTitles) lines.push(`🔓 Unlocked “${title}”`);
2656
+ lines.push(`🔁 First review due ${value.reviewDueAt.slice(0, 10)}`);
2657
+ if (value.courseComplete) lines.push("🏁 Course complete!");
2658
+ return lines;
2659
+ }
2660
+ //#endregion
2661
+ //#region src/tools.ts
2662
+ /**
2663
+ * The `study_*` tool surface, ported from LookatStudy's agent contract:
2664
+ * import (markdown / folder / GitHub), course map, lesson content with
2665
+ * concepts/starters/memory, KC-attributed answer recording with
2666
+ * mastery-driven progression, spaced reviews, mastery proposals, friction
2667
+ * logging, learner memory, Cornell notes, and soul switching. All state
2668
+ * mutations persist synchronously through the shared store.
2669
+ * @module dsh-plugin-lookatstudy/tools
2670
+ */
2671
+ /** SM-2 quality grades, shared by the parameter enum and the state layer. */
2672
+ const QUALITIES = [
2673
+ 0,
2674
+ 1,
2675
+ 2,
2676
+ 3,
2677
+ 4,
2678
+ 5
2679
+ ];
2680
+ const LESSON_STATUSES = [
2681
+ "locked",
2682
+ "available",
2683
+ "in_progress",
2684
+ "mastered"
2685
+ ];
2686
+ const LESSON_KINDS = [
2687
+ "study",
2688
+ "practice",
2689
+ "exam"
2690
+ ];
2691
+ const FRICTION_CATEGORIES = [
2692
+ "confused",
2693
+ "blocked",
2694
+ "frustrated"
2695
+ ];
2696
+ const MEMORY_CATEGORIES = [
2697
+ "global",
2698
+ "pattern",
2699
+ "lesson"
2700
+ ];
2701
+ const NOTE_ZONES = [
2702
+ "understand",
2703
+ "record",
2704
+ "practice"
2705
+ ];
2706
+ const NOTE_SOURCES = [
2707
+ "ai",
2708
+ "content",
2709
+ "chat"
2710
+ ];
2711
+ const MODES = [
2712
+ "direct",
2713
+ "guide",
2714
+ "practice"
2715
+ ];
2716
+ const nullableInteger = { oneOf: [{ type: "integer" }, { type: "null" }] };
2717
+ const nullableString = { oneOf: [{ type: "string" }, { type: "null" }] };
2718
+ /**
2719
+ * Fail loud when an importer produced no lessons — a course with an empty
2720
+ * path is useless and hides upstream parsing problems. Runs before any state
2721
+ * mutation so a failed import leaves persisted state untouched.
2722
+ * @param parsed - parsed course about to be imported.
2723
+ */
2724
+ function requireParsedLessons(parsed) {
2725
+ if (parsed.sections.reduce((n, s) => n + s.lessons.length, 0) === 0) throw new Error("lookatstudy-plugin: import produced 0 lessons — the source needs ## sections containing ### lessons, or lesson-like files in a folder");
2726
+ }
2727
+ /** Canonical value shared by the three import tools. */
2728
+ function toImportValue(course) {
2729
+ const lessons = course.sections.flatMap((s) => s.lessons);
2730
+ const first = lessons.find((l) => l.status === "available") ?? lessons[0];
2731
+ return {
2732
+ courseId: course.id,
2733
+ title: course.title,
2734
+ sections: course.sections.length,
2735
+ lessons: lessons.length,
2736
+ firstLessonId: first.id,
2737
+ firstLessonTitle: first.title
2738
+ };
2739
+ }
2740
+ /** Canonical value of `study_map`. */
2741
+ function toMapValue(course) {
2742
+ const lessons = course.sections.flatMap((s) => s.lessons);
2743
+ return {
2744
+ courseId: course.id,
2745
+ title: course.title,
2746
+ counts: {
2747
+ total: lessons.length,
2748
+ mastered: lessons.filter((l) => l.status === "mastered").length,
2749
+ available: lessons.filter((l) => l.status === "available").length
2750
+ },
2751
+ tree: course.sections.map((section) => ({
2752
+ title: section.title,
2753
+ lessons: section.lessons.map((lesson) => ({
2754
+ id: lesson.id,
2755
+ title: lesson.title,
2756
+ kind: lesson.kind,
2757
+ status: lesson.status,
2758
+ masteryPct: lesson.mastery === null ? null : Math.round(lesson.mastery * 100),
2759
+ crown: masteryToCrown(lesson.mastery),
2760
+ weakConcepts: (conceptViews(lesson) ?? []).filter((c) => c.weak).length,
2761
+ frictionCount: lesson.friction.length
2762
+ }))
2763
+ }))
2764
+ };
2765
+ }
2766
+ /** Canonical value of `study_lesson`. */
2767
+ function toLessonValue(ref, state) {
2768
+ const next = nextLesson(ref.course, ref.lesson.id);
2769
+ const pending = state.proposals.find((p) => p.lessonId === ref.lesson.id && p.status === "pending");
2770
+ return {
2771
+ lessonId: ref.lesson.id,
2772
+ courseId: ref.course.id,
2773
+ courseTitle: ref.course.title,
2774
+ sectionTitle: ref.section.title,
2775
+ title: ref.lesson.title,
2776
+ kind: ref.lesson.kind,
2777
+ status: ref.lesson.status,
2778
+ body: ref.lesson.body,
2779
+ masteryPct: ref.lesson.mastery === null ? null : Math.round(ref.lesson.mastery * 100),
2780
+ crown: masteryToCrown(ref.lesson.mastery),
2781
+ attempts: ref.lesson.attempts,
2782
+ correctCount: ref.lesson.correctCount,
2783
+ strategy: strategyBand(ref.lesson.mastery),
2784
+ concepts: conceptViews(ref.lesson),
2785
+ starters: starterPrompts(ref.lesson.title),
2786
+ memory: {
2787
+ lesson: ref.lesson.memory,
2788
+ global: state.memoryGlobal,
2789
+ pattern: state.memoryPatterns[ref.course.id] ?? null
2790
+ },
2791
+ noteCount: ref.lesson.notes.length,
2792
+ pendingProposal: pending === void 0 ? null : {
2793
+ id: pending.id,
2794
+ rationale: pending.rationale
2795
+ },
2796
+ nextLessonId: next?.id ?? null
2797
+ };
2798
+ }
2799
+ /**
2800
+ * Parse a GitHub repository URL into owner/repo.
2801
+ * @param url - `https://github.com/<owner>/<repo>` (`.git` suffix and subpaths tolerated).
2802
+ * @returns owner and repo.
2803
+ */
2804
+ function parseGithubUrl(url) {
2805
+ const match = url.match(/^(?:https?:\/\/)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[/?#].*)?$/);
2806
+ if (!match) throw new Error(`lookatstudy-plugin: not a GitHub repository URL: ${JSON.stringify(url)} (expected https://github.com/<owner>/<repo>)`);
2807
+ return {
2808
+ owner: match[1],
2809
+ repo: match[2]
2810
+ };
2811
+ }
2812
+ /** Wrap `fetch` so cancellation of the tool call aborts in-flight repo fetches. */
2813
+ function signalFetch(signal) {
2814
+ return (input, init) => fetch(input, {
2815
+ ...init,
2816
+ signal
2817
+ });
2818
+ }
2819
+ /** Total over a missing `meta` (events logged before a presentationMeta existed): renders nothing instead of throwing into the presenter fallback. */
2820
+ const textBlocks = (lines) => (lines ?? []).map((text) => ({
2821
+ type: "text",
2822
+ text
2823
+ }));
2824
+ /**
2825
+ * Build the full study tool set over one store.
2826
+ * @param store - state store owned by `apply`.
2827
+ * @returns tool definitions ready for `ctx.tools.register`.
2828
+ */
2829
+ function studyTools(store) {
2830
+ /** Run a mutating state operation and persist. */
2831
+ const mutate = (fn) => {
2832
+ const result = fn(store.get());
2833
+ store.save();
2834
+ return result;
2835
+ };
2836
+ const importOutput = { schema: {
2837
+ type: "object",
2838
+ additionalProperties: false,
2839
+ properties: {
2840
+ courseId: {
2841
+ type: "string",
2842
+ required: true
2843
+ },
2844
+ title: {
2845
+ type: "string",
2846
+ required: true
2847
+ },
2848
+ sections: {
2849
+ type: "integer",
2850
+ required: true
2851
+ },
2852
+ lessons: {
2853
+ type: "integer",
2854
+ required: true
2855
+ },
2856
+ firstLessonId: {
2857
+ type: "string",
2858
+ required: true
2859
+ },
2860
+ firstLessonTitle: {
2861
+ type: "string",
2862
+ required: true
2863
+ }
2864
+ }
2865
+ } };
2866
+ const importPresent = {
2867
+ presentationMeta: (_args, value) => importLines(value),
2868
+ presentResult: (_args, result) => ({
2869
+ card: "generic",
2870
+ content: textBlocks(result.meta)
2871
+ })
2872
+ };
2873
+ return [
2874
+ defineTool({
2875
+ name: "study_import_markdown",
2876
+ description: "Import pasted markdown as a structured course: H2 (##) becomes a section, H3 (###) a lesson. Use for notes, single long documents, or content fetched by other means.",
2877
+ parameters: {
2878
+ markdown: {
2879
+ type: "string",
2880
+ required: true,
2881
+ description: "The full markdown source of the course."
2882
+ },
2883
+ title: {
2884
+ type: "string",
2885
+ description: "Optional course title overriding the first H1."
2886
+ }
2887
+ },
2888
+ output: {
2889
+ ...importOutput,
2890
+ render: (_args, value) => [{
2891
+ type: "text",
2892
+ text: `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
2893
+ }]
2894
+ },
2895
+ async execute(args) {
2896
+ const parsed = parseMarkdownToCourse(args.markdown);
2897
+ if (args.title !== void 0) parsed.title = args.title;
2898
+ requireParsedLessons(parsed);
2899
+ return mutate((state) => toImportValue(importCourse(state, parsed, "markdown", "pasted markdown")));
2900
+ },
2901
+ presentCall: (args) => ({
2902
+ card: "generic",
2903
+ title: `Import markdown course${args.title === void 0 ? "" : `: ${args.title}`}`,
2904
+ kind: "read"
2905
+ }),
2906
+ ...importPresent
2907
+ }),
2908
+ defineTool({
2909
+ name: "study_import_folder",
2910
+ description: "Import a local folder as a course: markdown, txt, html, Jupyter notebooks, rst/Rmd/org/adoc, and 30+ code file types become lessons grouped into sections by directory (code is teaching material too). PDF/PPTX are not supported in this edition.",
2911
+ parameters: {
2912
+ path: {
2913
+ type: "string",
2914
+ required: true,
2915
+ description: "Absolute path of the folder to scan."
2916
+ },
2917
+ title: {
2918
+ type: "string",
2919
+ description: "Optional course title overriding the folder name."
2920
+ }
2921
+ },
2922
+ output: {
2923
+ ...importOutput,
2924
+ render: (_args, value) => [{
2925
+ type: "text",
2926
+ text: `Imported folder course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
2927
+ }]
2928
+ },
2929
+ async execute(args) {
2930
+ if (!existsSync(args.path)) throw new Error(`lookatstudy-plugin: folder does not exist: ${args.path}`);
2931
+ const files = (await scanFolder(args.path)).map((doc) => ({
2932
+ path: doc.path,
2933
+ title: doc.title,
2934
+ md: doc.content
2935
+ }));
2936
+ const parsed = buildCourseFromFiles(args.title ?? basename(args.path.replaceAll("\\", "/")), files);
2937
+ requireParsedLessons(parsed);
2938
+ return mutate((state) => toImportValue(importCourse(state, parsed, "folder", args.path)));
2939
+ },
2940
+ timeoutMs: 6e4,
2941
+ presentCall: (args) => ({
2942
+ card: "generic",
2943
+ title: `Scan folder: ${args.path}`,
2944
+ kind: "read",
2945
+ rawInput: args.path
2946
+ }),
2947
+ ...importPresent
2948
+ }),
2949
+ defineTool({
2950
+ name: "study_import_github",
2951
+ description: "Import a GitHub learning repository as a course. Discovery follows the README outline, files are fetched through the jsDelivr CDN (works where github.com is unreachable). Best for curated curricula (e.g. microsoft/AI-For-Beginners); awesome-lists are rejected.",
2952
+ parameters: {
2953
+ url: {
2954
+ type: "string",
2955
+ required: true,
2956
+ description: "Repository URL, e.g. https://github.com/microsoft/AI-For-Beginners."
2957
+ },
2958
+ branch: {
2959
+ type: "string",
2960
+ description: "Branch to read (main tried, then master); defaults to main."
2961
+ }
2962
+ },
2963
+ output: {
2964
+ ...importOutput,
2965
+ render: (_args, value) => [{
2966
+ type: "text",
2967
+ text: `Imported GitHub course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
2968
+ }]
2969
+ },
2970
+ async execute(args, exec) {
2971
+ const { owner, repo } = parseGithubUrl(args.url);
2972
+ const result = await importRepoToParsedCourse(owner, repo, args.branch ?? "main", signalFetch(exec.signal));
2973
+ requireParsedLessons(result.course);
2974
+ return mutate((state) => toImportValue(importCourse(state, result.course, "github", args.url)));
2975
+ },
2976
+ timeoutMs: 18e4,
2977
+ presentCall: (args) => ({
2978
+ card: "generic",
2979
+ title: `Import GitHub course: ${args.url}`,
2980
+ kind: "fetch"
2981
+ }),
2982
+ ...importPresent
2983
+ }),
2984
+ defineTool({
2985
+ name: "study_courses",
2986
+ description: "List imported courses with progress, average mastery, due reviews, and the current lesson id.",
2987
+ parameters: {},
2988
+ output: {
2989
+ schema: {
2990
+ type: "object",
2991
+ additionalProperties: false,
2992
+ properties: {
2993
+ total: {
2994
+ type: "integer",
2995
+ required: true
2996
+ },
2997
+ courses: {
2998
+ type: "array",
2999
+ required: true,
3000
+ items: {
3001
+ type: "object",
3002
+ additionalProperties: false,
3003
+ properties: {
3004
+ courseId: {
3005
+ type: "string",
3006
+ required: true
3007
+ },
3008
+ title: {
3009
+ type: "string",
3010
+ required: true
3011
+ },
3012
+ source: {
3013
+ type: "string",
3014
+ required: true,
3015
+ enum: [
3016
+ "markdown",
3017
+ "folder",
3018
+ "github"
3019
+ ]
3020
+ },
3021
+ total: {
3022
+ type: "integer",
3023
+ required: true
3024
+ },
3025
+ mastered: {
3026
+ type: "integer",
3027
+ required: true
3028
+ },
3029
+ avgMasteryPct: {
3030
+ ...nullableInteger,
3031
+ required: true
3032
+ },
3033
+ dueCount: {
3034
+ type: "integer",
3035
+ required: true
3036
+ },
3037
+ currentLessonId: {
3038
+ ...nullableString,
3039
+ required: true
3040
+ }
3041
+ }
3042
+ }
3043
+ }
3044
+ }
3045
+ },
3046
+ render: (_args, value) => [{
3047
+ type: "text",
3048
+ text: value.courses.length === 0 ? "No courses imported yet. Import one with study_import_markdown, study_import_folder, or study_import_github." : value.courses.map((c) => `“${c.title}” (${c.source}) — ${c.mastered}/${c.total} lessons mastered${c.avgMasteryPct === null ? "" : `, avg mastery ${c.avgMasteryPct}%`}${c.dueCount === 0 ? "" : `, ${c.dueCount} reviews due`}${c.currentLessonId === null ? "" : `, current lesson ${c.currentLessonId}`}`).join("\n")
3049
+ }]
3050
+ },
3051
+ async execute() {
3052
+ const summaries = courseSummaries(store.get(), /* @__PURE__ */ new Date());
3053
+ return {
3054
+ total: summaries.length,
3055
+ courses: summaries.map((s) => ({
3056
+ courseId: s.courseId,
3057
+ title: s.title,
3058
+ source: s.source,
3059
+ total: s.total,
3060
+ mastered: s.mastered,
3061
+ avgMasteryPct: s.avgMasteryPct,
3062
+ dueCount: s.dueCount,
3063
+ currentLessonId: s.currentLessonId
3064
+ }))
3065
+ };
3066
+ },
3067
+ isConcurrencySafe: () => true,
3068
+ presentCall: () => ({
3069
+ card: "generic",
3070
+ title: "List courses",
3071
+ kind: "read"
3072
+ })
3073
+ }),
3074
+ defineTool({
3075
+ name: "study_map",
3076
+ description: "Show one course's skill tree: sections, lessons with locked/available/in_progress/mastered status, mastery, weak-concept count (⚡), and friction count — the weak spots to target.",
3077
+ parameters: { courseId: {
3078
+ type: "string",
3079
+ required: true,
3080
+ description: "Course id from an import result or study_courses."
3081
+ } },
3082
+ output: {
3083
+ schema: {
3084
+ type: "object",
3085
+ additionalProperties: false,
3086
+ properties: {
3087
+ courseId: {
3088
+ type: "string",
3089
+ required: true
3090
+ },
3091
+ title: {
3092
+ type: "string",
3093
+ required: true
3094
+ },
3095
+ counts: {
3096
+ type: "object",
3097
+ required: true,
3098
+ additionalProperties: false,
3099
+ properties: {
3100
+ total: {
3101
+ type: "integer",
3102
+ required: true
3103
+ },
3104
+ mastered: {
3105
+ type: "integer",
3106
+ required: true
3107
+ },
3108
+ available: {
3109
+ type: "integer",
3110
+ required: true
3111
+ }
3112
+ }
3113
+ },
3114
+ tree: {
3115
+ type: "array",
3116
+ required: true,
3117
+ items: {
3118
+ type: "object",
3119
+ additionalProperties: false,
3120
+ properties: {
3121
+ title: {
3122
+ type: "string",
3123
+ required: true
3124
+ },
3125
+ lessons: {
3126
+ type: "array",
3127
+ required: true,
3128
+ items: {
3129
+ type: "object",
3130
+ additionalProperties: false,
3131
+ properties: {
3132
+ id: {
3133
+ type: "string",
3134
+ required: true
3135
+ },
3136
+ title: {
3137
+ type: "string",
3138
+ required: true
3139
+ },
3140
+ kind: {
3141
+ type: "string",
3142
+ required: true,
3143
+ enum: [...LESSON_KINDS]
3144
+ },
3145
+ status: {
3146
+ type: "string",
3147
+ required: true,
3148
+ enum: [...LESSON_STATUSES]
3149
+ },
3150
+ masteryPct: {
3151
+ ...nullableInteger,
3152
+ required: true
3153
+ },
3154
+ crown: {
3155
+ type: "integer",
3156
+ required: true
3157
+ },
3158
+ weakConcepts: {
3159
+ type: "integer",
3160
+ required: true
3161
+ },
3162
+ frictionCount: {
3163
+ type: "integer",
3164
+ required: true
3165
+ }
3166
+ }
3167
+ }
3168
+ }
3169
+ }
3170
+ }
3171
+ }
3172
+ }
3173
+ },
3174
+ render: (_args, value) => textBlocks(mapLines(value))
3175
+ },
3176
+ async execute(args) {
3177
+ return toMapValue(findCourse(store.get(), args.courseId));
3178
+ },
3179
+ isConcurrencySafe: () => true,
3180
+ presentCall: (args) => ({
3181
+ card: "generic",
3182
+ title: `Course map: ${args.courseId}`,
3183
+ kind: "read"
3184
+ }),
3185
+ presentationMeta: (_args, value) => mapLines(value),
3186
+ presentResult: (_args, result) => ({
3187
+ card: "generic",
3188
+ content: textBlocks(result.meta)
3189
+ })
3190
+ }),
3191
+ defineTool({
3192
+ name: "study_lesson",
3193
+ description: "Open one lesson and make it the focus: returns its markdown content (the source of truth to teach from), teaching strategy band, knowledge concepts with mastery/weak flags, four consolidation starters, memory slots, and any pending mastery proposal.",
3194
+ parameters: { lessonId: {
3195
+ type: "string",
3196
+ required: true,
3197
+ description: "Lesson id from a map, import, or courses call."
3198
+ } },
3199
+ output: {
3200
+ schema: {
3201
+ type: "object",
3202
+ additionalProperties: false,
3203
+ properties: {
3204
+ lessonId: {
3205
+ type: "string",
3206
+ required: true
3207
+ },
3208
+ courseId: {
3209
+ type: "string",
3210
+ required: true
3211
+ },
3212
+ courseTitle: {
3213
+ type: "string",
3214
+ required: true
3215
+ },
3216
+ sectionTitle: {
3217
+ type: "string",
3218
+ required: true
3219
+ },
3220
+ title: {
3221
+ type: "string",
3222
+ required: true
3223
+ },
3224
+ kind: {
3225
+ type: "string",
3226
+ required: true,
3227
+ enum: [...LESSON_KINDS]
3228
+ },
3229
+ status: {
3230
+ type: "string",
3231
+ required: true,
3232
+ enum: [...LESSON_STATUSES]
3233
+ },
3234
+ body: {
3235
+ type: "string",
3236
+ required: true
3237
+ },
3238
+ masteryPct: {
3239
+ ...nullableInteger,
3240
+ required: true
3241
+ },
3242
+ crown: {
3243
+ type: "integer",
3244
+ required: true
3245
+ },
3246
+ attempts: {
3247
+ type: "integer",
3248
+ required: true
3249
+ },
3250
+ correctCount: {
3251
+ type: "integer",
3252
+ required: true
3253
+ },
3254
+ strategy: {
3255
+ type: "string",
3256
+ required: true
3257
+ },
3258
+ concepts: {
3259
+ oneOf: [{ type: "null" }, {
3260
+ type: "array",
3261
+ items: {
3262
+ type: "object",
3263
+ additionalProperties: false,
3264
+ properties: {
3265
+ title: {
3266
+ type: "string",
3267
+ required: true
3268
+ },
3269
+ masteryPct: {
3270
+ type: "integer",
3271
+ required: true
3272
+ },
3273
+ weak: {
3274
+ type: "boolean",
3275
+ required: true
3276
+ },
3277
+ /** 1 once this concept has been quizzed at least once, else 0 (ConceptView.tested). */
3278
+ tested: {
3279
+ type: "integer",
3280
+ required: true
3281
+ }
3282
+ }
3283
+ }
3284
+ }],
3285
+ required: true
3286
+ },
3287
+ starters: {
3288
+ type: "array",
3289
+ required: true,
3290
+ items: {
3291
+ type: "object",
3292
+ additionalProperties: false,
3293
+ properties: {
3294
+ label: {
3295
+ type: "string",
3296
+ required: true
3297
+ },
3298
+ message: {
3299
+ type: "string",
3300
+ required: true
3301
+ },
3302
+ effect: {
3303
+ type: "string",
3304
+ required: true,
3305
+ enum: [
3306
+ "mastery",
3307
+ "friction",
3308
+ "none"
3309
+ ]
3310
+ }
3311
+ }
3312
+ }
3313
+ },
3314
+ memory: {
3315
+ type: "object",
3316
+ required: true,
3317
+ additionalProperties: false,
3318
+ properties: {
3319
+ lesson: {
3320
+ ...nullableString,
3321
+ required: true
3322
+ },
3323
+ global: {
3324
+ ...nullableString,
3325
+ required: true
3326
+ },
3327
+ pattern: {
3328
+ ...nullableString,
3329
+ required: true
3330
+ }
3331
+ }
3332
+ },
3333
+ noteCount: {
3334
+ type: "integer",
3335
+ required: true
3336
+ },
3337
+ pendingProposal: {
3338
+ oneOf: [{ type: "null" }, {
3339
+ type: "object",
3340
+ additionalProperties: false,
3341
+ properties: {
3342
+ id: {
3343
+ type: "string",
3344
+ required: true
3345
+ },
3346
+ rationale: {
3347
+ type: "string",
3348
+ required: true
3349
+ }
3350
+ }
3351
+ }],
3352
+ required: true
3353
+ },
3354
+ nextLessonId: {
3355
+ ...nullableString,
3356
+ required: true
3357
+ }
3358
+ }
3359
+ },
3360
+ render: (_args, value) => [{
3361
+ type: "text",
3362
+ text: `Lesson “${value.title}” — ${value.courseTitle} / ${value.sectionTitle}\nstatus ${value.status}${value.masteryPct === null ? "" : `, mastery ${value.masteryPct}%`}, ${value.correctCount}/${value.attempts} answers correct\nstrategy: ${value.strategy}\n` + (value.concepts === null ? "" : `concepts: ${value.concepts.map((c) => `${c.title} ${c.masteryPct}%${c.weak ? " ⚡weak" : ""}`).join(" · ")}\n`) + `starters: ${value.starters.map((s) => s.label).join(" / ")}\n\n${value.body}${value.nextLessonId === null ? "\n\n(this is the last lesson)" : `\n\n(next lesson: ${value.nextLessonId})`}`
3363
+ }]
3364
+ },
3365
+ async execute(args) {
3366
+ return mutate((state) => {
3367
+ const { ref } = attemptLesson(state, args.lessonId, /* @__PURE__ */ new Date());
3368
+ state.focus = { lessonId: ref.lesson.id };
3369
+ return toLessonValue(ref, state);
3370
+ });
3371
+ },
3372
+ presentCall: (args) => ({
3373
+ card: "generic",
3374
+ title: `Open lesson: ${args.lessonId}`,
3375
+ kind: "read"
3376
+ })
3377
+ }),
3378
+ defineTool({
3379
+ name: "study_record_answer",
3380
+ description: "Record one graded answer and update mastery — call after EVERY learner answer to a scored question. Name the `concept` the question tested (from study_lesson / study_define_concepts) so per-concept mastery stays accurate; lesson mastery is the WEAKEST concept. Mastery ≥50% unlocks the next lesson early; ≥90% graduates automatically and schedules the first review. Also pass the question text and the learner's answer to keep a practice log.",
3381
+ parameters: {
3382
+ lessonId: {
3383
+ type: "string",
3384
+ required: true,
3385
+ description: "Lesson the question tested."
3386
+ },
3387
+ correct: {
3388
+ type: "boolean",
3389
+ required: true,
3390
+ description: "Whether the learner answered correctly."
3391
+ },
3392
+ concept: {
3393
+ type: "string",
3394
+ description: "Concept title the question tested (required once concepts are defined)."
3395
+ },
3396
+ rationale: {
3397
+ type: "string",
3398
+ description: "One line: why you graded it this way."
3399
+ },
3400
+ question: {
3401
+ type: "string",
3402
+ description: "The question text, for the practice log."
3403
+ },
3404
+ givenAnswer: {
3405
+ type: "string",
3406
+ description: "The learner's answer, for the practice log."
3407
+ }
3408
+ },
3409
+ output: {
3410
+ schema: {
3411
+ type: "object",
3412
+ additionalProperties: false,
3413
+ properties: {
3414
+ lessonId: {
3415
+ type: "string",
3416
+ required: true
3417
+ },
3418
+ lessonTitle: {
3419
+ type: "string",
3420
+ required: true
3421
+ },
3422
+ correct: {
3423
+ type: "boolean",
3424
+ required: true
3425
+ },
3426
+ concept: {
3427
+ oneOf: [{ type: "null" }, {
3428
+ type: "object",
3429
+ additionalProperties: false,
3430
+ properties: {
3431
+ title: {
3432
+ type: "string",
3433
+ required: true
3434
+ },
3435
+ masteryPct: {
3436
+ type: "integer",
3437
+ required: true
3438
+ },
3439
+ weak: {
3440
+ type: "boolean",
3441
+ required: true
3442
+ }
3443
+ }
3444
+ }],
3445
+ required: true
3446
+ },
3447
+ prevMasteryPct: {
3448
+ type: "integer",
3449
+ required: true
3450
+ },
3451
+ newMasteryPct: {
3452
+ type: "integer",
3453
+ required: true
3454
+ },
3455
+ crown: {
3456
+ type: "integer",
3457
+ required: true
3458
+ },
3459
+ mastered: {
3460
+ type: "boolean",
3461
+ required: true
3462
+ },
3463
+ attempts: {
3464
+ type: "integer",
3465
+ required: true
3466
+ },
3467
+ correctCount: {
3468
+ type: "integer",
3469
+ required: true
3470
+ },
3471
+ graduated: {
3472
+ type: "boolean",
3473
+ required: true
3474
+ },
3475
+ unlockedLessonIds: {
3476
+ type: "array",
3477
+ required: true,
3478
+ items: { type: "string" }
3479
+ },
3480
+ reviewDueAt: {
3481
+ ...nullableString,
3482
+ required: true
3483
+ }
3484
+ }
3485
+ },
3486
+ render: (_args, value) => [{
3487
+ type: "text",
3488
+ text: answerLine(value) + (value.concept === null ? "" : `\nconcept: ${value.concept.title} ${value.concept.masteryPct}%${value.concept.weak ? " ⚡weak" : ""}`) + (value.graduated ? "\n🎓 mastery ≥90% — lesson graduated, first review scheduled." : "") + (value.unlockedLessonIds.length === 0 ? "" : `\n🔓 unlocked: ${value.unlockedLessonIds.join(", ")}`)
3489
+ }]
3490
+ },
3491
+ async execute(args) {
3492
+ return mutate((state) => {
3493
+ const r = recordAnswer(state, args.lessonId, args.correct, args.concept, /* @__PURE__ */ new Date());
3494
+ if (args.question !== void 0) addNote(state, args.lessonId, "practice", args.question.slice(0, 80), `${args.question}\n\nlearner answered: ${args.givenAnswer ?? "(not recorded)"} — ${args.correct ? "✓ correct" : "✗ incorrect"}${args.rationale === void 0 ? "" : `\nrationale: ${args.rationale}`}`, "ai", null, /* @__PURE__ */ new Date());
3495
+ return {
3496
+ lessonId: r.ref.lesson.id,
3497
+ lessonTitle: r.ref.lesson.title,
3498
+ correct: args.correct,
3499
+ concept: r.concept === null ? null : {
3500
+ title: r.concept.title,
3501
+ masteryPct: Math.round(r.concept.mastery * 100),
3502
+ weak: r.concept.mastery < .7
3503
+ },
3504
+ prevMasteryPct: Math.round(r.prevMastery * 100),
3505
+ newMasteryPct: Math.round(r.newMastery * 100),
3506
+ crown: r.crown,
3507
+ mastered: r.mastered,
3508
+ attempts: r.ref.lesson.attempts,
3509
+ correctCount: r.ref.lesson.correctCount,
3510
+ graduated: r.progression.graduated,
3511
+ unlockedLessonIds: r.progression.unlocked.map((u) => u.id),
3512
+ reviewDueAt: r.progression.nextDue
3513
+ };
3514
+ });
3515
+ },
3516
+ presentCall: (args) => ({
3517
+ card: "generic",
3518
+ title: `Record answer (${args.correct ? "correct" : "incorrect"}): ${args.lessonId}`
3519
+ }),
3520
+ presentationMeta: (_args, value) => [answerLine(value)],
3521
+ presentResult: (_args, result) => ({
3522
+ card: "generic",
3523
+ content: textBlocks(result.meta)
3524
+ })
3525
+ }),
3526
+ defineTool({
3527
+ name: "study_complete_lesson",
3528
+ description: "Mark a lesson mastered manually (graduation at 90% mastery is the automatic path — this is the override). Unlocks the next lesson and schedules the first spaced review for tomorrow. Call only when the learner has genuinely worked through the lesson.",
3529
+ parameters: { lessonId: {
3530
+ type: "string",
3531
+ required: true,
3532
+ description: "Lesson to complete."
3533
+ } },
3534
+ output: {
3535
+ schema: {
3536
+ type: "object",
3537
+ additionalProperties: false,
3538
+ properties: {
3539
+ lessonId: {
3540
+ type: "string",
3541
+ required: true
3542
+ },
3543
+ lessonTitle: {
3544
+ type: "string",
3545
+ required: true
3546
+ },
3547
+ unlockedLessonIds: {
3548
+ type: "array",
3549
+ required: true,
3550
+ items: { type: "string" }
3551
+ },
3552
+ unlockedLessonTitles: {
3553
+ type: "array",
3554
+ required: true,
3555
+ items: { type: "string" }
3556
+ },
3557
+ reviewDueAt: {
3558
+ type: "string",
3559
+ required: true
3560
+ },
3561
+ courseComplete: {
3562
+ type: "boolean",
3563
+ required: true
3564
+ }
3565
+ }
3566
+ },
3567
+ render: (_args, value) => [{
3568
+ type: "text",
3569
+ text: completeLines(value).join("\n")
3570
+ }]
3571
+ },
3572
+ async execute(args) {
3573
+ return mutate((state) => {
3574
+ const r = completeLesson(state, args.lessonId, /* @__PURE__ */ new Date());
3575
+ return {
3576
+ lessonId: r.ref.lesson.id,
3577
+ lessonTitle: r.ref.lesson.title,
3578
+ unlockedLessonIds: r.unlocked.map((u) => u.id),
3579
+ unlockedLessonTitles: r.unlocked.map((u) => u.title),
3580
+ reviewDueAt: r.dueAt,
3581
+ courseComplete: r.courseComplete
3582
+ };
3583
+ });
3584
+ },
3585
+ presentCall: (args) => ({
3586
+ card: "generic",
3587
+ title: `Complete lesson: ${args.lessonId}`
3588
+ }),
3589
+ presentationMeta: (_args, value) => completeLines(value),
3590
+ presentResult: (_args, result) => ({
3591
+ card: "generic",
3592
+ content: textBlocks(result.meta)
3593
+ })
3594
+ }),
3595
+ defineTool({
3596
+ name: "study_due_reviews",
3597
+ description: "List mastered lessons whose spaced-repetition review is due (optionally within one course), oldest first. Start every session here.",
3598
+ parameters: { courseId: {
3599
+ type: "string",
3600
+ description: "Restrict to one course; omit to scan all courses."
3601
+ } },
3602
+ output: {
3603
+ schema: {
3604
+ type: "object",
3605
+ additionalProperties: false,
3606
+ properties: {
3607
+ total: {
3608
+ type: "integer",
3609
+ required: true
3610
+ },
3611
+ due: {
3612
+ type: "array",
3613
+ required: true,
3614
+ items: {
3615
+ type: "object",
3616
+ additionalProperties: false,
3617
+ properties: {
3618
+ lessonId: {
3619
+ type: "string",
3620
+ required: true
3621
+ },
3622
+ courseTitle: {
3623
+ type: "string",
3624
+ required: true
3625
+ },
3626
+ lessonTitle: {
3627
+ type: "string",
3628
+ required: true
3629
+ },
3630
+ dueAt: {
3631
+ type: "string",
3632
+ required: true
3633
+ },
3634
+ overdueDays: {
3635
+ type: "integer",
3636
+ required: true
3637
+ }
3638
+ }
3639
+ }
3640
+ }
3641
+ }
3642
+ },
3643
+ render: (_args, value) => textBlocks(dueLines(value))
3644
+ },
3645
+ async execute(args) {
3646
+ const due = dueReviews(store.get(), args.courseId, /* @__PURE__ */ new Date());
3647
+ return {
3648
+ total: due.length,
3649
+ due: due.map((d) => ({
3650
+ lessonId: d.lessonId,
3651
+ courseTitle: d.courseTitle,
3652
+ lessonTitle: d.lessonTitle,
3653
+ dueAt: d.dueAt,
3654
+ overdueDays: d.overdueDays
3655
+ }))
3656
+ };
3657
+ },
3658
+ isConcurrencySafe: () => true,
3659
+ presentCall: () => ({
3660
+ card: "generic",
3661
+ title: "List due reviews",
3662
+ kind: "search"
3663
+ }),
3664
+ presentationMeta: (_args, value) => dueLines(value),
3665
+ presentResult: (_args, result) => ({
3666
+ card: "generic",
3667
+ content: textBlocks(result.meta)
3668
+ })
3669
+ }),
3670
+ defineTool({
3671
+ name: "study_record_review",
3672
+ description: "Record an SM-2 review grade for a mastered lesson and advance its schedule. Grade how well the learner recalled the material: 5 perfect, 4 hesitant, 3 recalled with effort, 2 incorrect but recognized, 1 incorrect, 0 complete blackout. Target weak concepts (⚡) first.",
3673
+ parameters: {
3674
+ lessonId: {
3675
+ type: "string",
3676
+ required: true,
3677
+ description: "Lesson being reviewed."
3678
+ },
3679
+ quality: {
3680
+ type: "integer",
3681
+ required: true,
3682
+ enum: [...QUALITIES],
3683
+ description: "SM-2 recall quality, 0 (blackout) to 5 (perfect)."
3684
+ }
3685
+ },
3686
+ output: {
3687
+ schema: {
3688
+ type: "object",
3689
+ additionalProperties: false,
3690
+ properties: {
3691
+ lessonId: {
3692
+ type: "string",
3693
+ required: true
3694
+ },
3695
+ lessonTitle: {
3696
+ type: "string",
3697
+ required: true
3698
+ },
3699
+ quality: {
3700
+ type: "integer",
3701
+ required: true
3702
+ },
3703
+ intervalDays: {
3704
+ type: "integer",
3705
+ required: true
3706
+ },
3707
+ repetitions: {
3708
+ type: "integer",
3709
+ required: true
3710
+ },
3711
+ easeFactor: {
3712
+ type: "number",
3713
+ required: true
3714
+ },
3715
+ dueAt: {
3716
+ type: "string",
3717
+ required: true
3718
+ }
3719
+ }
3720
+ },
3721
+ render: (_args, value) => [{
3722
+ type: "text",
3723
+ text: reviewLine(value)
3724
+ }]
3725
+ },
3726
+ async execute(args) {
3727
+ return mutate((state) => {
3728
+ const r = recordReview(state, args.lessonId, args.quality, /* @__PURE__ */ new Date());
3729
+ return {
3730
+ lessonId: r.ref.lesson.id,
3731
+ lessonTitle: r.ref.lesson.title,
3732
+ quality: args.quality,
3733
+ intervalDays: r.intervalDays,
3734
+ repetitions: r.repetitions,
3735
+ easeFactor: r.easeFactor,
3736
+ dueAt: r.dueAt
3737
+ };
3738
+ });
3739
+ },
3740
+ presentCall: (args) => ({
3741
+ card: "generic",
3742
+ title: `Record review (quality ${args.quality}): ${args.lessonId}`
3743
+ }),
3744
+ presentationMeta: (_args, value) => [reviewLine(value)],
3745
+ presentResult: (_args, result) => ({
3746
+ card: "generic",
3747
+ content: textBlocks(result.meta)
3748
+ })
3749
+ }),
3750
+ defineTool({
3751
+ name: "study_delete_course",
3752
+ description: "Delete one course and all its progress. Ask the learner before calling.",
3753
+ parameters: { courseId: {
3754
+ type: "string",
3755
+ required: true,
3756
+ description: "Course to delete."
3757
+ } },
3758
+ output: {
3759
+ schema: {
3760
+ type: "object",
3761
+ additionalProperties: false,
3762
+ properties: {
3763
+ deletedCourseId: {
3764
+ type: "string",
3765
+ required: true
3766
+ },
3767
+ remaining: {
3768
+ type: "integer",
3769
+ required: true
3770
+ }
3771
+ }
3772
+ },
3773
+ render: (_args, value) => [{
3774
+ type: "text",
3775
+ text: `Deleted course ${value.deletedCourseId}. ${value.remaining} courses remain.`
3776
+ }]
3777
+ },
3778
+ async execute(args) {
3779
+ return mutate((state) => {
3780
+ findCourse(state, args.courseId);
3781
+ deleteCourse(state, args.courseId);
3782
+ return {
3783
+ deletedCourseId: args.courseId,
3784
+ remaining: state.courses.length
3785
+ };
3786
+ });
3787
+ },
3788
+ presentCall: (args) => ({
3789
+ card: "generic",
3790
+ title: `Delete course: ${args.courseId}`,
3791
+ kind: "delete",
3792
+ rawInput: args.courseId
3793
+ })
3794
+ }),
3795
+ defineTool({
3796
+ name: "study_define_concepts",
3797
+ description: "Define a lesson's knowledge components — the 2–7 independently quizzable units mastery tracks. Call this the FIRST time you teach a lesson, derived from its content. Titles ≤10 characters; descriptions say what understanding this concept means. Lesson mastery is the WEAKEST concept; cover weak ones (⚡) first when quizzing.",
3798
+ parameters: {
3799
+ lessonId: {
3800
+ type: "string",
3801
+ required: true,
3802
+ description: "Lesson to describe."
3803
+ },
3804
+ concepts: {
3805
+ type: "array",
3806
+ required: true,
3807
+ description: "2–7 concepts.",
3808
+ items: {
3809
+ type: "object",
3810
+ additionalProperties: false,
3811
+ properties: {
3812
+ title: {
3813
+ type: "string",
3814
+ required: true,
3815
+ description: "Short concept title (≤10 chars)."
3816
+ },
3817
+ description: {
3818
+ type: "string",
3819
+ required: true,
3820
+ description: "What understanding this concept means."
3821
+ }
3822
+ }
3823
+ }
3824
+ }
3825
+ },
3826
+ output: {
3827
+ schema: {
3828
+ type: "object",
3829
+ additionalProperties: false,
3830
+ properties: {
3831
+ lessonId: {
3832
+ type: "string",
3833
+ required: true
3834
+ },
3835
+ concepts: {
3836
+ type: "array",
3837
+ required: true,
3838
+ items: {
3839
+ type: "object",
3840
+ additionalProperties: false,
3841
+ properties: {
3842
+ title: {
3843
+ type: "string",
3844
+ required: true
3845
+ },
3846
+ masteryPct: {
3847
+ type: "integer",
3848
+ required: true
3849
+ }
3850
+ }
3851
+ }
3852
+ }
3853
+ }
3854
+ },
3855
+ render: (_args, value) => [{
3856
+ type: "text",
3857
+ text: `Concepts defined: ${value.concepts.map((c) => `${c.title} (${c.masteryPct}%)`).join(" · ")}. Attribute quiz answers with the \`concept\` parameter.`
3858
+ }]
3859
+ },
3860
+ async execute(args) {
3861
+ return mutate((state) => {
3862
+ defineConcepts(state, args.lessonId, args.concepts);
3863
+ const ref = findLesson(state, args.lessonId);
3864
+ return {
3865
+ lessonId: ref.lesson.id,
3866
+ concepts: (conceptViews(ref.lesson) ?? []).map((c) => ({
3867
+ title: c.title,
3868
+ masteryPct: c.masteryPct
3869
+ }))
3870
+ };
3871
+ });
3872
+ },
3873
+ presentCall: (args) => ({
3874
+ card: "generic",
3875
+ title: `Define concepts: ${args.lessonId}`
3876
+ })
3877
+ }),
3878
+ defineTool({
3879
+ name: "study_propose_mastery",
3880
+ description: "Propose graduating a lesson as mastered ahead of the 90% threshold — use when mastery is ≥85% and the learner has convincingly demonstrated understanding (e.g. a Feynman-style explanation back to you). Creates a PENDING proposal: present it with your rationale and WAIT for the learner's decision, then resolve with study_resolve_proposal. Never apply it yourself.",
3881
+ parameters: {
3882
+ lessonId: {
3883
+ type: "string",
3884
+ required: true,
3885
+ description: "Lesson judged mastered."
3886
+ },
3887
+ rationale: {
3888
+ type: "string",
3889
+ required: true,
3890
+ description: "Why you believe it is mastered — the learner reads this."
3891
+ }
3892
+ },
3893
+ output: {
3894
+ schema: {
3895
+ type: "object",
3896
+ additionalProperties: false,
3897
+ properties: {
3898
+ proposalId: {
3899
+ type: "string",
3900
+ required: true
3901
+ },
3902
+ lessonTitle: {
3903
+ type: "string",
3904
+ required: true
3905
+ },
3906
+ status: {
3907
+ type: "string",
3908
+ required: true,
3909
+ enum: [
3910
+ "pending",
3911
+ "applied",
3912
+ "rejected"
3913
+ ]
3914
+ },
3915
+ rationale: {
3916
+ type: "string",
3917
+ required: true
3918
+ }
3919
+ }
3920
+ },
3921
+ render: (_args, value) => [{
3922
+ type: "text",
3923
+ text: `Proposal ${value.proposalId} (${value.status}): “${value.lessonTitle}” — ${value.rationale}\nPresent this to the learner and wait; resolve via study_resolve_proposal.`
3924
+ }]
3925
+ },
3926
+ async execute(args) {
3927
+ return mutate((state) => {
3928
+ const ref = findLesson(state, args.lessonId);
3929
+ const proposal = proposeMastery(state, args.lessonId, args.rationale, /* @__PURE__ */ new Date());
3930
+ return {
3931
+ proposalId: proposal.id,
3932
+ lessonTitle: ref.lesson.title,
3933
+ status: proposal.status,
3934
+ rationale: proposal.rationale
3935
+ };
3936
+ });
3937
+ },
3938
+ presentCall: (args) => ({
3939
+ card: "generic",
3940
+ title: `Propose mastery: ${args.lessonId}`
3941
+ }),
3942
+ presentationMeta: (_args, value) => ({
3943
+ kind: "study-proposal-created",
3944
+ proposalId: value.proposalId,
3945
+ lessonTitle: value.lessonTitle,
3946
+ rationale: value.rationale
3947
+ }),
3948
+ presentResult: (_args, result) => ({
3949
+ card: "generic",
3950
+ content: textBlocks([`🎓 Proposed mastery for “${result.meta?.lessonTitle ?? "lesson"}”: ${result.meta?.rationale ?? ""}`])
3951
+ })
3952
+ }),
3953
+ defineTool({
3954
+ name: "study_resolve_proposal",
3955
+ description: "Resolve a pending mastery proposal with the learner's explicit decision (they said yes / no in chat). Accepting floors every concept to 95%, graduates the lesson, and unlocks the next one.",
3956
+ parameters: {
3957
+ proposalId: {
3958
+ type: "string",
3959
+ required: true,
3960
+ description: "Proposal id from study_propose_mastery."
3961
+ },
3962
+ accept: {
3963
+ type: "boolean",
3964
+ required: true,
3965
+ description: "The learner's decision."
3966
+ }
3967
+ },
3968
+ output: {
3969
+ schema: {
3970
+ type: "object",
3971
+ additionalProperties: false,
3972
+ properties: {
3973
+ proposalId: {
3974
+ type: "string",
3975
+ required: true
3976
+ },
3977
+ lessonId: {
3978
+ type: "string",
3979
+ required: true
3980
+ },
3981
+ status: {
3982
+ type: "string",
3983
+ required: true,
3984
+ enum: ["applied", "rejected"]
3985
+ }
3986
+ }
3987
+ },
3988
+ render: (_args, value) => [{
3989
+ type: "text",
3990
+ text: value.status === "applied" ? `🎓 Proposal applied — lesson ${value.lessonId} mastered (all concepts ≥95%), next lesson unlocked, review scheduled.` : `Proposal rejected — continuing practice on ${value.lessonId}.`
3991
+ }]
3992
+ },
3993
+ async execute(args) {
3994
+ return mutate((state) => {
3995
+ const proposal = resolveProposal(state, args.proposalId, args.accept, /* @__PURE__ */ new Date());
3996
+ return {
3997
+ proposalId: proposal.id,
3998
+ lessonId: proposal.lessonId,
3999
+ status: proposal.status
4000
+ };
4001
+ });
4002
+ },
4003
+ presentCall: (args) => ({
4004
+ card: "generic",
4005
+ title: `Resolve proposal: ${args.proposalId}`
4006
+ }),
4007
+ presentationMeta: (_args, value) => ({
4008
+ kind: "study-proposal-resolved",
4009
+ proposalId: value.proposalId,
4010
+ status: value.status
4011
+ }),
4012
+ presentResult: (_args, result) => ({
4013
+ card: "generic",
4014
+ content: textBlocks([`Proposal ${result.meta?.proposalId ?? "?"} ${result.meta?.status ?? ""}.`])
4015
+ })
4016
+ }),
4017
+ defineTool({
4018
+ name: "study_report_friction",
4019
+ description: "SILENTLY log a learning-friction moment — call when the learner seems confused (糊涂), stuck (卡住), or frustrated (受挫), or when they say \"我没太懂\". One short line. Never mention that you logged it; it feeds the weak-spot map and adapts difficulty.",
4020
+ parameters: {
4021
+ category: {
4022
+ type: "string",
4023
+ required: true,
4024
+ enum: [...FRICTION_CATEGORIES],
4025
+ description: "confused | blocked | frustrated."
4026
+ },
4027
+ summary: {
4028
+ type: "string",
4029
+ description: "One short line: what specifically is hard."
4030
+ },
4031
+ lessonId: {
4032
+ type: "string",
4033
+ description: "Lesson it happened on, when known."
4034
+ }
4035
+ },
4036
+ output: {
4037
+ schema: {
4038
+ type: "object",
4039
+ additionalProperties: false,
4040
+ properties: { logged: {
4041
+ type: "boolean",
4042
+ required: true
4043
+ } }
4044
+ },
4045
+ render: () => [{
4046
+ type: "text",
4047
+ text: "Noted."
4048
+ }]
4049
+ },
4050
+ async execute(args) {
4051
+ return mutate((state) => {
4052
+ addFriction(state, args.lessonId ?? null, args.category, args.summary ?? null, /* @__PURE__ */ new Date());
4053
+ return { logged: true };
4054
+ });
4055
+ },
4056
+ presentCall: () => ({
4057
+ card: "generic",
4058
+ title: "Log friction"
4059
+ })
4060
+ }),
4061
+ defineTool({
4062
+ name: "study_remember",
4063
+ description: "Write a learner-memory slot — call only when you learn something worth keeping across sessions (how they best learn, a recurring pattern, a specific gap). NOT for transient chat. To merge: read the current slot first (study_lesson's memory field), then send the merged 1–3 sentence version — this REPLACES the slot.",
4064
+ parameters: {
4065
+ category: {
4066
+ type: "string",
4067
+ required: true,
4068
+ enum: [...MEMORY_CATEGORIES],
4069
+ description: "global (cross-course style) | pattern (per-course recurring pattern) | lesson (this lesson's specific gap)."
4070
+ },
4071
+ content: {
4072
+ type: "string",
4073
+ required: true,
4074
+ description: "The merged 1–3 sentence slot content."
4075
+ },
4076
+ lessonId: {
4077
+ type: "string",
4078
+ description: "Lesson (for the lesson slot) or any lesson of the course (for the pattern slot)."
4079
+ }
4080
+ },
4081
+ output: {
4082
+ schema: {
4083
+ type: "object",
4084
+ additionalProperties: false,
4085
+ properties: {
4086
+ previous: {
4087
+ ...nullableString,
4088
+ required: true
4089
+ },
4090
+ stored: {
4091
+ type: "string",
4092
+ required: true
4093
+ }
4094
+ }
4095
+ },
4096
+ render: () => [{
4097
+ type: "text",
4098
+ text: "Remembered."
4099
+ }]
4100
+ },
4101
+ async execute(args) {
4102
+ return mutate((state) => ({
4103
+ previous: setMemory(state, args.category, args.content, args.lessonId),
4104
+ stored: args.content
4105
+ }));
4106
+ },
4107
+ presentCall: () => ({
4108
+ card: "generic",
4109
+ title: "Update learner memory"
4110
+ })
4111
+ }),
4112
+ defineTool({
4113
+ name: "study_note_save",
4114
+ description: "Save an entry to the learner's Cornell notebook. Zones: `understand` (knowledge structures you generated — concept maps as mermaid, compare tables, diagrams; sediment your best structures here after showing them), `record` (the learner's own words — when they ask to take a note, or when they write something worth keeping, with the verbatim `quote`), `practice` (quiz log — normally written automatically by study_record_answer).",
4115
+ parameters: {
4116
+ lessonId: {
4117
+ type: "string",
4118
+ required: true,
4119
+ description: "Lesson the note belongs to."
4120
+ },
4121
+ zone: {
4122
+ type: "string",
4123
+ required: true,
4124
+ enum: [...NOTE_ZONES],
4125
+ description: "understand | record | practice."
4126
+ },
4127
+ title: {
4128
+ type: "string",
4129
+ required: true,
4130
+ description: "Short entry title."
4131
+ },
4132
+ text: {
4133
+ type: "string",
4134
+ required: true,
4135
+ description: "Entry body — markdown for the understand zone."
4136
+ },
4137
+ source: {
4138
+ type: "string",
4139
+ required: true,
4140
+ enum: [...NOTE_SOURCES],
4141
+ description: "ai (you generated) | content (quoted from lesson) | chat (quoted from conversation)."
4142
+ },
4143
+ quote: {
4144
+ type: "string",
4145
+ description: "Verbatim source quote, for record-zone notes."
4146
+ }
4147
+ },
4148
+ output: {
4149
+ schema: {
4150
+ type: "object",
4151
+ additionalProperties: false,
4152
+ properties: {
4153
+ noteId: {
4154
+ type: "string",
4155
+ required: true
4156
+ },
4157
+ zone: {
4158
+ type: "string",
4159
+ required: true
4160
+ }
4161
+ }
4162
+ },
4163
+ render: (_args, value) => [{
4164
+ type: "text",
4165
+ text: `Saved ${value.zone}-zone note ${value.noteId}.`
4166
+ }]
4167
+ },
4168
+ async execute(args) {
4169
+ return mutate((state) => {
4170
+ const note = addNote(state, args.lessonId, args.zone, args.title, args.text, args.source, args.quote ?? null, /* @__PURE__ */ new Date());
4171
+ return {
4172
+ noteId: note.id,
4173
+ zone: note.zone
4174
+ };
4175
+ });
4176
+ },
4177
+ presentCall: (args) => ({
4178
+ card: "generic",
4179
+ title: `Save ${args.zone} note: ${args.title}`
4180
+ })
4181
+ }),
4182
+ defineTool({
4183
+ name: "study_notes",
4184
+ description: "Read the learner's Cornell notebook: three zones per lesson (understand structures, learner records, practice log).",
4185
+ parameters: { lessonId: {
4186
+ type: "string",
4187
+ description: "One lesson's notes; omit for all lessons (most recent last)."
4188
+ } },
4189
+ output: {
4190
+ schema: {
4191
+ type: "object",
4192
+ additionalProperties: false,
4193
+ properties: {
4194
+ total: {
4195
+ type: "integer",
4196
+ required: true
4197
+ },
4198
+ notes: {
4199
+ type: "array",
4200
+ required: true,
4201
+ items: {
4202
+ type: "object",
4203
+ additionalProperties: false,
4204
+ properties: {
4205
+ id: {
4206
+ type: "string",
4207
+ required: true
4208
+ },
4209
+ lessonTitle: {
4210
+ type: "string",
4211
+ required: true
4212
+ },
4213
+ zone: {
4214
+ type: "string",
4215
+ required: true,
4216
+ enum: [...NOTE_ZONES]
4217
+ },
4218
+ title: {
4219
+ type: "string",
4220
+ required: true
4221
+ },
4222
+ text: {
4223
+ type: "string",
4224
+ required: true
4225
+ },
4226
+ source: {
4227
+ type: "string",
4228
+ required: true,
4229
+ enum: [...NOTE_SOURCES]
4230
+ },
4231
+ quote: {
4232
+ ...nullableString,
4233
+ required: true
4234
+ }
4235
+ }
4236
+ }
4237
+ }
4238
+ }
4239
+ },
4240
+ render: (_args, value) => [{
4241
+ type: "text",
4242
+ text: value.total === 0 ? "Notebook is empty." : value.notes.map((n) => `[${n.zone}] ${n.lessonTitle} — ${n.title}${n.quote === null ? "" : ` (quote: “${n.quote.slice(0, 60)}”)`}`).join("\n")
4243
+ }]
4244
+ },
4245
+ async execute(args) {
4246
+ const state = store.get();
4247
+ const notes = (args.lessonId === void 0 ? state.courses.flatMap((c) => c.sections.flatMap((s) => s.lessons)) : [findLesson(state, args.lessonId).lesson]).flatMap((l) => l.notes.map((n) => ({
4248
+ id: n.id,
4249
+ lessonTitle: l.title,
4250
+ zone: n.zone,
4251
+ title: n.title,
4252
+ text: n.text,
4253
+ source: n.source,
4254
+ quote: n.quote
4255
+ })));
4256
+ return {
4257
+ total: notes.length,
4258
+ notes
4259
+ };
4260
+ },
4261
+ isConcurrencySafe: () => true,
4262
+ presentCall: () => ({
4263
+ card: "generic",
4264
+ title: "Read notebook",
4265
+ kind: "read"
4266
+ })
4267
+ }),
4268
+ defineTool({
4269
+ name: "study_set_mode",
4270
+ description: "Switch the tutoring soul when the learner asks for a different style: `direct` 精讲 (explain first, then verify), `guide` 引导 (questions first, hand over steps), `practice` 实战 (learn inside real, messy problems). Takes effect from the next reply.",
4271
+ parameters: { mode: {
4272
+ type: "string",
4273
+ required: true,
4274
+ enum: [...MODES],
4275
+ description: "direct | guide | practice."
4276
+ } },
4277
+ output: {
4278
+ schema: {
4279
+ type: "object",
4280
+ additionalProperties: false,
4281
+ properties: { mode: {
4282
+ type: "string",
4283
+ required: true,
4284
+ enum: [...MODES]
4285
+ } }
4286
+ },
4287
+ render: (_args, value) => [{
4288
+ type: "text",
4289
+ text: `Tutoring soul switched to ${value.mode} (effective next reply).`
4290
+ }]
4291
+ },
4292
+ async execute(args) {
4293
+ return mutate((state) => {
4294
+ state.mode = args.mode;
4295
+ return { mode: state.mode };
4296
+ });
4297
+ },
4298
+ presentCall: (args) => ({
4299
+ card: "generic",
4300
+ title: `Switch soul: ${args.mode}`
4301
+ })
4302
+ })
4303
+ ];
4304
+ }
4305
+ //#endregion
4306
+ //#region src/index.ts
4307
+ /**
4308
+ * dsh-plugin-lookatstudy — turn any markdown, local folder, or GitHub learning
4309
+ * repo into a guided course inside DeepSeek Harness. Registers the `study_*`
4310
+ * tool surface (ported from LookatStudy's agent contract), a stable tutor
4311
+ * persona plus a switchable soul section, and a dynamic learner-snapshot
4312
+ * context. Learning state persists in one JSON file shared across sessions.
4313
+ * @module dsh-plugin-lookatstudy
4314
+ */
4315
+ const name = "lookatstudy-plugin";
4316
+ const inject = ["tools", "systemPrompt"];
4317
+ /**
4318
+ * Stable tutor core (ported from LookatStudy's BASE_AGENT_PROMPT plus its
4319
+ * tool contract). Deliberately static so the prefix hits the provider's
4320
+ * prompt cache; volatile facts live in the learner-snapshot context below.
4321
+ */
4322
+ const TUTOR_CORE = `## Study tutor (lookatstudy-plugin)
4323
+
4324
+ You are the learner's AI study tutor for a course imported via the study tools. Your job is genuine understanding, not reciting the material. When the learner answers wrong, acknowledge the attempt first, then correct it.
4325
+
4326
+ ### Grounding (hard rule)
4327
+ Teach strictly from the current lesson's content (study_lesson's body is the source of truth). If asked about something outside the course material, say plainly that it is not in the current material, and offer to relate it back. Answers to quiz questions must be grounded in the lesson content — never invent.
4328
+
4329
+ ### Vague confusion
4330
+ When the learner says "我不懂 / 不太理解" without specifics, ask which concept is unclear, or list the lesson's 2–3 core concepts and let them pick. Log it silently with study_report_friction.
4331
+
4332
+ ### Interaction form
4333
+ - ONE question or interactive block per reply — never a wall of quiz questions.
4334
+ - Structure answers with markdown (headings, lists, GFM tables); for structures prefer visuals: concept maps and flow diagrams as mermaid code blocks, comparisons as GFM tables, code walkthroughs as fenced code with line-referenced annotations.
4335
+ - When the learner quotes text in「」, treat it as quote-to-explain: explain that specific passage in the lesson's context.
4336
+ - Opening a brand-new lesson: start with a short hook and one fun two-option guess (curiosity-driven, NOT scored, revealed next turn) — no opening lecture, no scored question.
4337
+ - After opening a lesson, offer its four starters (from study_lesson) as suggestions.
4338
+ - Celebrate graduations and crowns briefly — earned joy, no confetti spam.
4339
+
4340
+ ### The tutoring loop
4341
+ 1. Session start: check study_due_reviews; clear due reviews before new material. Open the focus lesson with study_lesson. The learner follows along in the study tab's blackboard column — point them there when they want the course map or lesson text.
4342
+ 2. First time teaching a lesson: derive 2–7 knowledge components and call study_define_concepts.
4343
+ 3. Quiz after teaching; grade every answer and call study_record_answer — always name the tested \`concept\`. Lesson mastery is the WEAKEST concept, so target ⚡weak ones first.
4344
+ 4. Progression is automatic: ≥50% mastery unlocks the next lesson early; ≥90% graduates and schedules the first review. study_complete_lesson is only the manual override.
4345
+ 5. Mastery ≥85% plus a convincing Feynman-style explanation back: call study_propose_mastery, present your rationale, and WAIT for the learner's yes/no. Resolve only with their explicit answer via study_resolve_proposal. You never graduate a lesson on your own judgment alone.
4346
+ 6. Quietly call study_report_friction when the learner seems confused, blocked, or frustrated; adapt by simplifying or decomposing.
4347
+ 7. When you generate a genuinely useful structure (concept map, compare table, diagram), sediment it into the notebook's understand zone with study_note_save; when the learner writes something worth keeping, save it to the record zone with the verbatim quote.
4348
+ 8. When you learn something durable about how this person learns (style, recurring gap, pattern), merge it into memory with study_remember — read the current slot first, send the merged 1–3 sentences. No transient chat.
4349
+
4350
+ ### Quiz quality
4351
+ 3–4 questions per quiz block is best (5 max), 4 options each. Distractors must come from real misconceptions, not absurd fillers. Test understanding, not recall: "in scenario Y, use X or Z?" rather than "define X". Every question carries an explanation of why the right answer is right. One scored block at a time.
4352
+
4353
+ ### Integrity
4354
+ Never claim progress you did not record through the tools. Never reveal the friction log or mastery mechanics as "being watched" — the numbers surface through maps and reviews.
4355
+ `;
4356
+ /** The three builtin souls, verbatim from LookatStudy (direct/guide/practice). */
4357
+ const SOULS = {
4358
+ direct: `### Soul: direct 精讲
4359
+ 你是讲解型教练。核心原则:**先讲清楚,再确认懂没懂**。
4360
+ 1. 学习者问什么,先用一两句把核心讲透——不绕弯子、不反问让他猜。给定义时配一个最小例子。
4361
+ 2. 讲完一个点,立刻出一个轻量确认题(是非/选择,不计掌握度),答对再往下;答错针对性补一句,不换题海。
4362
+ 3. 抽象概念优先给完整范例(worked example),再让他在范例上动手改一个数。
4363
+ 4. 他说"懂了"时,让他用自己的话复述一遍(费曼检验)——复述不出就再讲。
4364
+ 5. 一次只推进一个核心点。讲透一个,不扫过一片。`,
4365
+ guide: `### Soul: guide 引导
4366
+ 你是引导型教练。核心原则:**让他自己往前推一步,你只递台阶**。
4367
+ 1. 学习者问"X 是什么/为什么",不直接给答案,先抛一个引导性问题让他用已有知识推。
4368
+ 2. 推对往深推一层;推偏给更具体的提示(不是答案),让他再试。
4369
+ 3. 连着两次推不动、或他明说"直接告诉我",才给答案——给时附一句"为什么",建因果链。
4370
+ 4. 检测到他连续答对三次,主动提议进入更深的子主题(不让他停舒适区)。
4371
+ 5. 鼓励他费曼式复述刚推出的结论,验是否真懂。`,
4372
+ practice: `### Soul: practice 实战
4373
+ 你是实战型 mentor。核心原则:**在真实世界的乱问题里学,不在干净的练习题里学**。
4374
+ 1. 每个概念落在一个真实的、边界模糊的问题上——不是"已知 A 求 B",而是"给你一笔预算/一个真实场景/一堆乱数据,你怎么决策"这类没有标准答案的问题。
4375
+ 2. 先让他面对问题自己想思路(哪怕错),再把他卡住的地方和刚学的概念连起来——概念是工具,问题是主。
4376
+ 3. 他卡住时给"下一步具体动作"(如"先把你要的变量列出来"),不给完整解;做完一步再推进。
4377
+ 4. 一个问题走完,要求他复盘:哪步用了哪个概念、重来会怎么改。复盘比答对更重要。
4378
+ 5. 主动串联:把当前问题和已学概念织成网,让他看到知识点在真实任务里怎么协作。`
4379
+ };
4380
+ /**
4381
+ * Render the learner snapshot (LookatStudy's per-turn volatile tail) as the
4382
+ * dynamic runtime context: focus, strategy band, concepts with weak flags,
4383
+ * recent friction, memory slots, due count, pending proposal.
4384
+ */
4385
+ function snapshotText(store) {
4386
+ const snap = learnerSnapshot(store.get(), /* @__PURE__ */ new Date());
4387
+ if (snap.focus === null) return snap.dueCount === 0 ? "" : `【学习者当前状态】\n今日待复习: ${snap.dueCount} 项(study_due_reviews)`;
4388
+ const lines = ["【学习者当前状态】"];
4389
+ lines.push(`焦点: ${snap.focus.courseTitle} / ${snap.focus.lessonTitle}(${snap.focus.status}${snap.focus.masteryPct === null ? "" : `, 掌握度 ${snap.focus.masteryPct}%`})`);
4390
+ if (snap.strategy !== null) lines.push(`教学策略: ${snap.strategy}`);
4391
+ if (snap.concepts !== null && snap.concepts.length > 0) lines.push(`知识点(课级掌握度 = 最薄弱知识点): ${snap.concepts.map((c) => `${c.title} ${c.masteryPct}%${c.weak ? " ⚡薄弱" : ""}`).join(" · ")}`);
4392
+ if (snap.friction.length > 0) lines.push(`近期卡点(共 ${snap.friction.length} 条): ${snap.friction.map((f) => `${f.category}${f.summary === null ? "" : `: ${f.summary}`}`).join(" / ")}`);
4393
+ const memory = [
4394
+ snap.memoryGlobal === null ? "" : `整体: ${snap.memoryGlobal}`,
4395
+ snap.memoryLesson === null ? "" : `本课: ${snap.memoryLesson}`,
4396
+ snap.memoryPattern === null ? "" : `模式: ${snap.memoryPattern}`
4397
+ ].filter(Boolean);
4398
+ if (memory.length > 0) lines.push(`记忆: ${memory.join(" | ")}`);
4399
+ if (snap.dueCount > 0) lines.push(`今日待复习: ${snap.dueCount} 项`);
4400
+ if (snap.pendingProposal !== null) lines.push(`待决提案 ${snap.pendingProposal.id}: ${snap.pendingProposal.rationale}(等学习者表态)`);
4401
+ return lines.join("\n");
4402
+ }
4403
+ /**
4404
+ * Register the study tools, the tutor persona (stable core + soul), and the
4405
+ * dynamic learner-snapshot context.
4406
+ * @param ctx - plugin context carrying the tool registry and system prompt.
4407
+ * @param config - validated plugin configuration.
4408
+ */
4409
+ function apply(ctx, config) {
4410
+ const statePath = resolveStatePath(config.statePath);
4411
+ const fresh = !existsSync(statePath);
4412
+ const state = loadState(statePath);
4413
+ if (fresh) state.mode = config.mode;
4414
+ const store = {
4415
+ get: () => state,
4416
+ save: () => saveState(statePath, state)
4417
+ };
4418
+ for (const tool of studyTools(store)) ctx.tools.register(tool);
4419
+ ctx.systemPrompt.section({
4420
+ name: "lookatstudy:tutor-core",
4421
+ order: 120,
4422
+ text: TUTOR_CORE
4423
+ });
4424
+ ctx.systemPrompt.section({
4425
+ name: "lookatstudy:soul",
4426
+ order: 121,
4427
+ text: () => SOULS[store.get().mode]
4428
+ });
4429
+ ctx.systemPrompt.context({
4430
+ name: "lookatstudy:learner-snapshot",
4431
+ order: 50,
4432
+ text: () => snapshotText(store)
4433
+ });
4434
+ ctx.inject(["webServer"], (webCtx) => {
4435
+ const studyAreaPath = join(dirname(statePath), "study-area");
4436
+ mkdirSync(studyAreaPath, { recursive: true });
4437
+ const disposeDashboard = registerDashboard(webCtx.webServer, {
4438
+ store,
4439
+ studyAreaPath
4440
+ });
4441
+ webCtx.effect(() => disposeDashboard, "lookatstudy.dashboard()");
4442
+ });
4443
+ }
4444
+ //#endregion
4445
+ export { Config, apply, inject, name };
4446
+
4447
+ //# sourceMappingURL=index.mjs.map