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/README.md +3 -3
- package/lib/adoc-parser-1UxcYHid.mjs +47 -0
- package/lib/client.js +157 -28
- package/lib/client.js.map +1 -1
- package/lib/code-parser-BOOk9IWV.mjs +0 -2
- package/lib/index.d.mts +12 -5
- package/lib/index.mjs +967 -565
- package/lib/notebook-parser-ChbZBIKJ.mjs +0 -2
- package/lib/org-parser-BT5yvx9h.mjs +53 -0
- package/lib/rmd-parser-EgaMaffn.mjs +17 -0
- package/lib/rst-parser-CV93w3Sq.mjs +99 -0
- package/package.json +7 -2
- package/lib/code-parser-BOOk9IWV.mjs.map +0 -1
- package/lib/index.d.mts.map +0 -1
- package/lib/index.mjs.map +0 -1
- package/lib/notebook-parser-ChbZBIKJ.mjs.map +0 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
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", () =>
|
|
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) =>
|
|
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
|
-
|
|
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, {
|
|
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
|
-
*
|
|
2483
|
-
*
|
|
2484
|
-
* 流程: fetch README → detectRepoPattern → 发现文件树 → fetchMarkdownContents
|
|
2485
|
-
* → classifyFile(在 buildCourseFromFiles 内)→ buildCourseFromFiles
|
|
2165
|
+
* Step 1: 拉取仓库清单 —— README 全文 + 完整目录树 + 课程文件列表。
|
|
2486
2166
|
*
|
|
2487
|
-
*
|
|
2167
|
+
* 三样东西:
|
|
2168
|
+
* 1. README 全文 → 给 LLM 看课程大纲
|
|
2169
|
+
* 2. 完整目录树(所有路径) → 给 LLM 看仓库结构(translations/、images/、lab/ 等)
|
|
2170
|
+
* 3. 课程文件列表(filterLessonFiles 过滤后) → 供 Step 3+5 拉正文用
|
|
2488
2171
|
*
|
|
2489
|
-
*
|
|
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
|
|
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" ? [
|
|
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)
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
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
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
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
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
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("
|
|
2237
|
+
send("目录树拉取失败,使用 README 链接列表");
|
|
2534
2238
|
}
|
|
2535
|
-
|
|
2536
|
-
if (
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
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
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
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
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
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
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
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
|
|
2813
|
-
function signalFetch(signal) {
|
|
2814
|
-
return (input, init) =>
|
|
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
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
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
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
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
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
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
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
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: "
|
|
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
|
-
...
|
|
2924
|
-
render:
|
|
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
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
}
|
|
2936
|
-
const
|
|
2937
|
-
|
|
2938
|
-
|
|
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
|
-
...
|
|
3153
|
+
...designOrImportedPresent
|
|
2948
3154
|
}),
|
|
2949
3155
|
defineTool({
|
|
2950
3156
|
name: "study_import_github",
|
|
2951
|
-
description: "
|
|
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
|
-
...
|
|
2965
|
-
render:
|
|
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
|
|
2973
|
-
|
|
2974
|
-
|
|
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
|
-
...
|
|
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/
|
|
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
|
|
4386
|
-
|
|
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
|
-
*
|
|
4405
|
-
*
|
|
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
|
-
|
|
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:
|
|
4825
|
+
text: () => tutorCoreText(store.get())
|
|
4423
4826
|
});
|
|
4424
4827
|
ctx.systemPrompt.section({
|
|
4425
4828
|
name: "lookatstudy:soul",
|
|
4426
4829
|
order: 121,
|
|
4427
|
-
text: () =>
|
|
4830
|
+
text: () => soulText(store.get())
|
|
4428
4831
|
});
|
|
4429
4832
|
ctx.systemPrompt.context({
|
|
4430
4833
|
name: "lookatstudy:learner-snapshot",
|
|
4431
4834
|
order: 50,
|
|
4432
|
-
text: () =>
|
|
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
|