dsh-plugin-lookatstudy 0.8.1 → 0.9.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 CHANGED
@@ -18,7 +18,12 @@ const Config = z.object({
18
18
  "guide",
19
19
  "practice"
20
20
  ]).default("guide"),
21
- statePath: z.string().default("")
21
+ statePath: z.string().default(""),
22
+ active: z.union([
23
+ "auto",
24
+ "on",
25
+ "off"
26
+ ]).default("auto")
22
27
  });
23
28
  //#endregion
24
29
  //#region src/markdown.ts
@@ -207,7 +212,8 @@ function updateMastery(prev, correct, params = BKT_DEFAULTS) {
207
212
  const pObs = pObsGivenL * pL + pObsGivenNotL * (1 - pL);
208
213
  if (pObs === 0) return pL;
209
214
  const pLGivenObs = pObsGivenL * pL / pObs;
210
- return clamp01(pLGivenObs + pTransit * (1 - pLGivenObs));
215
+ const pLAfterTransit = pLGivenObs + pTransit * (1 - pLGivenObs);
216
+ return clamp01(pLAfterTransit);
211
217
  }
212
218
  /**
213
219
  * 把 mastery 概率映射成 crown level(1-5)给 UI 用。
@@ -237,11 +243,12 @@ const MASTERED_THRESHOLD = .9;
237
243
  /** Concepts below this mastery are flagged weak (LookatStudy kcContext). */
238
244
  const WEAK_CONCEPT_THRESHOLD = .7;
239
245
  const FRICTION_CAP = 10;
240
- /** Fresh empty state for a first run. */
246
+ /** Fresh empty state for a first run: dormant until the learner clicks 开始学习. */
241
247
  function emptyState() {
242
248
  return {
243
249
  version: 2,
244
250
  courses: [],
251
+ active: false,
245
252
  mode: "guide",
246
253
  focus: null,
247
254
  memoryGlobal: null,
@@ -258,7 +265,8 @@ function emptyState() {
258
265
  */
259
266
  function resolveStatePath(configured) {
260
267
  if (configured !== "") return configured;
261
- return join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), "lookatstudy-plugin", "state.json");
268
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
269
+ return join(dshHome, "lookatstudy-plugin", "state.json");
262
270
  }
263
271
  /**
264
272
  * Load persisted state; a missing file yields empty state, a corrupt file fails loud.
@@ -301,6 +309,7 @@ function loadState(path) {
301
309
  return {
302
310
  version: 2,
303
311
  courses,
312
+ active: raw.active ?? true,
304
313
  mode: raw.mode ?? "guide",
305
314
  focus: raw.focus ?? null,
306
315
  memoryGlobal: raw.memoryGlobal ?? null,
@@ -1053,6 +1062,7 @@ function workbenchState(state, now) {
1053
1062
  }
1054
1063
  const due = dueReviews(state, void 0, now);
1055
1064
  return {
1065
+ active: state.active,
1056
1066
  mode: state.mode,
1057
1067
  courses,
1058
1068
  focusLessonId: focusId,
@@ -1147,6 +1157,25 @@ function registerDashboard(webServer, deps) {
1147
1157
  sendJson(res, 200, workbenchState(deps.store.get(), /* @__PURE__ */ new Date()));
1148
1158
  return;
1149
1159
  }
1160
+ if (req.method === "POST" && pathname === "/lookatstudy/api/active") {
1161
+ const body = await readJsonBodySafe(req, res);
1162
+ if (body === void 0) return;
1163
+ if (typeof body.active !== "boolean") {
1164
+ sendJson(res, 400, {
1165
+ ok: false,
1166
+ error: "active (boolean) required"
1167
+ });
1168
+ return;
1169
+ }
1170
+ deps.store.get().active = body.active;
1171
+ deps.store.save();
1172
+ deps.onActiveChange(body.active);
1173
+ sendJson(res, 200, {
1174
+ ok: true,
1175
+ active: body.active
1176
+ });
1177
+ return;
1178
+ }
1150
1179
  if (req.method === "POST" && pathname === "/lookatstudy/api/focus") {
1151
1180
  const body = await readJsonBodySafe(req, res);
1152
1181
  if (body === void 0) return;
@@ -1419,7 +1448,7 @@ const IMAGE_EXT_MIME = {
1419
1448
  heic: "image/heic"
1420
1449
  };
1421
1450
  /** 排除的目录(非教学内容) */
1422
- const EXCLUDED_DIRS = new Set([
1451
+ const EXCLUDED_DIRS = /* @__PURE__ */ new Set([
1423
1452
  "node_modules",
1424
1453
  ".git",
1425
1454
  ".svn",
@@ -1522,7 +1551,8 @@ async function scanFolder(rootDir, onProgress, options) {
1522
1551
  let count = 0;
1523
1552
  for (const f of docFiles) {
1524
1553
  onProgress?.(++count, f.relPath);
1525
- const kind = EXT_KIND[f.relPath.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? ""];
1554
+ const ext = f.relPath.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "";
1555
+ const kind = EXT_KIND[ext];
1526
1556
  if (!kind) continue;
1527
1557
  try {
1528
1558
  const content = await readFileWithKind(f.absPath, kind);
@@ -1830,156 +1860,7 @@ function naturalPathCompare(a, b) {
1830
1860
  return pa.length - pb.length;
1831
1861
  }
1832
1862
  //#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
1863
  //#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
1864
  /** CDN URL 构造 */
1984
1865
  function cdnUrl(owner, repo, branch, path) {
1985
1866
  return `https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${path.replace(/^\.\//, "").replace(/^\//, "")}`;
@@ -2143,244 +2024,6 @@ function detectRepoPattern(readmeMd) {
2143
2024
  reason: `README 无课程文件链接,实质正文 ${proseChars} 字 → 将用文件树补全课程文件`
2144
2025
  };
2145
2026
  }
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
2027
  /** 从 .md 路径列表构造 DiscoveredFile[](复用 filterLessonFiles 排除规则 + 标题推断)。 */
2385
2028
  function pathsToDiscoveredFiles(paths) {
2386
2029
  const files = [];
@@ -2411,20 +2054,35 @@ function pathsToDiscoveredFiles(paths) {
2411
2054
  }
2412
2055
  return files;
2413
2056
  }
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
2057
  function httpsGet(url, opts = {}) {
2058
+ if (opts.signal?.aborted) return Promise.resolve({
2059
+ ok: false,
2060
+ error: "aborted"
2061
+ });
2427
2062
  return new Promise((resolve) => {
2063
+ let settled = false;
2064
+ const done = (r) => {
2065
+ if (settled) return;
2066
+ settled = true;
2067
+ clearTimeout(deadline);
2068
+ opts.signal?.removeEventListener("abort", onAbort);
2069
+ resolve(r);
2070
+ };
2071
+ const onAbort = () => {
2072
+ req.destroy();
2073
+ done({
2074
+ ok: false,
2075
+ error: "aborted"
2076
+ });
2077
+ };
2078
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
2079
+ const deadline = setTimeout(() => {
2080
+ req.destroy();
2081
+ done({
2082
+ ok: false,
2083
+ error: "deadline"
2084
+ });
2085
+ }, opts.deadlineMs ?? 25e3);
2428
2086
  const req = https.get(url, {
2429
2087
  headers: {
2430
2088
  "User-Agent": "lookatstudy-import",
@@ -2437,29 +2095,38 @@ function httpsGet(url, opts = {}) {
2437
2095
  res.on("data", (d) => {
2438
2096
  body += d.toString();
2439
2097
  });
2440
- res.on("end", () => resolve({
2098
+ res.on("end", () => done({
2441
2099
  ok: res.statusCode === 200,
2442
2100
  status: res.statusCode,
2443
2101
  body
2444
2102
  }));
2103
+ res.on("error", (e) => done({
2104
+ ok: false,
2105
+ status: res.statusCode,
2106
+ error: e.message
2107
+ }));
2445
2108
  });
2446
- req.on("error", (e) => resolve({
2109
+ req.on("error", (e) => done({
2447
2110
  ok: false,
2448
2111
  error: e.message
2449
2112
  }));
2450
2113
  req.on("timeout", () => {
2451
2114
  req.destroy();
2452
- resolve({
2115
+ done({
2453
2116
  ok: false,
2454
2117
  error: "timeout"
2455
2118
  });
2456
2119
  });
2457
2120
  });
2458
2121
  }
2459
- async function fetchRepoFileTree(owner, repo, branch, _fetchFn) {
2122
+ async function fetchRepoFileTree(owner, repo, branch, _fetchFn, signal) {
2460
2123
  const apiUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
2461
2124
  try {
2462
- const r = await httpsGet(apiUrl, { rejectUnauthorized: false });
2125
+ const r = await httpsGet(apiUrl, {
2126
+ rejectUnauthorized: false,
2127
+ deadlineMs: 24e4,
2128
+ signal
2129
+ });
2463
2130
  console.error(`[import] GitHub Tree API: HTTP ${r.status ?? r.error}`);
2464
2131
  if (r.ok && r.body) {
2465
2132
  const paths = (JSON.parse(r.body).tree ?? []).filter((n) => n.type === "blob").map((n) => n.path);
@@ -2471,6 +2138,22 @@ async function fetchRepoFileTree(owner, repo, branch, _fetchFn) {
2471
2138
  } catch (e) {
2472
2139
  console.error(`[import] GitHub Tree API 异常: ${e instanceof Error ? e.message : e}`);
2473
2140
  }
2141
+ try {
2142
+ const r2 = await httpsGet(`https://data.jsdelivr.com/v1/packages/gh/${owner}/${repo}@${branch}?structure=flat`, {
2143
+ rejectUnauthorized: false,
2144
+ signal
2145
+ });
2146
+ if (r2.ok && r2.body) {
2147
+ const paths = (JSON.parse(r2.body).files ?? []).map((f) => f.name);
2148
+ if (paths.length > 0) {
2149
+ console.error(`[import] jsdelivr data API 全树: ${paths.length} 文件(Tree API 降级)`);
2150
+ return {
2151
+ paths,
2152
+ source: "jsdelivr-list"
2153
+ };
2154
+ }
2155
+ }
2156
+ } catch {}
2474
2157
  return {
2475
2158
  paths: [],
2476
2159
  source: "none"
@@ -2479,95 +2162,512 @@ async function fetchRepoFileTree(owner, repo, branch, _fetchFn) {
2479
2162
  /** 文件数上限(防爆,和 IPC handler 一致) */
2480
2163
  const MAX_FILES = 500;
2481
2164
  /**
2482
- * GitHub 仓库构建课程结构 —— 纯编排函数。
2483
- *
2484
- * 流程: fetch README → detectRepoPattern → 发现文件树 → fetchMarkdownContents
2485
- * → classifyFile(在 buildCourseFromFiles 内)→ buildCourseFromFiles
2165
+ * Step 1: 拉取仓库清单 —— README 全文 + 完整目录树 + 课程文件列表。
2486
2166
  *
2487
- * 不落库、不发进度事件(onProgress 回调只传消息字符串,由调用方决定怎么用)。
2167
+ * 三样东西:
2168
+ * 1. README 全文 → 给 LLM 看课程大纲
2169
+ * 2. 完整目录树(所有路径) → 给 LLM 看仓库结构(translations/、images/、lab/ 等)
2170
+ * 3. 课程文件列表(filterLessonFiles 过滤后) → 供 Step 3+5 拉正文用
2488
2171
  *
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 进度回调(可选)
2172
+ * 不拉正文。
2494
2173
  */
2495
- async function importRepoToParsedCourse(owner, repo, branch, fetchFn, onProgress) {
2174
+ async function fetchRepoInventory(owner, repo, branch, fetchFn, onProgress, signal) {
2496
2175
  const send = (msg) => onProgress?.(msg);
2497
2176
  send("正在拉取 README…");
2498
- const branches = branch === "master" ? ["master", "main"] : ["main", "master"];
2177
+ const branches = branch === "master" ? [
2178
+ "master",
2179
+ "main",
2180
+ "develop",
2181
+ "gh-pages"
2182
+ ] : branch === "main" ? [
2183
+ "main",
2184
+ "master",
2185
+ "develop",
2186
+ "gh-pages"
2187
+ ] : [
2188
+ branch,
2189
+ "main",
2190
+ "master"
2191
+ ];
2192
+ const readmeCandidates = [
2193
+ "README.md",
2194
+ "readme.md",
2195
+ "README.MD",
2196
+ "README.rst",
2197
+ "README.adoc",
2198
+ "index.md",
2199
+ "home.md",
2200
+ "SUMMARY.md"
2201
+ ];
2499
2202
  let readmeMd = null;
2500
2203
  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(", ")})`);
2204
+ outer: for (const br of branches) for (const candidate of readmeCandidates) {
2205
+ if (signal?.aborted) throw new Error("导入已取消");
2206
+ try {
2207
+ const r = await fetchFn(cdnUrl(owner, repo, br, candidate));
2208
+ if (r.ok) {
2209
+ readmeMd = await r.text();
2210
+ readmeBranch = br;
2211
+ break outer;
2212
+ }
2213
+ } catch {}
2214
+ }
2215
+ if (!readmeMd) throw new Error(`无法拉取 README(试过分支: ${branches.join(", ")},文件名: ${readmeCandidates.join(", ")})`);
2510
2216
  send(`README 拉取成功(${readmeMd.length} 字符,分支 ${readmeBranch})`);
2511
2217
  const detection = detectRepoPattern(readmeMd);
2512
2218
  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);
2219
+ let fileList = filterLessonFiles(detection.lessonFiles ?? []);
2220
+ send(`README 链接发现 ${fileList.length} 个课程文件`);
2221
+ let fullTree = fileList.map((f) => f.path);
2222
+ try {
2223
+ send("扫描仓库完整目录结构…");
2224
+ const tree = await fetchRepoFileTree(owner, repo, readmeBranch, fetchFn, signal);
2525
2225
  if (tree.paths.length > 0) {
2226
+ fullTree = tree.paths;
2526
2227
  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})`);
2228
+ const existing = new Set(fileList.map((f) => f.path));
2229
+ const added = treeLessonFiles.filter((f) => !existing.has(f.path));
2230
+ if (added.length > 0) {
2231
+ fileList = [...fileList, ...added];
2232
+ send(`文件树补充 ${added.length} 个,共 ${fileList.length} 个课程文件`);
2530
2233
  }
2234
+ send(`目录树: ${fullTree.length} 个文件/目录`);
2531
2235
  }
2532
2236
  } catch {
2533
- send("文件树拉取失败,使用 README 链接发现");
2237
+ send("目录树拉取失败,使用 README 链接列表");
2534
2238
  }
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
- };
2239
+ if (signal?.aborted) throw new Error("导入已取消");
2240
+ if (fileList.length === 0) throw new Error(`未找到课程文件(README 无链接且文件树无可识别的文档/代码文件)`);
2241
+ if (fileList.length > MAX_FILES) {
2242
+ send(`文件数 ${fileList.length} 超过上限 ${MAX_FILES},截断`);
2243
+ fileList = fileList.slice(0, MAX_FILES);
2244
+ }
2245
+ return {
2246
+ readmeMd,
2247
+ fileList,
2248
+ fullTree,
2249
+ branch: readmeBranch,
2250
+ detection
2251
+ };
2252
+ }
2253
+ /**
2254
+ * Step 3: 批量提取文件的标题大纲(H1/H2/H3 + 每段字符数,不含正文)。
2255
+ * 拉取完整文件文本(不只前 N 行),因为字符数统计需要全文。
2256
+ * 并发度 5,同 fetchMarkdownContents。
2257
+ */
2258
+ async function fetchFileOutlines(filePaths, owner, repo, branch, fetchFn, onProgress, signal) {
2259
+ const result = /* @__PURE__ */ new Map();
2260
+ const CONCURRENCY = 5;
2261
+ for (let i = 0; i < filePaths.length; i += CONCURRENCY) {
2262
+ if (signal?.aborted) throw new Error("导入已取消");
2263
+ const batch = filePaths.slice(i, i + CONCURRENCY);
2264
+ const results = await Promise.allSettled(batch.map(async (filePath) => {
2265
+ const r = await fetchFn(cdnUrl(owner, repo, branch, filePath));
2266
+ if (!r.ok) return null;
2267
+ return {
2268
+ path: filePath,
2269
+ outline: extractOutlineWithCharCounts(await r.text(), filePath)
2270
+ };
2271
+ }));
2272
+ for (let j = 0; j < results.length; j++) {
2273
+ const res = results[j];
2274
+ if (res.status === "fulfilled" && res.value) result.set(res.value.path, res.value.outline);
2275
+ onProgress?.(i + j + 1, filePaths.length, batch[j] ?? "");
2276
+ }
2549
2277
  }
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);
2278
+ return result;
2279
+ }
2280
+ /**
2281
+ * markdown 文本提取 H1/H2/H3 标题 + 每段字符数。
2282
+ * 字符数 = 该标题行到下一个同级或更高级标题之间的字符数。
2283
+ * H2 边界:下一个 H1/H2;H3 边界:下一个 H1/H2/H3。
2284
+ * 代码块内的 # 不算标题。
2285
+ */
2286
+ function extractOutlineWithCharCounts(text, filePath) {
2287
+ const lines = text.split(/\r?\n/);
2288
+ const totalChars = text.length;
2289
+ let h1 = "";
2290
+ const rawHeadings = [];
2291
+ let inCodeFence = false;
2292
+ for (let i = 0; i < lines.length; i++) {
2293
+ const line = lines[i];
2294
+ if (/^(\s*)(```|~~~)/.test(line)) {
2295
+ inCodeFence = !inCodeFence;
2296
+ continue;
2297
+ }
2298
+ if (inCodeFence) continue;
2299
+ const h1Match = line.match(/^#\s+(.+)$/);
2300
+ if (h1Match) {
2301
+ if (!h1) h1 = h1Match[1].trim();
2302
+ rawHeadings.push({
2303
+ level: 1,
2304
+ title: h1Match[1].trim(),
2305
+ line: i
2306
+ });
2307
+ continue;
2308
+ }
2309
+ const h2Match = line.match(/^##\s+(.+)$/);
2310
+ if (h2Match) {
2311
+ rawHeadings.push({
2312
+ level: 2,
2313
+ title: h2Match[1].trim(),
2314
+ line: i
2315
+ });
2316
+ continue;
2317
+ }
2318
+ const h3Match = line.match(/^###\s+(.+)$/);
2319
+ if (h3Match) {
2320
+ rawHeadings.push({
2321
+ level: 3,
2322
+ title: h3Match[1].trim(),
2323
+ line: i
2324
+ });
2325
+ continue;
2326
+ }
2558
2327
  }
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} 章节,构建课程…`);
2328
+ const headings = [];
2329
+ for (let idx = 0; idx < rawHeadings.length; idx++) {
2330
+ const h = rawHeadings[idx];
2331
+ if (h.level === 1) continue;
2332
+ let endLine = lines.length;
2333
+ for (let j = idx + 1; j < rawHeadings.length; j++) if (rawHeadings[j].level <= h.level) {
2334
+ endLine = rawHeadings[j].line;
2335
+ break;
2336
+ }
2337
+ const sectionText = lines.slice(h.line, endLine).join("\n");
2338
+ headings.push({
2339
+ level: h.level,
2340
+ title: h.title,
2341
+ chars: sectionText.length
2342
+ });
2343
+ }
2344
+ if (!h1 && filePath.endsWith(".ipynb")) h1 = filePath.split("/").pop()?.replace(/\.ipynb$/i, "") ?? filePath;
2565
2345
  return {
2566
- course,
2567
- detection,
2568
- fetchedFiles: fetchResult.ok,
2569
- readmeBranch,
2570
- readmeMd
2346
+ h1: h1 || (filePath.split("/").pop() ?? filePath),
2347
+ totalChars,
2348
+ headings
2349
+ };
2350
+ }
2351
+ /**
2352
+ * 拉取单个文件的完整正文(Step 5a 用)。
2353
+ * 复用 fetchMarkdownContents 的解析逻辑(.ipynb → parseNotebook, .rst → rst-parser 等),
2354
+ * 但只拉一个文件,不做批量。
2355
+ */
2356
+ async function fetchSingleFileContent(filePath, owner, repo, branch, fetchFn) {
2357
+ try {
2358
+ const r = await fetchFn(cdnUrl(owner, repo, branch, filePath));
2359
+ if (!r.ok) return null;
2360
+ const lower = filePath.toLowerCase();
2361
+ if (lower.endsWith(".ipynb")) {
2362
+ const { parseNotebook } = await import("./notebook-parser-ChbZBIKJ.mjs");
2363
+ return parseNotebook(await r.text()).markdown;
2364
+ }
2365
+ const text = await r.text();
2366
+ if (lower.endsWith(".rst")) {
2367
+ const { parseRst } = await import("./rst-parser-CV93w3Sq.mjs");
2368
+ return parseRst(text).markdown;
2369
+ }
2370
+ if (lower.endsWith(".rmd")) {
2371
+ const { parseRmd } = await import("./rmd-parser-EgaMaffn.mjs");
2372
+ return parseRmd(text).markdown;
2373
+ }
2374
+ if (lower.endsWith(".org")) {
2375
+ const { parseOrg } = await import("./org-parser-BT5yvx9h.mjs");
2376
+ return parseOrg(text).markdown;
2377
+ }
2378
+ if (lower.endsWith(".adoc")) {
2379
+ const { parseAdoc } = await import("./adoc-parser-1UxcYHid.mjs");
2380
+ return parseAdoc(text).markdown;
2381
+ }
2382
+ if (CODE_EXTENSIONS.some((ext) => lower.endsWith(ext))) {
2383
+ const ext = lower.split(".").pop() ?? "";
2384
+ const { parseCode } = await import("./code-parser-BOOk9IWV.mjs");
2385
+ return parseCode(text, ext).markdown;
2386
+ }
2387
+ return text;
2388
+ } catch {
2389
+ return null;
2390
+ }
2391
+ }
2392
+ /**
2393
+ * Build the pending design from Step 1 (inventory) + Step 3 (outlines).
2394
+ * @param url - the GitHub URL the learner asked to import.
2395
+ * @param owner - repo owner.
2396
+ * @param repo - repo name.
2397
+ * @param inventory - fetchRepoInventory output (README, file list, tree).
2398
+ * @param outlines - fetchFileOutlines output, keyed by path.
2399
+ * @returns the pending design the brief renders from.
2400
+ */
2401
+ function buildPendingDesign(url, owner, repo, inventory, outlines) {
2402
+ const files = [];
2403
+ for (const discovered of inventory.fileList) {
2404
+ const outline = outlines.get(discovered.path);
2405
+ if (outline === void 0) continue;
2406
+ files.push({
2407
+ path: discovered.path,
2408
+ role: discovered.kind === "ipynb" ? "practice" : "original",
2409
+ outline
2410
+ });
2411
+ }
2412
+ const courseTitle = inventory.readmeMd.match(/^#\s+(.+)$/m)?.[1]?.trim() || repo;
2413
+ return {
2414
+ source: "github",
2415
+ url,
2416
+ owner,
2417
+ repo,
2418
+ branch: inventory.branch,
2419
+ courseTitle,
2420
+ readmeExcerpt: inventory.readmeMd.slice(0, 4e3),
2421
+ files,
2422
+ fullTreeCount: inventory.fullTree.length
2423
+ };
2424
+ }
2425
+ /**
2426
+ * Build the pending design from a local folder scan — the same brief, but the
2427
+ * bodies ride along (localContents), so apply is fully offline.
2428
+ * @param path - the absolute folder path (becomes sourceRef).
2429
+ * @param title - fallback course title (the folder's name).
2430
+ * @param docs - scanFolder output (relative paths + content).
2431
+ * @returns the pending design.
2432
+ */
2433
+ function buildPendingDesignFromFolder(path, title, docs) {
2434
+ const files = docs.map((doc) => ({
2435
+ path: doc.path,
2436
+ role: doc.kind === "ipynb" ? "practice" : "original",
2437
+ outline: extractOutlineWithCharCounts(doc.content, doc.path)
2438
+ }));
2439
+ const readme = docs.find((doc) => /^readme\.md$/i.test(doc.path.split("/").pop() ?? ""))?.content ?? "";
2440
+ const courseTitle = readme.match(/^#\s+(.+)$/m)?.[1]?.trim() || title;
2441
+ const localContents = new Map(docs.map((doc) => [doc.path, doc.content]));
2442
+ return {
2443
+ source: "folder",
2444
+ url: path,
2445
+ owner: "",
2446
+ repo: title,
2447
+ branch: "local",
2448
+ courseTitle,
2449
+ readmeExcerpt: readme.slice(0, 4e3),
2450
+ files,
2451
+ fullTreeCount: docs.length,
2452
+ localContents
2453
+ };
2454
+ }
2455
+ /**
2456
+ * Render the design brief — the ONLY channel into the tutor's context
2457
+ * (dsh models see tool results through output.render alone). Carries the
2458
+ * upstream design rules: study/practice/attached classification, the
2459
+ * 3000-8000 chars lesson pacing, sub-1000 merging, attached absorption, and
2460
+ * the strict JSON contract study_apply_design expects.
2461
+ * @param pending - the pending design to present.
2462
+ * @returns the full brief text.
2463
+ */
2464
+ function renderDesignBrief(pending) {
2465
+ const lines = [];
2466
+ lines.push(`## Course design brief: ${pending.courseTitle}`);
2467
+ lines.push(pending.source === "folder" ? `Folder import (${pending.fullTreeCount} files; ${pending.files.length} course files below).` : `Repo ${pending.owner}/${pending.repo}@${pending.branch} (${pending.fullTreeCount} paths in tree; ${pending.files.length} course files below).`);
2468
+ lines.push("");
2469
+ lines.push("### Repository README (first 4000 chars)");
2470
+ lines.push(pending.readmeExcerpt.trim() === "" ? "(empty)" : pending.readmeExcerpt);
2471
+ lines.push("");
2472
+ lines.push("### Files (role hint · h1 · totalChars · H2/H3 outline with per-heading chars)");
2473
+ for (const file of pending.files) {
2474
+ lines.push(`- ${file.path} (role hint: ${file.role}, total ${file.outline.totalChars} chars, h1: ${file.outline.h1})`);
2475
+ const headings = file.outline.headings.slice(0, 40);
2476
+ for (const heading of headings) lines.push(` ${"#".repeat(heading.level)} ${heading.title} [${heading.chars}]`);
2477
+ if (file.outline.headings.length > headings.length) lines.push(` (+${file.outline.headings.length - headings.length} more headings)`);
2478
+ }
2479
+ lines.push("");
2480
+ lines.push("### Design rules (from the LookatStudy import pipeline)");
2481
+ lines.push("Classify every lesson as exactly one of:");
2482
+ lines.push("- **study**: explanation/theory/tutorial content — the learning-world spine, its own lesson.");
2483
+ lines.push("- **practice**: Exercise / Lab / notebook — its own lesson.");
2484
+ lines.push("- **attached** (NOT its own lesson): quiz links, Conclusion, Challenge, Review references. Do not drop them — let the previous study lesson's anchor range naturally include them (omit their heading from the lesson list).");
2485
+ lines.push("Pacing by char counts (target 3000-8000 chars per lesson):");
2486
+ lines.push("- file totalChars < 3000 → one whole-file study lesson, no anchor.");
2487
+ lines.push("- an explanatory H2 (with its H3 children) under 8000 chars → one lesson, anchor = that H2's full title.");
2488
+ lines.push("- an explanatory H2 over 8000 chars with H3s → split into one lesson per H3, anchor = each H3's full title.");
2489
+ lines.push("- an explanatory H2 over 8000 chars without H3s → accept one long lesson.");
2490
+ lines.push("- after splitting, merge any lesson under 1000 chars into the adjacent same-world lesson to avoid fragmentation.");
2491
+ lines.push("Other rules:");
2492
+ lines.push("- role hints are references, not verdicts — README tables usually mark real roles (Lesson link = study, Notebook/Lab = practice).");
2493
+ lines.push("- if the directory layout is already clear (e.g. lessons/N-Topic/), keep its sections; do not over-reorganize.");
2494
+ lines.push(`- anchor is the full heading text used to slice the body; omit it for whole-file lessons.${pending.files.length > 80 ? " This repo is large: design at file granularity (omit anchors, one lesson per file) to keep the JSON manageable." : ""}`);
2495
+ lines.push("");
2496
+ lines.push("Now design the course and call study_apply_design with:");
2497
+ lines.push("{ \"sections\": [ { \"title\": \"...\", \"lessons\": [ { \"title\": \"...\", \"file\": \"<exact path from this brief>\", \"anchor\": \"<optional full heading text>\", \"world\": \"study\" | \"practice\" } ] } ] }");
2498
+ lines.push("Use ONLY file paths that appear in this brief — anything else is dropped. Apply directly, then walk the learner through the course map.");
2499
+ return lines.join("\n");
2500
+ }
2501
+ /**
2502
+ * Extract H2/H3 headings with line numbers; headings inside ``` / ~~~ fences
2503
+ * are body text (upstream import-pipeline.ts extractHeadings).
2504
+ * @param content - the file's markdown text.
2505
+ * @returns headings in document order.
2506
+ */
2507
+ function extractHeadings(content) {
2508
+ const lines = content.split(/\r?\n/);
2509
+ const headings = [];
2510
+ let inCodeFence = false;
2511
+ for (let i = 0; i < lines.length; i++) {
2512
+ const line = lines[i];
2513
+ if (/^\s*(```|~~~)/.test(line)) {
2514
+ inCodeFence = !inCodeFence;
2515
+ continue;
2516
+ }
2517
+ if (inCodeFence) continue;
2518
+ const h2 = line.match(/^##\s+(.+)$/);
2519
+ if (h2) {
2520
+ headings.push({
2521
+ level: 2,
2522
+ title: h2[1].trim(),
2523
+ line: i
2524
+ });
2525
+ continue;
2526
+ }
2527
+ const h3 = line.match(/^###\s+(.+)$/);
2528
+ if (h3) headings.push({
2529
+ level: 3,
2530
+ title: h3[1].trim(),
2531
+ line: i
2532
+ });
2533
+ }
2534
+ return headings;
2535
+ }
2536
+ /**
2537
+ * Locate an anchor among the file's headings by bidirectional substring
2538
+ * match (case-insensitive, leading #'s stripped) — upstream
2539
+ * import-pipeline.ts findTitleIndex.
2540
+ * @param headings - the file's headings.
2541
+ * @param anchor - the anchor text from the design.
2542
+ * @returns the heading index, or -1 when nothing matches.
2543
+ */
2544
+ function findTitleIndex(headings, anchor) {
2545
+ const anchorClean = anchor.replace(/^#{1,3}\s+/, "").toLowerCase().trim();
2546
+ if (anchorClean === "") return -1;
2547
+ for (let i = 0; i < headings.length; i++) {
2548
+ const titleLower = headings[i].title.toLowerCase();
2549
+ if (titleLower.includes(anchorClean) || anchorClean.includes(titleLower)) return i;
2550
+ }
2551
+ return -1;
2552
+ }
2553
+ /**
2554
+ * Slice one lesson's body out of a file (upstream import-pipeline.ts
2555
+ * extractSectionByIndex semantics, locked by upstream's
2556
+ * verify-section-extract suite):
2557
+ * - the file's FIRST lesson starts at line 0, absorbing the H1, preface
2558
+ * prose, and any leading attached H2 (e.g. a pre-lecture quiz);
2559
+ * - an H2 anchor runs until the next H2/H1, swallowing its H3 subsections;
2560
+ * - an H3 anchor runs until the very next H2/H3/H1;
2561
+ * - no matching heading → the whole file.
2562
+ * @param content - the file's text.
2563
+ * @param headings - the file's headings.
2564
+ * @param titleIndex - findTitleIndex result, or -1 for whole-file.
2565
+ * @param isFirstOfFile - whether this is the first designed lesson of the file.
2566
+ * @returns the sliced, trimmed body.
2567
+ */
2568
+ function sliceLessonBody(content, headings, titleIndex, isFirstOfFile) {
2569
+ const lines = content.split(/\r?\n/);
2570
+ if (titleIndex < 0) return content.trim();
2571
+ const anchor = headings[titleIndex];
2572
+ const startLine = isFirstOfFile ? 0 : anchor.line;
2573
+ let endLine = lines.length;
2574
+ for (let i = titleIndex + 1; i < headings.length; i++) if (headings[i].level <= anchor.level) {
2575
+ endLine = headings[i].line;
2576
+ break;
2577
+ }
2578
+ return lines.slice(startLine, endLine).join("\n").trim();
2579
+ }
2580
+ /**
2581
+ * Validate and clean the tutor's design JSON (upstream
2582
+ * parseStructureDesignResult rules): lessons pointing at files outside the
2583
+ * brief are dropped (anti-hallucination), worlds other than exactly
2584
+ * "practice" coerce to study, empty titles get a fallback, sections with no
2585
+ * surviving lessons are dropped.
2586
+ * @param design - the tutor's JSON (shape already enforced by the parameters schema).
2587
+ * @param validFiles - the pending design's file set.
2588
+ * @returns the cleaned design plus the dropped-lesson count.
2589
+ */
2590
+ function validateDesign(design, validFiles) {
2591
+ const sections = [];
2592
+ let droppedLessons = 0;
2593
+ for (const section of design.sections) {
2594
+ const lessons = [];
2595
+ for (const lesson of section.lessons ?? []) {
2596
+ if (typeof lesson.file !== "string" || !validFiles.has(lesson.file)) {
2597
+ droppedLessons++;
2598
+ continue;
2599
+ }
2600
+ const anchor = typeof lesson.anchor === "string" && lesson.anchor.trim() !== "" ? lesson.anchor : null;
2601
+ lessons.push({
2602
+ title: typeof lesson.title === "string" && lesson.title.trim() !== "" ? lesson.title.trim() : "Untitled lesson",
2603
+ file: lesson.file,
2604
+ anchor,
2605
+ world: lesson.world === "practice" ? "practice" : "study"
2606
+ });
2607
+ }
2608
+ if (lessons.length > 0) sections.push({
2609
+ title: typeof section.title === "string" && section.title.trim() !== "" ? section.title.trim() : "Untitled section",
2610
+ lessons
2611
+ });
2612
+ }
2613
+ if (sections.reduce((n, s) => n + s.lessons.length, 0) === 0) throw new Error(`lookatstudy-plugin: design produced 0 usable lessons (dropped ${droppedLessons} — every lesson's file must come from the design brief; call study_import_github again to re-read it)`);
2614
+ return {
2615
+ sections,
2616
+ droppedLessons
2617
+ };
2618
+ }
2619
+ /** GitHub-style anchor slug for ParsedLesson/ParsedSection anchors. */
2620
+ function slugAnchor(title) {
2621
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2622
+ return slug === "" ? "section" : slug;
2623
+ }
2624
+ /**
2625
+ * Assemble the final ParsedCourse from a validated design and the fetched
2626
+ * file contents: per-file heading extraction + anchor slicing with the
2627
+ * first-lesson-absorbs-the-header rule, practice worlds per lesson, and
2628
+ * all-practice sections marked so importCourse skips their exam nodes.
2629
+ * @param courseTitle - title from the brief.
2630
+ * @param validated - validateDesign output.
2631
+ * @param contents - fetched body text per designed file (missing key = loud failure).
2632
+ * @returns the parsed course ready for importCourse.
2633
+ */
2634
+ function buildCourseFromDesign(courseTitle, validated, contents) {
2635
+ const headingsCache = /* @__PURE__ */ new Map();
2636
+ const headingsOf = (file) => {
2637
+ const cached = headingsCache.get(file);
2638
+ if (cached === void 0) {
2639
+ const extracted = extractHeadings(contents.get(file) ?? "");
2640
+ headingsCache.set(file, extracted);
2641
+ return extracted;
2642
+ }
2643
+ return cached;
2644
+ };
2645
+ const firstLessonSeen = /* @__PURE__ */ new Set();
2646
+ return {
2647
+ title: courseTitle,
2648
+ sections: validated.sections.map((section) => {
2649
+ const lessons = section.lessons.map((lesson) => {
2650
+ const content = contents.get(lesson.file);
2651
+ if (content === void 0) throw new Error(`lookatstudy-plugin: no fetched content for designed file ${JSON.stringify(lesson.file)} — fetch failed earlier; retry study_apply_design`);
2652
+ const headings = headingsOf(lesson.file);
2653
+ const titleIndex = lesson.anchor === null ? -1 : findTitleIndex(headings, lesson.anchor);
2654
+ const isFirst = !firstLessonSeen.has(lesson.file);
2655
+ firstLessonSeen.add(lesson.file);
2656
+ return {
2657
+ title: lesson.title,
2658
+ anchor: slugAnchor(lesson.title),
2659
+ body: sliceLessonBody(content, headings, titleIndex, isFirst),
2660
+ sourceFilePath: lesson.file,
2661
+ world: lesson.world
2662
+ };
2663
+ });
2664
+ return {
2665
+ title: section.title,
2666
+ anchor: slugAnchor(section.title),
2667
+ world: lessons.every((l) => l.world === "practice") ? "practice" : "study",
2668
+ lessons
2669
+ };
2670
+ })
2571
2671
  };
2572
2672
  }
2573
2673
  //#endregion
@@ -2596,6 +2696,18 @@ function importLines(value) {
2596
2696
  return lines;
2597
2697
  }
2598
2698
  /**
2699
+ * Display lines for the design-required branch of a GitHub import.
2700
+ * @param value - the design_required branch value.
2701
+ * @returns card lines.
2702
+ */
2703
+ function designBriefLines(value) {
2704
+ return [
2705
+ `📐 Designing: ${value.courseTitle}`,
2706
+ `${value.repo}@${value.branch} · ${value.fileCount} course files · ${value.fullTreeCount} paths in tree`,
2707
+ "The tutor designs the structure, then study_apply_design imports it"
2708
+ ];
2709
+ }
2710
+ /**
2599
2711
  * Display lines for the skill-tree map.
2600
2712
  * @param value - map tool value.
2601
2713
  * @returns card lines.
@@ -2809,9 +2921,9 @@ function parseGithubUrl(url) {
2809
2921
  repo: match[2]
2810
2922
  };
2811
2923
  }
2812
- /** Wrap `fetch` so cancellation of the tool call aborts in-flight repo fetches. */
2813
- function signalFetch(signal) {
2814
- return (input, init) => fetch(input, {
2924
+ /** Wrap a fetch transport so cancellation of the tool call aborts in-flight repo fetches. */
2925
+ function signalFetch(signal, baseFetch) {
2926
+ return (input, init) => baseFetch(input, {
2815
2927
  ...init,
2816
2928
  signal
2817
2929
  });
@@ -2824,9 +2936,18 @@ const textBlocks = (lines) => (lines ?? []).map((text) => ({
2824
2936
  /**
2825
2937
  * Build the full study tool set over one store.
2826
2938
  * @param store - state store owned by `apply`.
2939
+ * @param deps - test seams (fetch stub); production leaves it default.
2827
2940
  * @returns tool definitions ready for `ctx.tools.register`.
2828
2941
  */
2829
- function studyTools(store) {
2942
+ function studyTools(store, deps = {}) {
2943
+ const baseFetch = deps.fetch ?? fetch;
2944
+ /**
2945
+ * The pending course design between study_import_github (design_required)
2946
+ * and study_apply_design. Memory-only on purpose: it is cheap to re-fetch
2947
+ * and state.json should not carry bulk inventories; a later import
2948
+ * replaces an unconsumed one.
2949
+ */
2950
+ let pendingDesign = null;
2830
2951
  /** Run a mutating state operation and persist. */
2831
2952
  const mutate = (fn) => {
2832
2953
  const result = fn(store.get());
@@ -2870,44 +2991,123 @@ function studyTools(store) {
2870
2991
  content: textBlocks(result.meta)
2871
2992
  })
2872
2993
  };
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
- }
2994
+ const importMarkdown = defineTool({
2995
+ name: "study_import_markdown",
2996
+ 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.",
2997
+ parameters: {
2998
+ markdown: {
2999
+ type: "string",
3000
+ required: true,
3001
+ description: "The full markdown source of the course."
2887
3002
  },
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
- }]
3003
+ title: {
3004
+ type: "string",
3005
+ description: "Optional course title overriding the first H1."
3006
+ }
3007
+ },
3008
+ output: {
3009
+ ...importOutput,
3010
+ render: (_args, value) => [{
3011
+ type: "text",
3012
+ text: `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
3013
+ }]
3014
+ },
3015
+ async execute(args) {
3016
+ const parsed = parseMarkdownToCourse(args.markdown);
3017
+ if (args.title !== void 0) parsed.title = args.title;
3018
+ requireParsedLessons(parsed);
3019
+ return mutate((state) => toImportValue(importCourse(state, parsed, "markdown", "pasted markdown")));
3020
+ },
3021
+ presentCall: (args) => ({
3022
+ card: "generic",
3023
+ title: `Import markdown course${args.title === void 0 ? "" : `: ${args.title}`}`,
3024
+ kind: "read"
3025
+ }),
3026
+ ...importPresent
3027
+ });
3028
+ /** Shared output for the two design-protocol import tools: already imported, or a brief is pending. */
3029
+ const designOrImportedOutput = { schema: { oneOf: [{
3030
+ type: "object",
3031
+ additionalProperties: false,
3032
+ properties: {
3033
+ status: {
3034
+ type: "string",
3035
+ enum: ["imported"],
3036
+ required: true
2894
3037
  },
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")));
3038
+ courseId: {
3039
+ type: "string",
3040
+ required: true
2900
3041
  },
2901
- presentCall: (args) => ({
2902
- card: "generic",
2903
- title: `Import markdown course${args.title === void 0 ? "" : `: ${args.title}`}`,
2904
- kind: "read"
2905
- }),
2906
- ...importPresent
2907
- }),
3042
+ title: {
3043
+ type: "string",
3044
+ required: true
3045
+ },
3046
+ sections: {
3047
+ type: "integer",
3048
+ required: true
3049
+ },
3050
+ lessons: {
3051
+ type: "integer",
3052
+ required: true
3053
+ },
3054
+ firstLessonId: {
3055
+ type: "string",
3056
+ required: true
3057
+ },
3058
+ firstLessonTitle: {
3059
+ type: "string",
3060
+ required: true
3061
+ }
3062
+ }
3063
+ }, {
3064
+ type: "object",
3065
+ additionalProperties: false,
3066
+ properties: {
3067
+ status: {
3068
+ type: "string",
3069
+ enum: ["design_required"],
3070
+ required: true
3071
+ },
3072
+ repo: {
3073
+ type: "string",
3074
+ required: true
3075
+ },
3076
+ branch: {
3077
+ type: "string",
3078
+ required: true
3079
+ },
3080
+ courseTitle: {
3081
+ type: "string",
3082
+ required: true
3083
+ },
3084
+ fileCount: {
3085
+ type: "integer",
3086
+ required: true
3087
+ },
3088
+ fullTreeCount: {
3089
+ type: "integer",
3090
+ required: true
3091
+ }
3092
+ }
3093
+ }] } };
3094
+ /** Shared render: the brief rides the design_required branch; imported stays the old summary. */
3095
+ const designOrImportedRender = (_args, value) => [{
3096
+ type: "text",
3097
+ text: value.status === "design_required" ? pendingDesign === null ? "Course design required — the brief is no longer pending; call the import tool again to re-fetch it." : renderDesignBrief(pendingDesign) : `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
3098
+ }];
3099
+ const designOrImportedPresent = {
3100
+ presentationMeta: (_args, value) => value.status === "design_required" ? designBriefLines(value) : importLines(value),
3101
+ presentResult: (_args, result) => ({
3102
+ card: "generic",
3103
+ content: textBlocks(result.meta)
3104
+ })
3105
+ };
3106
+ return [
3107
+ importMarkdown,
2908
3108
  defineTool({
2909
3109
  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.",
3110
+ description: "Start importing a local folder: scans markdown, txt, html, Jupyter notebooks, rst/Rmd/org/adoc, and 30+ code file types (PDF/PPTX unsupported), then returns a design brief the TUTOR designs the course structure from it and applies the result with study_apply_design (fully offline). Re-importing an already-imported path returns the existing course directly.",
2911
3111
  parameters: {
2912
3112
  path: {
2913
3113
  type: "string",
@@ -2916,26 +3116,32 @@ function studyTools(store) {
2916
3116
  },
2917
3117
  title: {
2918
3118
  type: "string",
2919
- description: "Optional course title overriding the folder name."
3119
+ description: "Optional course title overriding the folder name / README H1."
2920
3120
  }
2921
3121
  },
2922
3122
  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
- }]
3123
+ ...designOrImportedOutput,
3124
+ render: designOrImportedRender
2928
3125
  },
2929
3126
  async execute(args) {
2930
3127
  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)));
3128
+ const existing = store.get().courses.find((c) => c.source === "folder" && c.sourceRef === args.path);
3129
+ if (existing !== void 0) return {
3130
+ status: "imported",
3131
+ ...toImportValue(existing)
3132
+ };
3133
+ const docs = await scanFolder(args.path);
3134
+ if (docs.length === 0) throw new Error(`lookatstudy-plugin: no importable files found in ${args.path}`);
3135
+ const title = args.title ?? basename(args.path.replaceAll("\\", "/"));
3136
+ pendingDesign = buildPendingDesignFromFolder(args.path, title, docs);
3137
+ return {
3138
+ status: "design_required",
3139
+ repo: pendingDesign.repo,
3140
+ branch: pendingDesign.branch,
3141
+ courseTitle: pendingDesign.courseTitle,
3142
+ fileCount: pendingDesign.files.length,
3143
+ fullTreeCount: pendingDesign.fullTreeCount
3144
+ };
2939
3145
  },
2940
3146
  timeoutMs: 6e4,
2941
3147
  presentCall: (args) => ({
@@ -2944,11 +3150,11 @@ function studyTools(store) {
2944
3150
  kind: "read",
2945
3151
  rawInput: args.path
2946
3152
  }),
2947
- ...importPresent
3153
+ ...designOrImportedPresent
2948
3154
  }),
2949
3155
  defineTool({
2950
3156
  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.",
3157
+ description: "Start importing a GitHub learning repository: fetches the README outline and every course file's heading outline (with char counts) through the jsDelivr CDN, then returns a design brief — the TUTOR designs the course structure (sections/lessons/anchors/worlds) from it and applies the result with study_apply_design. Re-importing an already-imported URL returns the existing course directly. Awesome-lists are rejected.",
2952
3158
  parameters: {
2953
3159
  url: {
2954
3160
  type: "string",
@@ -2961,17 +3167,30 @@ function studyTools(store) {
2961
3167
  }
2962
3168
  },
2963
3169
  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
- }]
3170
+ ...designOrImportedOutput,
3171
+ render: designOrImportedRender
2969
3172
  },
2970
3173
  async execute(args, exec) {
2971
3174
  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)));
3175
+ const branch = args.branch ?? "main";
3176
+ const fetchFn = signalFetch(exec.signal, baseFetch);
3177
+ const existing = store.get().courses.find((c) => c.source === "github" && c.sourceRef === args.url);
3178
+ if (existing !== void 0) return {
3179
+ status: "imported",
3180
+ ...toImportValue(existing)
3181
+ };
3182
+ const inventory = await fetchRepoInventory(owner, repo, branch, fetchFn, void 0, exec.signal);
3183
+ const outlines = await fetchFileOutlines(inventory.fileList.map((f) => f.path), owner, repo, inventory.branch, fetchFn, void 0, exec.signal);
3184
+ pendingDesign = buildPendingDesign(args.url, owner, repo, inventory, outlines);
3185
+ if (pendingDesign.files.length === 0) throw new Error("lookatstudy-plugin: course files were discovered but no outlines could be fetched (CDN unreachable?)");
3186
+ return {
3187
+ status: "design_required",
3188
+ repo: `${owner}/${repo}`,
3189
+ branch: pendingDesign.branch,
3190
+ courseTitle: pendingDesign.courseTitle,
3191
+ fileCount: pendingDesign.files.length,
3192
+ fullTreeCount: pendingDesign.fullTreeCount
3193
+ };
2975
3194
  },
2976
3195
  timeoutMs: 18e4,
2977
3196
  presentCall: (args) => ({
@@ -2979,7 +3198,139 @@ function studyTools(store) {
2979
3198
  title: `Import GitHub course: ${args.url}`,
2980
3199
  kind: "fetch"
2981
3200
  }),
2982
- ...importPresent
3201
+ ...designOrImportedPresent
3202
+ }),
3203
+ defineTool({
3204
+ name: "study_apply_design",
3205
+ description: "Apply the tutor-designed course structure to the pending import (the one study_import_github or study_import_folder returned design_required for). Every lesson's file must come from the design brief — unknown paths are dropped (anti-hallucination); lesson bodies are sliced by their anchor heading and the course is imported. On a validation or fetch error the tutor fixes the design and simply calls again.",
3206
+ parameters: { sections: {
3207
+ type: "array",
3208
+ required: true,
3209
+ description: "Designed sections in learning order.",
3210
+ items: {
3211
+ type: "object",
3212
+ additionalProperties: false,
3213
+ properties: {
3214
+ title: {
3215
+ type: "string",
3216
+ required: true,
3217
+ description: "Section title (in the learner's language)."
3218
+ },
3219
+ lessons: {
3220
+ type: "array",
3221
+ required: true,
3222
+ items: {
3223
+ type: "object",
3224
+ additionalProperties: false,
3225
+ properties: {
3226
+ title: {
3227
+ type: "string",
3228
+ required: true,
3229
+ description: "Lesson title."
3230
+ },
3231
+ file: {
3232
+ type: "string",
3233
+ required: true,
3234
+ description: "Exact file path from the design brief."
3235
+ },
3236
+ anchor: {
3237
+ type: "string",
3238
+ description: "Full H2/H3 heading text the lesson body starts at; omit for whole-file lessons."
3239
+ },
3240
+ world: {
3241
+ type: "string",
3242
+ description: "\"study\" (explanation) or \"practice\" (exercise/lab/notebook); anything else is treated as study."
3243
+ }
3244
+ }
3245
+ }
3246
+ }
3247
+ }
3248
+ }
3249
+ } },
3250
+ output: {
3251
+ schema: {
3252
+ type: "object",
3253
+ additionalProperties: false,
3254
+ properties: {
3255
+ courseId: {
3256
+ type: "string",
3257
+ required: true
3258
+ },
3259
+ title: {
3260
+ type: "string",
3261
+ required: true
3262
+ },
3263
+ sections: {
3264
+ type: "integer",
3265
+ required: true
3266
+ },
3267
+ lessons: {
3268
+ type: "integer",
3269
+ required: true
3270
+ },
3271
+ firstLessonId: {
3272
+ type: "string",
3273
+ required: true
3274
+ },
3275
+ firstLessonTitle: {
3276
+ type: "string",
3277
+ required: true
3278
+ },
3279
+ droppedLessons: {
3280
+ type: "integer",
3281
+ required: true
3282
+ }
3283
+ }
3284
+ },
3285
+ render: (_args, value) => [{
3286
+ type: "text",
3287
+ text: `Imported designed course “${value.title}” (${value.sections} sections, ${value.lessons} lessons${value.droppedLessons > 0 ? `, ${value.droppedLessons} hallucinated lesson(s) dropped` : ""}). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}). Present the course map to the learner.`
3288
+ }]
3289
+ },
3290
+ async execute(args, exec) {
3291
+ const pd = pendingDesign;
3292
+ if (pd === null) throw new Error("lookatstudy-plugin: no pending course design — call study_import_github or study_import_folder first (a dsh restart also clears it)");
3293
+ const validated = validateDesign(args, new Set(pd.files.map((f) => f.path)));
3294
+ const uniqueFiles = [...new Set(validated.sections.flatMap((s) => s.lessons.map((l) => l.file)))];
3295
+ const contents = /* @__PURE__ */ new Map();
3296
+ if (pd.localContents !== void 0) for (const file of uniqueFiles) {
3297
+ const text = pd.localContents.get(file);
3298
+ if (text === void 0) throw new Error(`lookatstudy-plugin: designed file ${JSON.stringify(file)} is not in the scanned folder — use only paths from the design brief`);
3299
+ contents.set(file, text);
3300
+ }
3301
+ else {
3302
+ const failed = [];
3303
+ const fetchFn = signalFetch(exec.signal, baseFetch);
3304
+ for (let i = 0; i < uniqueFiles.length; i += 5) {
3305
+ if (exec.signal.aborted) throw new Error("lookatstudy-plugin: import aborted");
3306
+ const batch = uniqueFiles.slice(i, i + 5);
3307
+ const texts = await Promise.all(batch.map((f) => fetchSingleFileContent(f, pd.owner, pd.repo, pd.branch, fetchFn)));
3308
+ for (let j = 0; j < batch.length; j++) if (texts[j] === null) failed.push(batch[j]);
3309
+ else contents.set(batch[j], texts[j]);
3310
+ }
3311
+ if (contents.size === 0) throw new Error(`lookatstudy-plugin: every designed file failed to fetch (${failed.length}) — the CDN path is unreachable; retry or re-import`);
3312
+ if (failed.length > 0) throw new Error(`lookatstudy-plugin: ${failed.length} designed file(s) failed to fetch: ${failed.join(", ")} — drop or fix them and call study_apply_design again`);
3313
+ }
3314
+ const parsed = buildCourseFromDesign(pd.courseTitle, validated, contents);
3315
+ requireParsedLessons(parsed);
3316
+ const value = mutate((state) => toImportValue(importCourse(state, parsed, pd.source, pd.url)));
3317
+ pendingDesign = null;
3318
+ return {
3319
+ ...value,
3320
+ droppedLessons: validated.droppedLessons
3321
+ };
3322
+ },
3323
+ timeoutMs: 18e4,
3324
+ presentCall: () => ({
3325
+ card: "generic",
3326
+ title: "Apply course design",
3327
+ kind: "edit"
3328
+ }),
3329
+ presentationMeta: (_args, value) => [...importLines(value), ...value.droppedLessons > 0 ? [`${value.droppedLessons} dropped (files outside the brief)`] : []],
3330
+ presentResult: (_args, result) => ({
3331
+ card: "generic",
3332
+ content: textBlocks(result.meta)
3333
+ })
2983
3334
  }),
2984
3335
  defineTool({
2985
3336
  name: "study_courses",
@@ -4303,17 +4654,7 @@ function studyTools(store) {
4303
4654
  ];
4304
4655
  }
4305
4656
  //#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"];
4657
+ //#region src/surface.ts
4317
4658
  /**
4318
4659
  * Stable tutor core (ported from LookatStudy's BASE_AGENT_PROMPT plus its
4319
4660
  * tool contract). Deliberately static so the prefix hits the provider's
@@ -4326,6 +4667,9 @@ You are the learner's AI study tutor for a course imported via the study tools.
4326
4667
  ### Grounding (hard rule)
4327
4668
  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
4669
 
4670
+ ### Language
4671
+ Answer in the learner's own language (the language of their interface and their messages), including quiz stems, options, and explanations. Quote course material verbatim in its original language.
4672
+
4329
4673
  ### Vague confusion
4330
4674
  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
4675
 
@@ -4347,6 +4691,9 @@ When the learner says "我不懂 / 不太理解" without specifics, ask which co
4347
4691
  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
4692
  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
4693
 
4694
+ ### Course import design
4695
+ When study_import_github or study_import_folder returns status "design_required", it renders a design brief (files with heading outlines and per-heading char counts). Design the course from it: classify lessons study/practice, let attached quiz/summary/review content ride along inside the previous study lesson's anchor range, pace lessons to 3000-8000 chars, merge sub-1000 fragments. Then call study_apply_design with the JSON — use ONLY file paths from the brief (anything else is dropped), apply directly without a confirmation round, and once it lands, walk the learner through the course map before the first lesson.
4696
+
4350
4697
  ### Quiz quality
4351
4698
  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
4699
 
@@ -4377,13 +4724,23 @@ const SOULS = {
4377
4724
  4. 一个问题走完,要求他复盘:哪步用了哪个概念、重来会怎么改。复盘比答对更重要。
4378
4725
  5. 主动串联:把当前问题和已学概念织成网,让他看到知识点在真实任务里怎么协作。`
4379
4726
  };
4727
+ /** The dormant gate: an inactive surface renders no persona text at all. */
4728
+ function tutorCoreText(state) {
4729
+ return state.active ? TUTOR_CORE : "";
4730
+ }
4731
+ /** The active soul under the same gate (inactive renders empty). */
4732
+ function soulText(state) {
4733
+ return state.active ? SOULS[state.mode] : "";
4734
+ }
4380
4735
  /**
4381
4736
  * Render the learner snapshot (LookatStudy's per-turn volatile tail) as the
4382
4737
  * dynamic runtime context: focus, strategy band, concepts with weak flags,
4383
- * recent friction, memory slots, due count, pending proposal.
4738
+ * recent friction, memory slots, due count, pending proposal. Dormant
4739
+ * surfaces render nothing.
4384
4740
  */
4385
- function snapshotText(store) {
4386
- const snap = learnerSnapshot(store.get(), /* @__PURE__ */ new Date());
4741
+ function snapshotSectionText(state) {
4742
+ if (!state.active) return "";
4743
+ const snap = learnerSnapshot(state, /* @__PURE__ */ new Date());
4387
4744
  if (snap.focus === null) return snap.dueCount === 0 ? "" : `【学习者当前状态】\n今日待复习: ${snap.dueCount} 项(study_due_reviews)`;
4388
4745
  const lines = ["【学习者当前状态】"];
4389
4746
  lines.push(`焦点: ${snap.focus.courseTitle} / ${snap.focus.lessonTitle}(${snap.focus.status}${snap.focus.masteryPct === null ? "" : `, 掌握度 ${snap.focus.masteryPct}%`})`);
@@ -4401,8 +4758,51 @@ function snapshotText(store) {
4401
4758
  return lines.join("\n");
4402
4759
  }
4403
4760
  /**
4404
- * Register the study tools, the tutor persona (stable core + soul), and the
4405
- * dynamic learner-snapshot context.
4761
+ * Create the activation-gated tool surface: `sync()` registers all study
4762
+ * tools when active and disposes them when not; it is idempotent per state,
4763
+ * so callers may fire it on every activation flip.
4764
+ * @param registry - the host tool registry (`ctx.tools`).
4765
+ * @param store - the live learning-state store.
4766
+ */
4767
+ function createStudySurface(registry, store) {
4768
+ const disposers = [];
4769
+ let surfaceOn = false;
4770
+ const unregisterAll = () => {
4771
+ for (const dispose of disposers.splice(0)) dispose();
4772
+ };
4773
+ return {
4774
+ sync() {
4775
+ const want = store.get().active;
4776
+ if (want === surfaceOn) return;
4777
+ surfaceOn = want;
4778
+ if (want) for (const tool of studyTools(store)) disposers.push(registry.register(tool));
4779
+ else unregisterAll();
4780
+ },
4781
+ dispose() {
4782
+ surfaceOn = false;
4783
+ unregisterAll();
4784
+ }
4785
+ };
4786
+ }
4787
+ //#endregion
4788
+ //#region src/index.ts
4789
+ /**
4790
+ * dsh-plugin-lookatstudy — turn any markdown, local folder, or GitHub learning
4791
+ * repo into a guided course inside DeepSeek Harness. Registers the `study_*`
4792
+ * tool surface (ported from LookatStudy's agent contract), a stable tutor
4793
+ * persona plus a switchable soul section, and a dynamic learner-snapshot
4794
+ * context — all activation-gated: dormant installs expose none of it until
4795
+ * the learner clicks 开始学习. Learning state persists in one JSON file
4796
+ * shared across sessions.
4797
+ * @module dsh-plugin-lookatstudy
4798
+ */
4799
+ const name = "lookatstudy-plugin";
4800
+ const inject = ["tools", "systemPrompt"];
4801
+ /**
4802
+ * Register the activation-gated study surface: the 20 `study_*` tools (kept
4803
+ * unregistered while dormant), the tutor persona (stable core + soul), and
4804
+ * the dynamic learner-snapshot context — every prompt text renders empty
4805
+ * while inactive, and empty sections are dropped at assembly.
4406
4806
  * @param ctx - plugin context carrying the tool registry and system prompt.
4407
4807
  * @param config - validated plugin configuration.
4408
4808
  */
@@ -4411,37 +4811,39 @@ function apply(ctx, config) {
4411
4811
  const fresh = !existsSync(statePath);
4412
4812
  const state = loadState(statePath);
4413
4813
  if (fresh) state.mode = config.mode;
4814
+ if (config.active !== "auto") state.active = config.active === "on";
4414
4815
  const store = {
4415
4816
  get: () => state,
4416
4817
  save: () => saveState(statePath, state)
4417
4818
  };
4418
- for (const tool of studyTools(store)) ctx.tools.register(tool);
4819
+ const surface = createStudySurface(ctx.tools, store);
4820
+ surface.sync();
4821
+ ctx.effect(() => () => surface.dispose(), "lookatstudy.studySurface()");
4419
4822
  ctx.systemPrompt.section({
4420
4823
  name: "lookatstudy:tutor-core",
4421
4824
  order: 120,
4422
- text: TUTOR_CORE
4825
+ text: () => tutorCoreText(store.get())
4423
4826
  });
4424
4827
  ctx.systemPrompt.section({
4425
4828
  name: "lookatstudy:soul",
4426
4829
  order: 121,
4427
- text: () => SOULS[store.get().mode]
4830
+ text: () => soulText(store.get())
4428
4831
  });
4429
4832
  ctx.systemPrompt.context({
4430
4833
  name: "lookatstudy:learner-snapshot",
4431
4834
  order: 50,
4432
- text: () => snapshotText(store)
4835
+ text: () => snapshotSectionText(store.get())
4433
4836
  });
4434
4837
  ctx.inject(["webServer"], (webCtx) => {
4435
4838
  const studyAreaPath = join(dirname(statePath), "study-area");
4436
4839
  mkdirSync(studyAreaPath, { recursive: true });
4437
4840
  const disposeDashboard = registerDashboard(webCtx.webServer, {
4438
4841
  store,
4439
- studyAreaPath
4842
+ studyAreaPath,
4843
+ onActiveChange: surface.sync
4440
4844
  });
4441
4845
  webCtx.effect(() => disposeDashboard, "lookatstudy.dashboard()");
4442
4846
  });
4443
4847
  }
4444
4848
  //#endregion
4445
4849
  export { Config, apply, inject, name };
4446
-
4447
- //# sourceMappingURL=index.mjs.map