dsh-plugin-lookatstudy 0.22.1 → 0.22.2
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/client.js +229 -91
- package/lib/client.js.map +1 -1
- package/lib/{docx-parser-BhyqPImb.mjs → docx-parser-BB_sCd8F.mjs} +1 -1
- package/lib/{epub-parser-oH96guBW.mjs → epub-parser-B7JEfgSO.mjs} +2 -2
- package/lib/{html-article-Da8ksU0i.mjs → html-article-k4tDTqDh.mjs} +6 -2
- package/lib/index.mjs +731 -168
- package/lib/{inflate-DIKUrTRi.mjs → inflate-C-hhR5be.mjs} +19 -7
- package/lib/{pptx-parser-B9IDkiGM.mjs → pptx-parser-BooxpGV4.mjs} +1 -1
- package/lib/{zip-reader-KnRrq0av.mjs → zip-reader-BakpZwuD.mjs} +1 -1
- package/package.json +3 -2
package/lib/index.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { n as inflateZlib } from "./inflate-
|
|
2
|
-
import { t as extractArticle } from "./html-article-
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { n as inflateZlib } from "./inflate-C-hhR5be.mjs";
|
|
2
|
+
import { t as extractArticle } from "./html-article-k4tDTqDh.mjs";
|
|
3
|
+
import { closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
6
|
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
8
|
import https from "node:https";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { readFile, readdir } from "node:fs/promises";
|
|
@@ -82,7 +82,6 @@ const ZH = {
|
|
|
82
82
|
"quiz.right": "答对了",
|
|
83
83
|
"quiz.wrong": "答错了",
|
|
84
84
|
"quiz.youChose": "你选了",
|
|
85
|
-
"quiz.answer": "正确答案",
|
|
86
85
|
"quiz.hook": "练习卡完成:{correct}/{total} 正确。请简短点评,针对错题讲讲思路。",
|
|
87
86
|
"quiz.action.explain-wrong": "讲讲错题",
|
|
88
87
|
"quiz.action.retry": "再来一组",
|
|
@@ -118,8 +117,6 @@ const ZH = {
|
|
|
118
117
|
"review.nudge.go": "去复习",
|
|
119
118
|
"rail.search.jump": "打开这一课",
|
|
120
119
|
"proposal.banner": "《{lesson}》可以提前毕业了",
|
|
121
|
-
"proposal.accept": "接受",
|
|
122
|
-
"proposal.decline": "再练练",
|
|
123
120
|
"proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
|
|
124
121
|
"proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
|
|
125
122
|
"tutor.thinking": "导师思考中…",
|
|
@@ -400,7 +397,7 @@ const ZH = {
|
|
|
400
397
|
"exam.leave.message": "考试进行中——现在离开,未答的题将按错计分。",
|
|
401
398
|
"exam.leave.confirm": "离开并结算",
|
|
402
399
|
"exam.leave.cancel": "继续答题",
|
|
403
|
-
"prompt.exam.bank": "请为「{section}」的章节测验出题:先用
|
|
400
|
+
"prompt.exam.bank": "请为「{section}」的章节测验出题:先用 study_lesson 读一遍本节课时,再按各课时的知识点用 study_exam_bank_apply 一次性提交整份题库(单选 5-15 题,kcTitle 必须用本节的概念标题,每题附一句话解析)。题库就绪后告诉我可以开考。",
|
|
404
401
|
"prompt.review": "开始今天的复习,从最到期的课时开始",
|
|
405
402
|
"prompt.proposal.accept": "接受提案 {id} —— 确认标记这课为已掌握",
|
|
406
403
|
"prompt.proposal.decline": "拒绝提案 {id} —— 我想再练练"
|
|
@@ -445,7 +442,6 @@ const EN = {
|
|
|
445
442
|
"quiz.right": "Correct",
|
|
446
443
|
"quiz.wrong": "Wrong",
|
|
447
444
|
"quiz.youChose": "You chose",
|
|
448
|
-
"quiz.answer": "Answer",
|
|
449
445
|
"quiz.hook": "Practice card finished: {correct}/{total} correct. Please comment briefly and walk through the wrong ones.",
|
|
450
446
|
"quiz.action.explain-wrong": "Explain my wrong answers",
|
|
451
447
|
"quiz.action.retry": "Another set",
|
|
@@ -481,8 +477,6 @@ const EN = {
|
|
|
481
477
|
"review.nudge.go": "Review now",
|
|
482
478
|
"rail.search.jump": "Open this lesson",
|
|
483
479
|
"proposal.banner": "{lesson} is ready to graduate early",
|
|
484
|
-
"proposal.accept": "Accept",
|
|
485
|
-
"proposal.decline": "Keep practicing",
|
|
486
480
|
"proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
|
|
487
481
|
"proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
|
|
488
482
|
"tutor.thinking": "The tutor is thinking…",
|
|
@@ -763,7 +757,7 @@ const EN = {
|
|
|
763
757
|
"exam.leave.message": "The exam is in progress — leaving now counts unanswered questions as wrong.",
|
|
764
758
|
"exam.leave.confirm": "Leave & settle",
|
|
765
759
|
"exam.leave.cancel": "Keep answering",
|
|
766
|
-
"prompt.exam.bank": "Author the section exam bank for \"{section}\": read this section's lessons with
|
|
760
|
+
"prompt.exam.bank": "Author the section exam bank for \"{section}\": read this section's lessons with study_lesson, then submit the full multiple-choice set in one study_exam_bank_apply call (5-15 single-choice questions, kcTitle must be one of the section's concept titles, one-line explanation each). Tell me when the bank is ready.",
|
|
767
761
|
"prompt.review": "Start today's review, from the most overdue lesson first",
|
|
768
762
|
"prompt.proposal.accept": "Accept proposal {id} — confirm marking this lesson as mastered",
|
|
769
763
|
"prompt.proposal.decline": "Reject proposal {id} — I want to practice more"
|
|
@@ -1072,7 +1066,7 @@ function coursePackMarkdown(course) {
|
|
|
1072
1066
|
for (const lesson of section.lessons) {
|
|
1073
1067
|
if (lesson.kind === "exam") continue;
|
|
1074
1068
|
out.push(`### ${lesson.title}`, "");
|
|
1075
|
-
const body = lesson.body.replace(/\s+$/, "");
|
|
1069
|
+
const body = lesson.body.replace(/\s+$/, "").split("\n").map((line) => /^#{1,3}(\s|$)/.test(line) ? `\\${line}` : line).join("\n");
|
|
1076
1070
|
if (body !== "") out.push(body, "");
|
|
1077
1071
|
}
|
|
1078
1072
|
}
|
|
@@ -1513,10 +1507,12 @@ function emptyState() {
|
|
|
1513
1507
|
focus: null,
|
|
1514
1508
|
memoryGlobal: null,
|
|
1515
1509
|
memoryPatterns: {},
|
|
1510
|
+
frictionGlobal: [],
|
|
1516
1511
|
artifacts: {},
|
|
1517
1512
|
proposals: [],
|
|
1518
1513
|
lessonSessions: {},
|
|
1519
1514
|
lessonThreads: {},
|
|
1515
|
+
trash: [],
|
|
1520
1516
|
lastConsolidatedAt: null,
|
|
1521
1517
|
xp: {
|
|
1522
1518
|
total: 0,
|
|
@@ -1543,14 +1539,48 @@ function resolveStatePath(configured) {
|
|
|
1543
1539
|
return join(dshHome, "lookatstudy-plugin", "state.json");
|
|
1544
1540
|
}
|
|
1545
1541
|
/**
|
|
1546
|
-
* Load persisted state; a missing file yields empty state,
|
|
1542
|
+
* Load persisted state; a missing file yields empty state, and an unusable
|
|
1543
|
+
* file (torn bytes, future version) recovers from `.bak` or degrades to a
|
|
1544
|
+
* fresh state instead of throwing out of the host's apply() (audit A3).
|
|
1547
1545
|
* v1 → v2 migration: `completed` lessons become `mastered`, lessons gain `kind`
|
|
1548
|
-
* (default `study`).
|
|
1546
|
+
* (default `study`).
|
|
1549
1547
|
* @param path - state-file path.
|
|
1550
1548
|
* @returns the loaded state.
|
|
1551
1549
|
*/
|
|
1552
1550
|
function loadState(path) {
|
|
1553
|
-
if (
|
|
1551
|
+
if (existsSync(path)) try {
|
|
1552
|
+
return parseStateFile(path);
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1555
|
+
const backup = loadBackup(path);
|
|
1556
|
+
if (backup !== null) {
|
|
1557
|
+
console.error(`lookatstudy-plugin: state file unusable (${message}); recovered from ${path}.bak`);
|
|
1558
|
+
return backup;
|
|
1559
|
+
}
|
|
1560
|
+
console.error(`lookatstudy-plugin: state file unusable (${message}) and no backup survived — quarantining it and starting fresh (${path})`);
|
|
1561
|
+
quarantine(path);
|
|
1562
|
+
return emptyState();
|
|
1563
|
+
}
|
|
1564
|
+
return loadBackup(path) ?? emptyState();
|
|
1565
|
+
}
|
|
1566
|
+
function loadBackup(path) {
|
|
1567
|
+
const bak = `${path}.bak`;
|
|
1568
|
+
if (!existsSync(bak)) return null;
|
|
1569
|
+
try {
|
|
1570
|
+
return parseStateFile(bak);
|
|
1571
|
+
} catch {
|
|
1572
|
+
return null;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
function quarantine(path) {
|
|
1576
|
+
try {
|
|
1577
|
+
renameSync(path, `${path}.corrupt-${Date.now().toString(36)}`);
|
|
1578
|
+
} catch (error) {
|
|
1579
|
+
console.error(`lookatstudy-plugin: could not quarantine the unusable state file (${String(error)})`);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
/** Parse + migrate one state file; malformed bytes, shapes, and future versions throw. */
|
|
1583
|
+
function parseStateFile(path) {
|
|
1554
1584
|
let parsed;
|
|
1555
1585
|
try {
|
|
1556
1586
|
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -1588,6 +1618,7 @@ function loadState(path) {
|
|
|
1588
1618
|
focus: raw.focus ?? null,
|
|
1589
1619
|
memoryGlobal: raw.memoryGlobal ?? null,
|
|
1590
1620
|
memoryPatterns: raw.memoryPatterns ?? {},
|
|
1621
|
+
frictionGlobal: raw.frictionGlobal ?? [],
|
|
1591
1622
|
proposals: raw.proposals ?? [],
|
|
1592
1623
|
lessonSessions: raw.lessonSessions ?? {},
|
|
1593
1624
|
lessonThreads: (() => {
|
|
@@ -1620,6 +1651,7 @@ function loadState(path) {
|
|
|
1620
1651
|
return groups;
|
|
1621
1652
|
})(),
|
|
1622
1653
|
artifacts: raw.artifacts ?? {},
|
|
1654
|
+
trash: raw.trash ?? [],
|
|
1623
1655
|
lastConsolidatedAt: raw.lastConsolidatedAt ?? null,
|
|
1624
1656
|
xp: raw.xp ?? {
|
|
1625
1657
|
total: 0,
|
|
@@ -1635,15 +1667,41 @@ function loadState(path) {
|
|
|
1635
1667
|
};
|
|
1636
1668
|
}
|
|
1637
1669
|
/**
|
|
1638
|
-
* Persist state atomically
|
|
1670
|
+
* Persist state atomically. The tmp file carries pid + random bytes so
|
|
1671
|
+
* processes sharing one state.json (multi-profile installs) never interleave
|
|
1672
|
+
* on the same temp path — a fixed name let one writer rename the other's
|
|
1673
|
+
* half-written bytes into place; the previous generation is kept one deep as
|
|
1674
|
+
* `.bak` for loadState recovery; the rename retries through the Windows
|
|
1675
|
+
* AV/indexer EPERM window (audit A3).
|
|
1639
1676
|
* @param path - state-file path.
|
|
1640
1677
|
* @param state - state to persist.
|
|
1641
1678
|
*/
|
|
1642
1679
|
function saveState(path, state) {
|
|
1643
1680
|
mkdirSync(dirname(path), { recursive: true });
|
|
1644
|
-
const tmp = `${path}.tmp`;
|
|
1681
|
+
const tmp = `${path}.${process.pid}-${randomBytes(4).toString("hex")}.tmp`;
|
|
1645
1682
|
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
1646
|
-
|
|
1683
|
+
try {
|
|
1684
|
+
const fd = openSync(tmp, "r+");
|
|
1685
|
+
try {
|
|
1686
|
+
fsyncSync(fd);
|
|
1687
|
+
} finally {
|
|
1688
|
+
closeSync(fd);
|
|
1689
|
+
}
|
|
1690
|
+
} catch {}
|
|
1691
|
+
if (existsSync(path)) try {
|
|
1692
|
+
copyFileSync(path, `${path}.bak`);
|
|
1693
|
+
} catch {}
|
|
1694
|
+
for (let attempt = 0;; attempt++) try {
|
|
1695
|
+
renameSync(tmp, path);
|
|
1696
|
+
return;
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
const code = error.code;
|
|
1699
|
+
if ((code === "EPERM" || code === "EACCES" || code === "EBUSY") && attempt < 5) {
|
|
1700
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * (attempt + 1));
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
throw error;
|
|
1704
|
+
}
|
|
1647
1705
|
}
|
|
1648
1706
|
/**
|
|
1649
1707
|
* Slugify a course title into an id prefix: lowercase alphanumerics joined by `-`.
|
|
@@ -1693,8 +1751,13 @@ function freshLesson(title, anchor, body, kind = "study") {
|
|
|
1693
1751
|
*/
|
|
1694
1752
|
function importCourse(state, parsed, source, sourceRef, languageTarget) {
|
|
1695
1753
|
const base = slugify(parsed.title);
|
|
1696
|
-
|
|
1697
|
-
|
|
1754
|
+
if (source !== "markdown") {
|
|
1755
|
+
const sameIdentity = state.courses.find((c) => c.source === source && c.sourceRef === sourceRef && c.title === parsed.title);
|
|
1756
|
+
if (sameIdentity !== void 0) return sameIdentity;
|
|
1757
|
+
} else {
|
|
1758
|
+
const sameTitle = state.courses.find((c) => c.source === "markdown" && c.title === parsed.title);
|
|
1759
|
+
if (sameTitle !== void 0) return sameTitle;
|
|
1760
|
+
}
|
|
1698
1761
|
let id = base;
|
|
1699
1762
|
for (let n = 2; state.courses.some((c) => c.id === id); n++) id = `${base}-${String(n)}`;
|
|
1700
1763
|
const course = {
|
|
@@ -1738,16 +1801,88 @@ function importCourse(state, parsed, source, sourceRef, languageTarget) {
|
|
|
1738
1801
|
return course;
|
|
1739
1802
|
}
|
|
1740
1803
|
/**
|
|
1741
|
-
*
|
|
1804
|
+
* Delete one course — into the restorable trash (audit B7), carrying its
|
|
1805
|
+
* proposals, artifacts, thread groups, legacy bindings, pattern memory, and
|
|
1806
|
+
* focus so a restore is total (this also leaves no orphan sediment behind —
|
|
1807
|
+
* audit C17). Unknown ids fail loud.
|
|
1742
1808
|
* @param state - state to mutate.
|
|
1743
1809
|
* @param courseId - course to remove.
|
|
1744
1810
|
*/
|
|
1745
1811
|
function deleteCourse(state, courseId) {
|
|
1746
1812
|
const i = state.courses.findIndex((c) => c.id === courseId);
|
|
1747
1813
|
if (i < 0) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
|
|
1748
|
-
state.courses.splice(i, 1);
|
|
1749
|
-
|
|
1750
|
-
|
|
1814
|
+
const [course] = state.courses.splice(i, 1);
|
|
1815
|
+
const prefix = `${courseId}:`;
|
|
1816
|
+
const lessonThreads = [];
|
|
1817
|
+
for (const key of Object.keys(state.lessonThreads)) if (key.startsWith(prefix)) {
|
|
1818
|
+
lessonThreads.push([key, state.lessonThreads[key]]);
|
|
1819
|
+
delete state.lessonThreads[key];
|
|
1820
|
+
}
|
|
1821
|
+
const lessonSessions = [];
|
|
1822
|
+
for (const key of Object.keys(state.lessonSessions)) if (key.startsWith(prefix)) {
|
|
1823
|
+
lessonSessions.push([key, state.lessonSessions[key]]);
|
|
1824
|
+
delete state.lessonSessions[key];
|
|
1825
|
+
}
|
|
1826
|
+
const artifacts = [];
|
|
1827
|
+
for (const key of Object.keys(state.artifacts)) if (key.startsWith(prefix)) {
|
|
1828
|
+
artifacts.push([key, state.artifacts[key]]);
|
|
1829
|
+
delete state.artifacts[key];
|
|
1830
|
+
}
|
|
1831
|
+
const proposals = state.proposals.filter((p) => p.lessonId.startsWith(prefix));
|
|
1832
|
+
state.proposals = state.proposals.filter((p) => !p.lessonId.startsWith(prefix));
|
|
1833
|
+
const pattern = state.memoryPatterns[courseId];
|
|
1834
|
+
const memoryPattern = pattern !== void 0 ? [courseId, pattern] : null;
|
|
1835
|
+
if (pattern !== void 0) delete state.memoryPatterns[courseId];
|
|
1836
|
+
const focusLessonId = state.focus !== null && state.focus.lessonId.startsWith(prefix) ? state.focus.lessonId : null;
|
|
1837
|
+
if (focusLessonId !== null) state.focus = null;
|
|
1838
|
+
state.trash = [{
|
|
1839
|
+
deletedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1840
|
+
course,
|
|
1841
|
+
proposals,
|
|
1842
|
+
artifacts,
|
|
1843
|
+
lessonThreads,
|
|
1844
|
+
lessonSessions,
|
|
1845
|
+
memoryPattern,
|
|
1846
|
+
focusLessonId
|
|
1847
|
+
}, ...state.trash ?? []].slice(0, 10);
|
|
1848
|
+
}
|
|
1849
|
+
/**
|
|
1850
|
+
* Restore one course from the trash with all its sediment. The slug may have
|
|
1851
|
+
* been re-taken while trashed — the tree is re-id'd and every carried key
|
|
1852
|
+
* remapped in that case. Unknown ids fail loud with the restorable list.
|
|
1853
|
+
* @param state - state to mutate.
|
|
1854
|
+
* @param courseId - trashed course to restore.
|
|
1855
|
+
* @returns the restored course.
|
|
1856
|
+
*/
|
|
1857
|
+
function restoreCourse(state, courseId) {
|
|
1858
|
+
state.trash ??= [];
|
|
1859
|
+
const i = state.trash.findIndex((t) => t.course.id === courseId);
|
|
1860
|
+
if (i < 0) throw new Error(`lookatstudy-plugin: no trashed course ${JSON.stringify(courseId)} (restorable: ${state.trash.map((t) => t.course.id).join(", ") || "none"})`);
|
|
1861
|
+
const entry = state.trash.splice(i, 1)[0];
|
|
1862
|
+
const course = entry.course;
|
|
1863
|
+
const oldId = course.id;
|
|
1864
|
+
let id = oldId;
|
|
1865
|
+
for (let n = 2; state.courses.some((c) => c.id === id); n++) id = `${oldId}-${String(n)}`;
|
|
1866
|
+
if (id !== oldId) {
|
|
1867
|
+
course.id = id;
|
|
1868
|
+
const remap = (key) => key.startsWith(`${oldId}:`) ? `${id}${key.slice(oldId.length)}` : key;
|
|
1869
|
+
course.sections.forEach((section, si) => section.lessons.forEach((lesson, li) => {
|
|
1870
|
+
lesson.id = `${id}:${si}:${li}`;
|
|
1871
|
+
}));
|
|
1872
|
+
for (const p of entry.proposals) p.lessonId = remap(p.lessonId);
|
|
1873
|
+
entry.artifacts = entry.artifacts.map(([k, v]) => [remap(k), v]);
|
|
1874
|
+
entry.lessonThreads = entry.lessonThreads.map(([k, v]) => [remap(k), v]);
|
|
1875
|
+
entry.lessonSessions = entry.lessonSessions.map(([k, v]) => [remap(k), v]);
|
|
1876
|
+
if (entry.focusLessonId !== null) entry.focusLessonId = remap(entry.focusLessonId);
|
|
1877
|
+
}
|
|
1878
|
+
state.courses.push(course);
|
|
1879
|
+
for (const p of entry.proposals) state.proposals.push(p);
|
|
1880
|
+
for (const [key, list] of entry.artifacts) state.artifacts[key] = [...state.artifacts[key] ?? [], ...list];
|
|
1881
|
+
for (const [key, group] of entry.lessonThreads) state.lessonThreads[key] = group;
|
|
1882
|
+
for (const [key, sessionId] of entry.lessonSessions) state.lessonSessions[key] = sessionId;
|
|
1883
|
+
if (entry.memoryPattern !== null) state.memoryPatterns[course.id] = entry.memoryPattern[1];
|
|
1884
|
+
if (entry.focusLessonId !== null && state.focus === null) state.focus = { lessonId: entry.focusLessonId };
|
|
1885
|
+
return course;
|
|
1751
1886
|
}
|
|
1752
1887
|
/**
|
|
1753
1888
|
* Locate a course; unknown ids fail loud.
|
|
@@ -1897,6 +2032,7 @@ function applyProgression(ref, now) {
|
|
|
1897
2032
|
*/
|
|
1898
2033
|
function attemptLesson(state, lessonId, now) {
|
|
1899
2034
|
const ref = findLesson(state, lessonId);
|
|
2035
|
+
if (ref.lesson.kind === "exam") throw new Error(`lookatstudy-plugin: exam node ${JSON.stringify(lessonId)} never enters the study state machine (it stays available; the exam surface is the bank + attempt flow)`);
|
|
1900
2036
|
if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; complete earlier lessons first`);
|
|
1901
2037
|
if (ref.lesson.status !== "available") return {
|
|
1902
2038
|
ref,
|
|
@@ -1969,7 +2105,7 @@ function applyExamBank(state, lessonId, questions, now) {
|
|
|
1969
2105
|
if (typeof q?.prompt !== "string" || q.prompt.trim() === "") throw new Error(`lookatstudy-plugin: question ${String(i)} has an empty prompt`);
|
|
1970
2106
|
if (!Array.isArray(q.options) || q.options.length < 2 || q.options.some((o) => typeof o !== "string" || o.trim() === "")) throw new Error(`lookatstudy-plugin: question ${String(i)} needs 2+ non-empty options`);
|
|
1971
2107
|
if (!Number.isInteger(q.answer) || q.answer < 0 || q.answer >= q.options.length) throw new Error(`lookatstudy-plugin: question ${String(i)} answer index ${String(q.answer)} is out of range`);
|
|
1972
|
-
if (q.kcTitle !== void 0 && q.kcTitle !== null && q.kcTitle !== "" && !kcUnion.has(q.kcTitle)) throw new Error(`lookatstudy-plugin: question ${String(i)} kcTitle ${JSON.stringify(q.kcTitle)} is not in the section's concept union ${JSON.stringify([...kcUnion])} — read the section lessons with
|
|
2108
|
+
if (q.kcTitle !== void 0 && q.kcTitle !== null && q.kcTitle !== "" && !kcUnion.has(q.kcTitle)) throw new Error(`lookatstudy-plugin: question ${String(i)} kcTitle ${JSON.stringify(q.kcTitle)} is not in the section's concept union ${JSON.stringify([...kcUnion])} — read the section lessons with study_lesson before authoring`);
|
|
1973
2109
|
return {
|
|
1974
2110
|
id: `q${String(i)}`,
|
|
1975
2111
|
prompt: q.prompt,
|
|
@@ -1989,10 +2125,12 @@ function applyExamBank(state, lessonId, questions, now) {
|
|
|
1989
2125
|
kcCount: new Set(bank.map((q) => q.kcTitle).filter((k) => k !== null)).size
|
|
1990
2126
|
};
|
|
1991
2127
|
}
|
|
1992
|
-
/** Drop the bank back to idle (regenerate;
|
|
2128
|
+
/** Drop the bank back to idle (regenerate; open attempts settle against their
|
|
2129
|
+
* snapshots first — audit C16: never cross-grade a live attempt). */
|
|
1993
2130
|
function regenerateExamBank(state, lessonId) {
|
|
1994
2131
|
const ref = findLesson(state, lessonId);
|
|
1995
2132
|
if (ref.lesson.kind !== "exam") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is not an exam node`);
|
|
2133
|
+
settleDanglingAttempts(state, lessonId);
|
|
1996
2134
|
ref.lesson.examBank = {
|
|
1997
2135
|
status: "idle",
|
|
1998
2136
|
questions: []
|
|
@@ -2001,7 +2139,12 @@ function regenerateExamBank(state, lessonId) {
|
|
|
2001
2139
|
/** Settle every dangling attempt of the lesson (grade dead — unanswered = wrong). */
|
|
2002
2140
|
function settleDanglingAttempts(state, lessonId) {
|
|
2003
2141
|
const ref = findLesson(state, lessonId);
|
|
2004
|
-
|
|
2142
|
+
let changed = false;
|
|
2143
|
+
for (const attempt of ref.lesson.examAttemptLog ?? []) if (attempt.finishedAt === null) {
|
|
2144
|
+
submitExamAttempt(state, lessonId, attempt.id, true, /* @__PURE__ */ new Date());
|
|
2145
|
+
changed = true;
|
|
2146
|
+
}
|
|
2147
|
+
return changed;
|
|
2005
2148
|
}
|
|
2006
2149
|
/** Open a new attempt against the ready bank (dangling ones are graded dead first). */
|
|
2007
2150
|
function startExamAttempt(state, lessonId, now) {
|
|
@@ -2021,9 +2164,14 @@ function startExamAttempt(state, lessonId, now) {
|
|
|
2021
2164
|
totalCount: null,
|
|
2022
2165
|
stars: null,
|
|
2023
2166
|
terminated: false,
|
|
2024
|
-
perQuestion: null
|
|
2167
|
+
perQuestion: null,
|
|
2168
|
+
snapshot: ref.lesson.examBank.questions.map((q) => ({
|
|
2169
|
+
...q,
|
|
2170
|
+
options: [...q.options]
|
|
2171
|
+
}))
|
|
2025
2172
|
};
|
|
2026
|
-
|
|
2173
|
+
const kept = [...log, attempt];
|
|
2174
|
+
ref.lesson.examAttemptLog = kept.length > 20 ? kept.slice(kept.length - 20) : kept;
|
|
2027
2175
|
return {
|
|
2028
2176
|
attemptId: attempt.id,
|
|
2029
2177
|
questions: ref.lesson.examBank.questions
|
|
@@ -2035,7 +2183,7 @@ function recordExamAnswer(state, lessonId, attemptId, questionId, answer) {
|
|
|
2035
2183
|
const attempt = ref.lesson.examAttemptLog?.find((a) => a.id === attemptId);
|
|
2036
2184
|
if (attempt === void 0) throw new Error(`lookatstudy-plugin: exam attempt ${JSON.stringify(attemptId)} not found on ${JSON.stringify(lessonId)}`);
|
|
2037
2185
|
if (attempt.finishedAt !== null) throw new Error(`lookatstudy-plugin: exam attempt ${JSON.stringify(attemptId)} already finished`);
|
|
2038
|
-
const question = ref.lesson.examBank?.questions.find((q) => q.id === questionId);
|
|
2186
|
+
const question = (attempt.snapshot ?? (ref.lesson.examBank?.status === "ready" ? ref.lesson.examBank.questions : [])).find((q) => q.id === questionId);
|
|
2039
2187
|
if (question === void 0) throw new Error(`lookatstudy-plugin: exam question ${JSON.stringify(questionId)} not in the bank`);
|
|
2040
2188
|
if (answer !== "" && (!/^\d+$/.test(answer) || Number(answer) >= question.options.length)) throw new Error(`lookatstudy-plugin: exam answer ${JSON.stringify(answer)} is not an option index of ${JSON.stringify(questionId)}`);
|
|
2041
2189
|
attempt.answers = {
|
|
@@ -2049,8 +2197,7 @@ function submitExamAttempt(state, lessonId, attemptId, terminated, now) {
|
|
|
2049
2197
|
const attempt = ref.lesson.examAttemptLog?.find((a) => a.id === attemptId);
|
|
2050
2198
|
if (attempt === void 0) throw new Error(`lookatstudy-plugin: exam attempt ${JSON.stringify(attemptId)} not found on ${JSON.stringify(lessonId)}`);
|
|
2051
2199
|
if (attempt.finishedAt !== null) throw new Error(`lookatstudy-plugin: exam attempt ${JSON.stringify(attemptId)} already finished`);
|
|
2052
|
-
const
|
|
2053
|
-
const perQuestion = (bank?.status === "ready" ? bank.questions : []).map((q) => {
|
|
2200
|
+
const perQuestion = (attempt.snapshot ?? (ref.lesson.examBank?.status === "ready" ? ref.lesson.examBank.questions : [])).map((q) => {
|
|
2054
2201
|
const user = attempt.answers[q.id] ?? "";
|
|
2055
2202
|
return {
|
|
2056
2203
|
exerciseId: q.id,
|
|
@@ -2100,6 +2247,7 @@ function submitExamAttempt(state, lessonId, attemptId, terminated, now) {
|
|
|
2100
2247
|
*/
|
|
2101
2248
|
function recordAnswer(state, lessonId, correct, concept, now) {
|
|
2102
2249
|
const ref = findLesson(state, lessonId);
|
|
2250
|
+
if (ref.lesson.kind === "exam") throw new Error(`lookatstudy-plugin: exam node ${JSON.stringify(lessonId)} takes exam attempts (start/submit), not study_record_answer`);
|
|
2103
2251
|
if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; open it with study_lesson first`);
|
|
2104
2252
|
if (ref.lesson.status === "available") ref.lesson.status = "in_progress";
|
|
2105
2253
|
const kcIndex = concept === void 0 ? void 0 : ref.lesson.concepts?.findIndex((c) => c.title === concept);
|
|
@@ -2164,6 +2312,7 @@ function recordAnswer(state, lessonId, correct, concept, now) {
|
|
|
2164
2312
|
* with the active pointer so pre-0.22 readers never disagree.
|
|
2165
2313
|
*/
|
|
2166
2314
|
function bindLessonThread(state, lessonId, sessionId, title) {
|
|
2315
|
+
findLesson(state, lessonId);
|
|
2167
2316
|
state.lessonThreads ??= {};
|
|
2168
2317
|
const group = state.lessonThreads[lessonId] ?? {
|
|
2169
2318
|
active: null,
|
|
@@ -2287,7 +2436,10 @@ function addFriction(state, lessonId, category, summary, now) {
|
|
|
2287
2436
|
summary,
|
|
2288
2437
|
at: now.toISOString()
|
|
2289
2438
|
};
|
|
2290
|
-
if (lessonId === null)
|
|
2439
|
+
if (lessonId === null) {
|
|
2440
|
+
state.frictionGlobal = [...state.frictionGlobal ?? [], entry].slice(-50);
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2291
2443
|
const ref = findLesson(state, lessonId);
|
|
2292
2444
|
ref.lesson.friction.push(entry);
|
|
2293
2445
|
if (ref.lesson.friction.length > FRICTION_CAP) ref.lesson.friction.splice(0, ref.lesson.friction.length - FRICTION_CAP);
|
|
@@ -2308,7 +2460,8 @@ function setMemory(state, category, content, lessonId) {
|
|
|
2308
2460
|
return prev;
|
|
2309
2461
|
}
|
|
2310
2462
|
if (category === "pattern") {
|
|
2311
|
-
const
|
|
2463
|
+
const cut = lessonId === void 0 ? -1 : lessonId.indexOf(":");
|
|
2464
|
+
const course = findCourse(state, cut === -1 ? lessonId ?? "" : lessonId.slice(0, cut));
|
|
2312
2465
|
const prev = state.memoryPatterns[course.id] ?? null;
|
|
2313
2466
|
state.memoryPatterns[course.id] = content;
|
|
2314
2467
|
return prev;
|
|
@@ -2371,7 +2524,7 @@ function recordArtifact(state, lessonId, artifact) {
|
|
|
2371
2524
|
created: false
|
|
2372
2525
|
};
|
|
2373
2526
|
const next = [...existing, artifact];
|
|
2374
|
-
state.artifacts[lessonId] = next;
|
|
2527
|
+
state.artifacts[lessonId] = next.length > 100 ? next.slice(next.length - 100) : next;
|
|
2375
2528
|
return {
|
|
2376
2529
|
artifact,
|
|
2377
2530
|
created: true
|
|
@@ -2452,6 +2605,11 @@ function resolveProposal(state, proposalId, accept, now) {
|
|
|
2452
2605
|
}
|
|
2453
2606
|
}
|
|
2454
2607
|
proposal.status = accept ? "applied" : "rejected";
|
|
2608
|
+
const resolved = state.proposals.filter((p) => p.status !== "pending");
|
|
2609
|
+
if (resolved.length > 20) {
|
|
2610
|
+
const keep = new Set(resolved.slice(resolved.length - 20).map((p) => p.id));
|
|
2611
|
+
state.proposals = state.proposals.filter((p) => p.status === "pending" || keep.has(p.id));
|
|
2612
|
+
}
|
|
2455
2613
|
return proposal;
|
|
2456
2614
|
}
|
|
2457
2615
|
/**
|
|
@@ -2640,6 +2798,18 @@ function gatherConsolidationWindow(state, capPerLesson = 10) {
|
|
|
2640
2798
|
const entries = [];
|
|
2641
2799
|
let frictionCount = 0;
|
|
2642
2800
|
let practiceCount = 0;
|
|
2801
|
+
for (const f of state.frictionGlobal ?? []) {
|
|
2802
|
+
if (since !== null && f.at <= since) continue;
|
|
2803
|
+
entries.push({
|
|
2804
|
+
lessonId: "",
|
|
2805
|
+
lessonTitle: "(课级)",
|
|
2806
|
+
kind: "friction",
|
|
2807
|
+
category: f.category,
|
|
2808
|
+
text: f.summary ?? "(no summary)",
|
|
2809
|
+
at: f.at
|
|
2810
|
+
});
|
|
2811
|
+
frictionCount++;
|
|
2812
|
+
}
|
|
2643
2813
|
for (const course of state.courses) for (const section of course.sections) for (const lesson of section.lessons) {
|
|
2644
2814
|
let perLesson = 0;
|
|
2645
2815
|
const collect = (kind, at, push) => {
|
|
@@ -2918,6 +3088,49 @@ function workbenchState(state, now) {
|
|
|
2918
3088
|
};
|
|
2919
3089
|
}
|
|
2920
3090
|
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
|
3091
|
+
/** Local hostnames the panel is ever legitimately served from (audit A2). */
|
|
3092
|
+
const LOCAL_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
3093
|
+
"localhost",
|
|
3094
|
+
"127.0.0.1",
|
|
3095
|
+
"[::1]",
|
|
3096
|
+
"::1"
|
|
3097
|
+
]);
|
|
3098
|
+
function headerValue(req, name) {
|
|
3099
|
+
const raw = req.headers?.[name];
|
|
3100
|
+
const value = Array.isArray(raw) ? raw[0] ?? "" : raw;
|
|
3101
|
+
return typeof value === "string" ? value : "";
|
|
3102
|
+
}
|
|
3103
|
+
/** Hostname of the Host header with the port stripped (IPv6 brackets kept). */
|
|
3104
|
+
function hostNameOf(req) {
|
|
3105
|
+
const host = headerValue(req, "host").toLowerCase().trim();
|
|
3106
|
+
if (host.startsWith("[")) return host.slice(0, host.indexOf("]") + 1);
|
|
3107
|
+
return host.split(":")[0] ?? "";
|
|
3108
|
+
}
|
|
3109
|
+
function originIsLocal(origin) {
|
|
3110
|
+
try {
|
|
3111
|
+
return LOCAL_HOSTNAMES.has(new URL(origin).hostname.toLowerCase());
|
|
3112
|
+
} catch {
|
|
3113
|
+
return false;
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
/**
|
|
3117
|
+
* Audit A2: the host does not token-guard plugin routes, so the dashboard
|
|
3118
|
+
* gates itself. Every request must name a local Host header (kills DNS
|
|
3119
|
+
* rebinding reads); mutating POSTs must additionally carry the plugin marker
|
|
3120
|
+
* header `x-lks-request` — a drive-by page can still smuggle a no-preflight
|
|
3121
|
+
* text/plain POST at us, but it cannot add that header. GETs stay
|
|
3122
|
+
* marker-free because `<img>` tags (attachment thumbnails) cannot send
|
|
3123
|
+
* custom headers; the Host check still applies to them.
|
|
3124
|
+
*/
|
|
3125
|
+
function requestPassesGate(req) {
|
|
3126
|
+
if (!LOCAL_HOSTNAMES.has(hostNameOf(req))) return false;
|
|
3127
|
+
if (req.method === "POST") {
|
|
3128
|
+
if (headerValue(req, "x-lks-request") !== "1") return false;
|
|
3129
|
+
const origin = headerValue(req, "origin").trim();
|
|
3130
|
+
if (origin !== "" && !originIsLocal(origin)) return false;
|
|
3131
|
+
}
|
|
3132
|
+
return true;
|
|
3133
|
+
}
|
|
2921
3134
|
function sendJson(res, status, value) {
|
|
2922
3135
|
res.writeHead(status, JSON_HEADERS).end(JSON.stringify(value));
|
|
2923
3136
|
}
|
|
@@ -2930,7 +3143,9 @@ function sendJson(res, status, value) {
|
|
|
2930
3143
|
*/
|
|
2931
3144
|
async function readJsonBodySafe(req, res, maxBytes = 65536) {
|
|
2932
3145
|
try {
|
|
2933
|
-
|
|
3146
|
+
const value = await readJsonBody(req, maxBytes);
|
|
3147
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("request body must be a JSON object");
|
|
3148
|
+
return value;
|
|
2934
3149
|
} catch (error) {
|
|
2935
3150
|
sendJson(res, 400, {
|
|
2936
3151
|
ok: false,
|
|
@@ -2943,20 +3158,30 @@ async function readJsonBodySafe(req, res, maxBytes = 65536) {
|
|
|
2943
3158
|
function readJsonBody(req, maxBytes = 65536) {
|
|
2944
3159
|
return new Promise((resolve, reject) => {
|
|
2945
3160
|
const chunks = [];
|
|
3161
|
+
let received = 0;
|
|
3162
|
+
let settled = false;
|
|
3163
|
+
const done = (err, value) => {
|
|
3164
|
+
if (settled) return;
|
|
3165
|
+
settled = true;
|
|
3166
|
+
if (err !== null) reject(err);
|
|
3167
|
+
else resolve(value);
|
|
3168
|
+
};
|
|
2946
3169
|
req.on("data", (chunk) => {
|
|
3170
|
+
if (settled) return;
|
|
2947
3171
|
chunks.push(chunk);
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
return;
|
|
2951
|
-
}
|
|
3172
|
+
received += chunk.length;
|
|
3173
|
+
if (received > maxBytes) done(/* @__PURE__ */ new Error("request body too large"));
|
|
2952
3174
|
});
|
|
2953
3175
|
req.on("end", () => {
|
|
3176
|
+
if (settled) return;
|
|
2954
3177
|
try {
|
|
2955
|
-
|
|
3178
|
+
done(null, JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
2956
3179
|
} catch {
|
|
2957
|
-
|
|
3180
|
+
done(/* @__PURE__ */ new Error("request body is not valid JSON"));
|
|
2958
3181
|
}
|
|
2959
3182
|
});
|
|
3183
|
+
req.on("error", () => done(/* @__PURE__ */ new Error("request aborted")));
|
|
3184
|
+
req.on("aborted", () => done(/* @__PURE__ */ new Error("request aborted")));
|
|
2960
3185
|
});
|
|
2961
3186
|
}
|
|
2962
3187
|
/**
|
|
@@ -2981,14 +3206,30 @@ function registerDashboard(webServer, deps) {
|
|
|
2981
3206
|
kind: "prefix",
|
|
2982
3207
|
path: "/lookatstudy",
|
|
2983
3208
|
handler: async (req, res) => {
|
|
3209
|
+
if (!requestPassesGate(req)) {
|
|
3210
|
+
sendJson(res, 403, {
|
|
3211
|
+
ok: false,
|
|
3212
|
+
error: "forbidden"
|
|
3213
|
+
});
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
2984
3216
|
const pathname = new URL(req.url ?? "/", "http://x").pathname;
|
|
2985
3217
|
if (req.method === "GET" && pathname === "/lookatstudy/api/state") {
|
|
2986
|
-
|
|
3218
|
+
const payload = JSON.stringify({
|
|
2987
3219
|
...workbenchState(deps.store.get(), /* @__PURE__ */ new Date()),
|
|
2988
3220
|
statePath: deps.statePath,
|
|
2989
3221
|
version: pluginVersion(),
|
|
2990
3222
|
model: await modelInfo()
|
|
2991
3223
|
});
|
|
3224
|
+
const etag = `"${createHash("sha1").update(payload).digest("hex")}"`;
|
|
3225
|
+
if (headerValue(req, "if-none-match") === etag) {
|
|
3226
|
+
res.writeHead(304, { etag }).end();
|
|
3227
|
+
return;
|
|
3228
|
+
}
|
|
3229
|
+
res.writeHead(200, {
|
|
3230
|
+
...JSON_HEADERS,
|
|
3231
|
+
etag
|
|
3232
|
+
}).end(payload);
|
|
2992
3233
|
return;
|
|
2993
3234
|
}
|
|
2994
3235
|
if (req.method === "GET" && pathname === "/lookatstudy/api/search") {
|
|
@@ -3073,6 +3314,32 @@ function registerDashboard(webServer, deps) {
|
|
|
3073
3314
|
}
|
|
3074
3315
|
return;
|
|
3075
3316
|
}
|
|
3317
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/course/restore") {
|
|
3318
|
+
const body = await readJsonBodySafe(req, res);
|
|
3319
|
+
if (body === void 0) return;
|
|
3320
|
+
if (typeof body.courseId !== "string") {
|
|
3321
|
+
sendJson(res, 400, {
|
|
3322
|
+
ok: false,
|
|
3323
|
+
error: "courseId (string) required"
|
|
3324
|
+
});
|
|
3325
|
+
return;
|
|
3326
|
+
}
|
|
3327
|
+
try {
|
|
3328
|
+
const course = restoreCourse(deps.store.get(), body.courseId);
|
|
3329
|
+
deps.store.save();
|
|
3330
|
+
sendJson(res, 200, {
|
|
3331
|
+
ok: true,
|
|
3332
|
+
courseId: course.id,
|
|
3333
|
+
title: course.title
|
|
3334
|
+
});
|
|
3335
|
+
} catch (error) {
|
|
3336
|
+
sendJson(res, 404, {
|
|
3337
|
+
ok: false,
|
|
3338
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3339
|
+
});
|
|
3340
|
+
}
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3076
3343
|
if (req.method === "POST" && pathname === "/lookatstudy/api/note/delete") {
|
|
3077
3344
|
const body = await readJsonBodySafe(req, res);
|
|
3078
3345
|
if (body === void 0) return;
|
|
@@ -3203,8 +3470,7 @@ function registerDashboard(webServer, deps) {
|
|
|
3203
3470
|
return;
|
|
3204
3471
|
}
|
|
3205
3472
|
try {
|
|
3206
|
-
settleDanglingAttempts(deps.store.get(), lessonId);
|
|
3207
|
-
deps.store.save();
|
|
3473
|
+
if (settleDanglingAttempts(deps.store.get(), lessonId)) deps.store.save();
|
|
3208
3474
|
const ref = findLesson(deps.store.get(), lessonId);
|
|
3209
3475
|
if (ref.lesson.kind !== "exam") throw new Error("not an exam node");
|
|
3210
3476
|
const bank = ref.lesson.examBank;
|
|
@@ -3372,7 +3638,15 @@ function registerDashboard(webServer, deps) {
|
|
|
3372
3638
|
return;
|
|
3373
3639
|
}
|
|
3374
3640
|
const title = typeof body.title === "string" ? body.title : null;
|
|
3375
|
-
|
|
3641
|
+
try {
|
|
3642
|
+
bindLessonThread(deps.store.get(), body.lessonId, body.sessionId, title);
|
|
3643
|
+
} catch (error) {
|
|
3644
|
+
sendJson(res, 404, {
|
|
3645
|
+
ok: false,
|
|
3646
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3647
|
+
});
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3376
3650
|
deps.store.save();
|
|
3377
3651
|
sendJson(res, 200, { ok: true });
|
|
3378
3652
|
return;
|
|
@@ -3912,7 +4186,7 @@ async function scanFolder(rootDir, onProgress, options) {
|
|
|
3912
4186
|
for (const doc of dedupedDocs) {
|
|
3913
4187
|
if (doc.kind !== "pptx") continue;
|
|
3914
4188
|
try {
|
|
3915
|
-
const { parsePptx } = await import("./pptx-parser-
|
|
4189
|
+
const { parsePptx } = await import("./pptx-parser-BooxpGV4.mjs");
|
|
3916
4190
|
const result = await parsePptx(await readFile(join(rootDir, doc.path)));
|
|
3917
4191
|
for (const img of result.images ?? []) pptxImages.push({
|
|
3918
4192
|
path: `${doc.path}#slide${img.slideNumber}.png`,
|
|
@@ -4101,19 +4375,20 @@ async function readFileWithKind(absPath, kind, parsePdf) {
|
|
|
4101
4375
|
const { parsePdfText } = await Promise.resolve().then(() => pdf_text_exports);
|
|
4102
4376
|
return parsePdfText(buf);
|
|
4103
4377
|
}
|
|
4378
|
+
if (statSync(absPath).size > 67108864) throw new Error(`文件超过 64MiB 上限 (audit B11): ${absPath}`);
|
|
4104
4379
|
if (kind === "pptx") {
|
|
4105
4380
|
const buf = await readFile(absPath);
|
|
4106
|
-
const { parsePptx } = await import("./pptx-parser-
|
|
4381
|
+
const { parsePptx } = await import("./pptx-parser-BooxpGV4.mjs");
|
|
4107
4382
|
return (await parsePptx(buf)).markdown;
|
|
4108
4383
|
}
|
|
4109
4384
|
if (kind === "docx") {
|
|
4110
4385
|
const buf = await readFile(absPath);
|
|
4111
|
-
const { parseDocx } = await import("./docx-parser-
|
|
4386
|
+
const { parseDocx } = await import("./docx-parser-BB_sCd8F.mjs");
|
|
4112
4387
|
return parseDocx(buf).replace(/^(#{1,5}) /gm, (m) => `#${m}`);
|
|
4113
4388
|
}
|
|
4114
4389
|
if (kind === "epub") {
|
|
4115
4390
|
const buf = await readFile(absPath);
|
|
4116
|
-
const { parseEpubFlat } = await import("./epub-parser-
|
|
4391
|
+
const { parseEpubFlat } = await import("./epub-parser-B7JEfgSO.mjs");
|
|
4117
4392
|
return parseEpubFlat(buf);
|
|
4118
4393
|
}
|
|
4119
4394
|
if (kind === "ipynb") {
|
|
@@ -4281,7 +4556,16 @@ function findStandaloneImages(images, docs) {
|
|
|
4281
4556
|
//#region src/vendor/repo-fetcher.ts
|
|
4282
4557
|
/** CDN URL 构造 */
|
|
4283
4558
|
function cdnUrl(owner, repo, branch, path) {
|
|
4284
|
-
|
|
4559
|
+
const segments = [];
|
|
4560
|
+
for (const seg of path.split("/")) {
|
|
4561
|
+
if (seg === "" || seg === ".") continue;
|
|
4562
|
+
if (seg === "..") {
|
|
4563
|
+
segments.pop();
|
|
4564
|
+
continue;
|
|
4565
|
+
}
|
|
4566
|
+
segments.push(seg);
|
|
4567
|
+
}
|
|
4568
|
+
return `https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${segments.join("/")}`;
|
|
4285
4569
|
}
|
|
4286
4570
|
/** 代码文件扩展名(代码即教学内容) */
|
|
4287
4571
|
const CODE_EXTENSIONS = [
|
|
@@ -4541,7 +4825,7 @@ async function fetchRepoFileTree(owner, repo, branch, _fetchFn, signal) {
|
|
|
4541
4825
|
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
|
|
4542
4826
|
try {
|
|
4543
4827
|
const r = await httpsGet(apiUrl, {
|
|
4544
|
-
rejectUnauthorized:
|
|
4828
|
+
rejectUnauthorized: process.env.LKS_INSECURE_TLS !== "1",
|
|
4545
4829
|
deadlineMs: 24e4,
|
|
4546
4830
|
signal
|
|
4547
4831
|
});
|
|
@@ -4558,7 +4842,7 @@ async function fetchRepoFileTree(owner, repo, branch, _fetchFn, signal) {
|
|
|
4558
4842
|
}
|
|
4559
4843
|
try {
|
|
4560
4844
|
const r2 = await httpsGet(`https://data.jsdelivr.com/v1/packages/gh/${owner}/${repo}@${branch}?structure=flat`, {
|
|
4561
|
-
rejectUnauthorized:
|
|
4845
|
+
rejectUnauthorized: process.env.LKS_INSECURE_TLS !== "1",
|
|
4562
4846
|
signal
|
|
4563
4847
|
});
|
|
4564
4848
|
if (r2.ok && r2.body) {
|
|
@@ -4940,6 +5224,117 @@ function normalizeUrlIdentity(url) {
|
|
|
4940
5224
|
}
|
|
4941
5225
|
}
|
|
4942
5226
|
//#endregion
|
|
5227
|
+
//#region src/vendor/net-guard.ts
|
|
5228
|
+
/**
|
|
5229
|
+
* Audit B6 (2026-09-14): the article/subtitle fetch paths take tutor- and
|
|
5230
|
+
* user-supplied URLs, and the host process runs with the user's full network
|
|
5231
|
+
* reach — an unguarded GET is an SSRF primitive against the local (formerly
|
|
5232
|
+
* unauthenticated) dashboard, cloud metadata, and LAN devices. Three guards:
|
|
5233
|
+
* 1. literal private/loopback addresses are refused (DNS names are NOT
|
|
5234
|
+
* resolved — documented boundary: a public DNS name pointing inward
|
|
5235
|
+
* still passes);
|
|
5236
|
+
* 2. redirects are followed MANUALLY with a per-hop re-check (a public URL
|
|
5237
|
+
* 302-ing to 169.254.169.254 must not be followed blindly);
|
|
5238
|
+
* 3. response bodies are stream-read under a hard byte cap before they
|
|
5239
|
+
* enter memory as text.
|
|
5240
|
+
* @module dsh-plugin-lookatstudy/net-guard
|
|
5241
|
+
*/
|
|
5242
|
+
/** Private/loopback IPv4 literal? */
|
|
5243
|
+
function isPrivateIPv4(host) {
|
|
5244
|
+
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
5245
|
+
if (m === null) return false;
|
|
5246
|
+
const octets = m.slice(1).map(Number);
|
|
5247
|
+
if (octets.some((o) => o > 255)) return false;
|
|
5248
|
+
const [a, b] = octets;
|
|
5249
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
5250
|
+
if (a === 169 && b === 254) return true;
|
|
5251
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
5252
|
+
if (a === 192 && b === 168) return true;
|
|
5253
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
5254
|
+
return false;
|
|
5255
|
+
}
|
|
5256
|
+
/** Private/loopback hostname? Literal IPs and localhost only (no DNS resolution). */
|
|
5257
|
+
function isPrivateHostname(hostname) {
|
|
5258
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
5259
|
+
if (host === "" || host === "localhost" || host.endsWith(".localhost")) return true;
|
|
5260
|
+
if (host.includes(":")) {
|
|
5261
|
+
if (host === "::1" || host === "::") return true;
|
|
5262
|
+
const first = host.split(":")[0] ?? "";
|
|
5263
|
+
if (/^fe[89ab]/.test(first)) return true;
|
|
5264
|
+
if (/^f[cd]/.test(first)) return true;
|
|
5265
|
+
const v4mapped = host.match(/(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
5266
|
+
if (v4mapped !== null) return isPrivateIPv4(v4mapped[1]);
|
|
5267
|
+
return false;
|
|
5268
|
+
}
|
|
5269
|
+
return isPrivateIPv4(host);
|
|
5270
|
+
}
|
|
5271
|
+
/** Parse and refuse non-public fetch targets; returns the parsed URL. */
|
|
5272
|
+
function assertPublicHttpUrl(url) {
|
|
5273
|
+
let parsed;
|
|
5274
|
+
try {
|
|
5275
|
+
parsed = new URL(url);
|
|
5276
|
+
} catch {
|
|
5277
|
+
throw new Error(`lookatstudy-plugin: not a valid URL: ${JSON.stringify(url)}`);
|
|
5278
|
+
}
|
|
5279
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`lookatstudy-plugin: refusing non-http(s) fetch target ${JSON.stringify(url)}`);
|
|
5280
|
+
if (isPrivateHostname(parsed.hostname)) throw new Error(`lookatstudy-plugin: refusing to fetch a private/loopback address (SSRF guard): ${parsed.hostname}`);
|
|
5281
|
+
return parsed;
|
|
5282
|
+
}
|
|
5283
|
+
/**
|
|
5284
|
+
* Fetch one URL as text under the guards: manual redirects re-checked per
|
|
5285
|
+
* hop, declared content-length pre-check, stream-read truncated at the cap.
|
|
5286
|
+
* Non-2xx (after the redirect hops) throws, matching the article path's
|
|
5287
|
+
* honest-failure contract.
|
|
5288
|
+
*/
|
|
5289
|
+
async function guardedFetchText(url, deps = {}) {
|
|
5290
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
5291
|
+
const maxBytes = deps.maxBytes ?? 2097152;
|
|
5292
|
+
const maxRedirects = deps.maxRedirects ?? 5;
|
|
5293
|
+
let current = assertPublicHttpUrl(url).toString();
|
|
5294
|
+
for (let hop = 0;; hop++) {
|
|
5295
|
+
const resp = await doFetch(current, {
|
|
5296
|
+
headers: deps.headers,
|
|
5297
|
+
redirect: "manual",
|
|
5298
|
+
signal: deps.signal
|
|
5299
|
+
});
|
|
5300
|
+
if (resp.status === 301 || resp.status === 302 || resp.status === 303 || resp.status === 307 || resp.status === 308) {
|
|
5301
|
+
const location = resp.headers.get("location");
|
|
5302
|
+
if (location === null) throw new Error(`lookatstudy-plugin: redirect without a Location header (HTTP ${resp.status})`);
|
|
5303
|
+
if (hop >= maxRedirects) throw new Error("lookatstudy-plugin: too many redirects");
|
|
5304
|
+
current = assertPublicHttpUrl(new URL(location, current).toString()).toString();
|
|
5305
|
+
continue;
|
|
5306
|
+
}
|
|
5307
|
+
if (!resp.ok) throw new Error(`lookatstudy-plugin: page fetch failed (HTTP ${resp.status}): ${current}`);
|
|
5308
|
+
const declaredRaw = resp.headers.get("content-length");
|
|
5309
|
+
if (declaredRaw !== null && /^\d+$/.test(declaredRaw) && Number(declaredRaw) > maxBytes) throw new Error(`lookatstudy-plugin: response declares ${declaredRaw} bytes, over the ${maxBytes} byte cap: ${current}`);
|
|
5310
|
+
const reader = resp.body?.getReader();
|
|
5311
|
+
if (reader === void 0) return await resp.text();
|
|
5312
|
+
const chunks = [];
|
|
5313
|
+
let total = 0;
|
|
5314
|
+
let capped = false;
|
|
5315
|
+
for (;;) {
|
|
5316
|
+
const { done, value } = await reader.read();
|
|
5317
|
+
if (done) break;
|
|
5318
|
+
chunks.push(value);
|
|
5319
|
+
total += value.length;
|
|
5320
|
+
if (total > maxBytes) {
|
|
5321
|
+
capped = true;
|
|
5322
|
+
break;
|
|
5323
|
+
}
|
|
5324
|
+
}
|
|
5325
|
+
if (capped) await reader.cancel().catch(() => {});
|
|
5326
|
+
const bytes = new Uint8Array(Math.min(total, maxBytes));
|
|
5327
|
+
let off = 0;
|
|
5328
|
+
for (const chunk of chunks) {
|
|
5329
|
+
if (off >= bytes.length) break;
|
|
5330
|
+
const take = Math.min(chunk.length, bytes.length - off);
|
|
5331
|
+
bytes.set(chunk.subarray(0, take), off);
|
|
5332
|
+
off += take;
|
|
5333
|
+
}
|
|
5334
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
5335
|
+
}
|
|
5336
|
+
}
|
|
5337
|
+
//#endregion
|
|
4943
5338
|
//#region src/vendor/pdf-text.ts
|
|
4944
5339
|
var pdf_text_exports = /* @__PURE__ */ __exportAll({
|
|
4945
5340
|
normalizeRadicals: () => normalizeRadicals,
|
|
@@ -5505,10 +5900,18 @@ async function fetchBilibiliSubtitles(url, fetchFn, signal, opts) {
|
|
|
5505
5900
|
missing.push(t.name);
|
|
5506
5901
|
continue;
|
|
5507
5902
|
}
|
|
5508
|
-
const
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5903
|
+
const subUrl = lane.url.startsWith("//") ? `https:${lane.url}` : lane.url;
|
|
5904
|
+
let raw = "";
|
|
5905
|
+
try {
|
|
5906
|
+
raw = await guardedFetchText(subUrl, {
|
|
5907
|
+
fetchImpl: fetchFn,
|
|
5908
|
+
signal,
|
|
5909
|
+
headers: BILI_HEADERS
|
|
5910
|
+
});
|
|
5911
|
+
} catch {
|
|
5912
|
+
missing.push(t.name);
|
|
5913
|
+
continue;
|
|
5914
|
+
}
|
|
5512
5915
|
let text = "";
|
|
5513
5916
|
try {
|
|
5514
5917
|
text = (JSON.parse(raw).body ?? []).map((b) => (b.content ?? "").trim()).filter(Boolean).join("");
|
|
@@ -5826,6 +6229,8 @@ function extractHeadings(content) {
|
|
|
5826
6229
|
function findTitleIndex(headings, anchor) {
|
|
5827
6230
|
const anchorClean = anchor.replace(/^#{1,3}\s+/, "").toLowerCase().trim();
|
|
5828
6231
|
if (anchorClean === "") return -1;
|
|
6232
|
+
const exact = headings.filter((h) => h.title.toLowerCase().trim() === anchorClean);
|
|
6233
|
+
if (exact.length === 1) return headings.indexOf(exact[0]);
|
|
5829
6234
|
for (let i = 0; i < headings.length; i++) {
|
|
5830
6235
|
const titleLower = headings[i].title.toLowerCase();
|
|
5831
6236
|
if (titleLower.includes(anchorClean) || anchorClean.includes(titleLower)) return i;
|
|
@@ -6100,8 +6505,15 @@ function sanitizeQuiz(raw) {
|
|
|
6100
6505
|
list.slice(0, MAX_QUESTIONS).forEach((q, index) => {
|
|
6101
6506
|
const item = q ?? {};
|
|
6102
6507
|
const prompt = typeof item.prompt === "string" ? item.prompt.trim() : "";
|
|
6103
|
-
const
|
|
6104
|
-
const
|
|
6508
|
+
const kept = [];
|
|
6509
|
+
const indexMap = /* @__PURE__ */ new Map();
|
|
6510
|
+
for (const [i, o] of (Array.isArray(item.options) ? item.options : []).entries()) if (typeof o === "string" && o.trim() !== "") {
|
|
6511
|
+
indexMap.set(i, kept.length);
|
|
6512
|
+
kept.push(o.trim());
|
|
6513
|
+
}
|
|
6514
|
+
const options = kept;
|
|
6515
|
+
const rawAnswer = typeof item.answer === "number" && Number.isInteger(item.answer) ? item.answer : -1;
|
|
6516
|
+
const answer = indexMap.get(rawAnswer) ?? -1;
|
|
6105
6517
|
const explanation = typeof item.explanation === "string" ? item.explanation.trim() : "";
|
|
6106
6518
|
if (prompt === "") {
|
|
6107
6519
|
warnings.push(`question ${index + 1}: empty prompt dropped`);
|
|
@@ -6442,6 +6854,12 @@ function signalFetch(signal, baseFetch) {
|
|
|
6442
6854
|
signal
|
|
6443
6855
|
});
|
|
6444
6856
|
}
|
|
6857
|
+
/** Audit B7: imported bodies are untrusted input landing in the tutor's
|
|
6858
|
+
* context — the in-prompt lesson body and the in-chat course pack are capped
|
|
6859
|
+
* (the panel always renders the full content; oversized packs download from
|
|
6860
|
+
* the panel's course export instead of riding the conversation). */
|
|
6861
|
+
const LESSON_BODY_PROMPT_CAP = 24e3;
|
|
6862
|
+
const EXPORT_PROMPT_CAP = 1e5;
|
|
6445
6863
|
/** Total over a missing `meta` (events logged before a presentationMeta existed): renders nothing instead of throwing into the presenter fallback. */
|
|
6446
6864
|
const textBlocks = (lines) => (lines ?? []).map((text) => ({
|
|
6447
6865
|
type: "text",
|
|
@@ -6464,6 +6882,8 @@ function studyTools(store, deps = {}) {
|
|
|
6464
6882
|
let pendingDesign = null;
|
|
6465
6883
|
/** Which context-budget part of the pending brief the tutor last asked for. */
|
|
6466
6884
|
let pendingPart = 1;
|
|
6885
|
+
/** Audit B7: outstanding delete confirmations (courseId → token + expiry). */
|
|
6886
|
+
const pendingDeletes = /* @__PURE__ */ new Map();
|
|
6467
6887
|
/** Run a mutating state operation and persist. */
|
|
6468
6888
|
const mutate = (fn) => {
|
|
6469
6889
|
const result = fn(store.get());
|
|
@@ -6500,13 +6920,11 @@ function studyTools(store, deps = {}) {
|
|
|
6500
6920
|
}
|
|
6501
6921
|
}
|
|
6502
6922
|
} };
|
|
6503
|
-
const
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
|
|
6508
|
-
})
|
|
6509
|
-
};
|
|
6923
|
+
const importPresentationMeta = (_args, value) => importLines(value);
|
|
6924
|
+
const importPresent = { presentResult: (_args, result) => ({
|
|
6925
|
+
card: "generic",
|
|
6926
|
+
content: textBlocks(result.meta)
|
|
6927
|
+
}) };
|
|
6510
6928
|
const importMarkdown = defineTool({
|
|
6511
6929
|
name: "study_import_markdown",
|
|
6512
6930
|
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.",
|
|
@@ -6523,6 +6941,7 @@ function studyTools(store, deps = {}) {
|
|
|
6523
6941
|
},
|
|
6524
6942
|
output: {
|
|
6525
6943
|
...importOutput,
|
|
6944
|
+
presentationMeta: importPresentationMeta,
|
|
6526
6945
|
render: (_args, value) => [{
|
|
6527
6946
|
type: "text",
|
|
6528
6947
|
text: `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
|
|
@@ -6605,6 +7024,10 @@ function studyTools(store, deps = {}) {
|
|
|
6605
7024
|
type: "integer",
|
|
6606
7025
|
required: true
|
|
6607
7026
|
},
|
|
7027
|
+
designSeq: {
|
|
7028
|
+
type: "integer",
|
|
7029
|
+
description: "Pending-design sequence (audit C29): renders detect stale briefs."
|
|
7030
|
+
},
|
|
6608
7031
|
part: {
|
|
6609
7032
|
type: "integer",
|
|
6610
7033
|
description: "Brief part this call rendered (context-budget batching)."
|
|
@@ -6616,17 +7039,23 @@ function studyTools(store, deps = {}) {
|
|
|
6616
7039
|
}
|
|
6617
7040
|
}] } };
|
|
6618
7041
|
/** Shared render: the brief rides the design_required branch; imported stays the old summary. */
|
|
7042
|
+
/** Audit C29: every brief carries a sequence number; renders of a stale
|
|
7043
|
+
* value (a newer import replaced the pending design) say so instead of
|
|
7044
|
+
* presenting the wrong brief. */
|
|
7045
|
+
let designSeq = 0;
|
|
7046
|
+
const setPendingDesign = (pd) => {
|
|
7047
|
+
pendingDesign = pd;
|
|
7048
|
+
designSeq += 1;
|
|
7049
|
+
};
|
|
6619
7050
|
const designOrImportedRender = (_args, value) => [{
|
|
6620
7051
|
type: "text",
|
|
6621
|
-
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, pendingPart) : `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
|
|
7052
|
+
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." : typeof value.designSeq === "number" && value.designSeq !== designSeq ? "This design brief is STALE — a newer import replaced the pending design. Call the import tool again for the fresh brief; do not design against this one." : renderDesignBrief(pendingDesign, pendingPart) : `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
|
|
6622
7053
|
}];
|
|
6623
|
-
const
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
})
|
|
6629
|
-
};
|
|
7054
|
+
const designOrImportedPresentationMeta = (_args, value) => value.status === "design_required" ? designBriefLines(value) : importLines(value);
|
|
7055
|
+
const designOrImportedPresent = { presentResult: (_args, result) => ({
|
|
7056
|
+
card: "generic",
|
|
7057
|
+
content: textBlocks(result.meta)
|
|
7058
|
+
}) };
|
|
6630
7059
|
const importFolder = defineTool({
|
|
6631
7060
|
name: "study_import_folder",
|
|
6632
7061
|
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.",
|
|
@@ -6647,6 +7076,7 @@ function studyTools(store, deps = {}) {
|
|
|
6647
7076
|
},
|
|
6648
7077
|
output: {
|
|
6649
7078
|
...designOrImportedOutput,
|
|
7079
|
+
presentationMeta: designOrImportedPresentationMeta,
|
|
6650
7080
|
render: designOrImportedRender
|
|
6651
7081
|
},
|
|
6652
7082
|
async execute(args) {
|
|
@@ -6661,10 +7091,11 @@ function studyTools(store, deps = {}) {
|
|
|
6661
7091
|
const docs = inventory.docs;
|
|
6662
7092
|
if (docs.length === 0) throw new Error(`lookatstudy-plugin: no importable files found in ${args.path}`);
|
|
6663
7093
|
const title = args.title ?? basename(args.path.replaceAll("\\", "/"));
|
|
6664
|
-
|
|
7094
|
+
const pd = buildPendingDesignFromFolder(args.path, title, docs, {
|
|
6665
7095
|
translations: inventory.translations,
|
|
6666
7096
|
images: inventory.images
|
|
6667
7097
|
});
|
|
7098
|
+
setPendingDesign(pd);
|
|
6668
7099
|
const localImages = /* @__PURE__ */ new Map();
|
|
6669
7100
|
for (const img of inventory.images) {
|
|
6670
7101
|
if (img.absPath === "" || img.source === "pdf_page") continue;
|
|
@@ -6674,9 +7105,9 @@ function studyTools(store, deps = {}) {
|
|
|
6674
7105
|
localImages.set(img.path, `data:${img.mime};base64,${buf.toString("base64")}`);
|
|
6675
7106
|
} catch {}
|
|
6676
7107
|
}
|
|
6677
|
-
if (localImages.size > 0)
|
|
7108
|
+
if (localImages.size > 0) pd.localImages = localImages;
|
|
6678
7109
|
pendingPart = part;
|
|
6679
|
-
return designRequiredValue(
|
|
7110
|
+
return designRequiredValue(pd, part);
|
|
6680
7111
|
},
|
|
6681
7112
|
timeoutMs: 6e4,
|
|
6682
7113
|
presentCall: (args) => ({
|
|
@@ -6694,6 +7125,7 @@ function studyTools(store, deps = {}) {
|
|
|
6694
7125
|
pd.partCount = partCount;
|
|
6695
7126
|
return {
|
|
6696
7127
|
status: "design_required",
|
|
7128
|
+
designSeq,
|
|
6697
7129
|
repo: pd.repo,
|
|
6698
7130
|
branch: pd.branch,
|
|
6699
7131
|
courseTitle: partCount > 1 ? `${pd.courseTitle} (part ${part})` : pd.courseTitle,
|
|
@@ -6709,18 +7141,20 @@ function studyTools(store, deps = {}) {
|
|
|
6709
7141
|
* study_import_url's github branch (routing is the only difference). */
|
|
6710
7142
|
const runGithubImport = async (url, branch, exec, part = 1) => {
|
|
6711
7143
|
const { owner, repo } = parseGithubUrl(url);
|
|
7144
|
+
const canonicalRef = `https://github.com/${owner}/${repo}`;
|
|
6712
7145
|
const resolvedBranch = branch ?? "main";
|
|
6713
7146
|
const fetchFn = signalFetch(exec.signal, baseFetch);
|
|
6714
|
-
const existing = part <= 1 ? store.get().courses.find((c) => c.source === "github" && c.sourceRef ===
|
|
7147
|
+
const existing = part <= 1 ? store.get().courses.find((c) => c.source === "github" && c.sourceRef === canonicalRef) : void 0;
|
|
6715
7148
|
if (existing !== void 0) return {
|
|
6716
7149
|
status: "imported",
|
|
6717
7150
|
...toImportValue(existing)
|
|
6718
7151
|
};
|
|
6719
7152
|
const inventory = await fetchRepoInventory(owner, repo, resolvedBranch, fetchFn, void 0, exec.signal);
|
|
6720
|
-
|
|
6721
|
-
|
|
7153
|
+
const pd = buildPendingDesign(canonicalRef, owner, repo, inventory, await fetchFileOutlines(inventory.fileList.map((f) => f.path), owner, repo, inventory.branch, fetchFn, void 0, exec.signal));
|
|
7154
|
+
setPendingDesign(pd);
|
|
7155
|
+
if (pd.files.length === 0) throw new Error("lookatstudy-plugin: course files were discovered but no outlines could be fetched (CDN unreachable?)");
|
|
6722
7156
|
pendingPart = part;
|
|
6723
|
-
return designRequiredValue(
|
|
7157
|
+
return designRequiredValue(pd, part);
|
|
6724
7158
|
};
|
|
6725
7159
|
const importGithub = defineTool({
|
|
6726
7160
|
name: "study_import_github",
|
|
@@ -6742,6 +7176,7 @@ function studyTools(store, deps = {}) {
|
|
|
6742
7176
|
},
|
|
6743
7177
|
output: {
|
|
6744
7178
|
...designOrImportedOutput,
|
|
7179
|
+
presentationMeta: designOrImportedPresentationMeta,
|
|
6745
7180
|
render: designOrImportedRender
|
|
6746
7181
|
},
|
|
6747
7182
|
execute: (args, exec) => runGithubImport(args.url, args.branch, exec, args.part ?? 1),
|
|
@@ -6769,6 +7204,7 @@ function studyTools(store, deps = {}) {
|
|
|
6769
7204
|
},
|
|
6770
7205
|
output: {
|
|
6771
7206
|
...designOrImportedOutput,
|
|
7207
|
+
presentationMeta: designOrImportedPresentationMeta,
|
|
6772
7208
|
render: designOrImportedRender
|
|
6773
7209
|
},
|
|
6774
7210
|
async execute(args, exec) {
|
|
@@ -6794,26 +7230,28 @@ function studyTools(store, deps = {}) {
|
|
|
6794
7230
|
|
|
6795
7231
|
${p.text}`));
|
|
6796
7232
|
if (docs.length === 0) throw new Error("lookatstudy-plugin: CC subtitle text was empty after chunking");
|
|
6797
|
-
|
|
7233
|
+
const pd = buildPendingDesignFromUrl(identity, cc.title, docs);
|
|
7234
|
+
setPendingDesign(pd);
|
|
6798
7235
|
pendingPart = part;
|
|
6799
|
-
return designRequiredValue(
|
|
7236
|
+
return designRequiredValue(pd, part);
|
|
6800
7237
|
}
|
|
6801
7238
|
throw new Error(`lookatstudy-plugin: ${route.url} 是视频链接(YouTube/抖音需要 yt-dlp,插件环境不可用)。请让学习者把字幕/文稿粘贴进来,用 study_import_markdown 导入。`);
|
|
6802
7239
|
}
|
|
6803
7240
|
if (route.flavor === "arxiv") {
|
|
7241
|
+
const part = args.part ?? 1;
|
|
7242
|
+
const existingEarly = part <= 1 ? store.get().courses.find((c) => c.source === "url" && c.sourceRef === route.url) : void 0;
|
|
7243
|
+
if (existingEarly !== void 0) return {
|
|
7244
|
+
status: "imported",
|
|
7245
|
+
...toImportValue(existingEarly)
|
|
7246
|
+
};
|
|
6804
7247
|
const text = parsePdfText(await downloadToBuffer(route.pdfUrl, fetchFn, { signal: exec.signal }));
|
|
6805
7248
|
if (!text || text.replace(/\s+/g, "").length < 200) throw new Error("lookatstudy-plugin: arXiv PDF has no extractable text layer (scanned/image PDF) — try the HTML version or paste the abstract as markdown");
|
|
6806
7249
|
const docs = prepareSingleDoc(`arxiv-${route.arxivId}`, `# arXiv:${route.arxivId}\n\n${text}`);
|
|
6807
7250
|
if (docs.length === 0) throw new Error("lookatstudy-plugin: arXiv PDF text was empty after chunking");
|
|
6808
|
-
const
|
|
6809
|
-
|
|
6810
|
-
if (existing !== void 0) return {
|
|
6811
|
-
status: "imported",
|
|
6812
|
-
...toImportValue(existing)
|
|
6813
|
-
};
|
|
6814
|
-
pendingDesign = buildPendingDesignFromUrl(route.url, `arXiv:${route.arxivId}`, docs);
|
|
7251
|
+
const pd = buildPendingDesignFromUrl(route.url, `arXiv:${route.arxivId}`, docs);
|
|
7252
|
+
setPendingDesign(pd);
|
|
6815
7253
|
pendingPart = part;
|
|
6816
|
-
return designRequiredValue(
|
|
7254
|
+
return designRequiredValue(pd, part);
|
|
6817
7255
|
}
|
|
6818
7256
|
const part = args.part ?? 1;
|
|
6819
7257
|
const identity = normalizeUrlIdentity(route.url);
|
|
@@ -6822,16 +7260,18 @@ ${p.text}`));
|
|
|
6822
7260
|
status: "imported",
|
|
6823
7261
|
...toImportValue(existing)
|
|
6824
7262
|
};
|
|
6825
|
-
const
|
|
6826
|
-
|
|
6827
|
-
|
|
7263
|
+
const html = await guardedFetchText(route.url, {
|
|
7264
|
+
fetchImpl: fetchFn,
|
|
7265
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; LookatStudyPlugin/0.9)" }
|
|
7266
|
+
});
|
|
6828
7267
|
const article = extractArticle(html, route.url);
|
|
6829
7268
|
if (article === null) throw new Error(`lookatstudy-plugin: ${route.url} does not look like an article page (no readable body found) — login walls, indexes and app shells are rejected honestly rather than imported as noise`);
|
|
6830
7269
|
const docs = prepareSingleDoc(article.title.slice(0, 60), article.markdown);
|
|
6831
7270
|
if (docs.length === 0) throw new Error("lookatstudy-plugin: article body was empty after chunking");
|
|
6832
|
-
|
|
7271
|
+
const pd = buildPendingDesignFromUrl(identity, article.title, docs);
|
|
7272
|
+
setPendingDesign(pd);
|
|
6833
7273
|
pendingPart = part;
|
|
6834
|
-
return designRequiredValue(
|
|
7274
|
+
return designRequiredValue(pd, part);
|
|
6835
7275
|
},
|
|
6836
7276
|
timeoutMs: 18e4,
|
|
6837
7277
|
presentCall: (args) => ({
|
|
@@ -6916,11 +7356,11 @@ ${p.text}`));
|
|
|
6916
7356
|
required: true
|
|
6917
7357
|
},
|
|
6918
7358
|
firstLessonId: {
|
|
6919
|
-
type: "string",
|
|
7359
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
6920
7360
|
required: true
|
|
6921
7361
|
},
|
|
6922
7362
|
firstLessonTitle: {
|
|
6923
|
-
type: "string",
|
|
7363
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
6924
7364
|
required: true
|
|
6925
7365
|
},
|
|
6926
7366
|
droppedLessons: {
|
|
@@ -6977,7 +7417,7 @@ ${p.text}`));
|
|
|
6977
7417
|
created: state.courses.length > before
|
|
6978
7418
|
};
|
|
6979
7419
|
});
|
|
6980
|
-
|
|
7420
|
+
setPendingDesign(null);
|
|
6981
7421
|
pendingPart = 1;
|
|
6982
7422
|
return {
|
|
6983
7423
|
...value,
|
|
@@ -6991,7 +7431,6 @@ ${p.text}`));
|
|
|
6991
7431
|
title: "Apply course design",
|
|
6992
7432
|
kind: "edit"
|
|
6993
7433
|
}),
|
|
6994
|
-
presentationMeta: (_args, value) => [...importLines(value), ...value.droppedLessons > 0 ? [`${value.droppedLessons} dropped (files outside the brief)`] : []],
|
|
6995
7434
|
presentResult: (_args, result) => ({
|
|
6996
7435
|
card: "generic",
|
|
6997
7436
|
content: textBlocks(result.meta)
|
|
@@ -7183,6 +7622,7 @@ ${p.text}`));
|
|
|
7183
7622
|
description: "Course id from an import result or study_courses."
|
|
7184
7623
|
} },
|
|
7185
7624
|
output: {
|
|
7625
|
+
presentationMeta: (_args, value) => mapLines(value),
|
|
7186
7626
|
schema: {
|
|
7187
7627
|
type: "object",
|
|
7188
7628
|
additionalProperties: false,
|
|
@@ -7285,7 +7725,6 @@ ${p.text}`));
|
|
|
7285
7725
|
title: `Course map: ${args.courseId}`,
|
|
7286
7726
|
kind: "read"
|
|
7287
7727
|
}),
|
|
7288
|
-
presentationMeta: (_args, value) => mapLines(value),
|
|
7289
7728
|
presentResult: (_args, result) => ({
|
|
7290
7729
|
card: "generic",
|
|
7291
7730
|
content: textBlocks(result.meta)
|
|
@@ -7502,12 +7941,13 @@ ${p.text}`));
|
|
|
7502
7941
|
},
|
|
7503
7942
|
render: (_args, value) => [{
|
|
7504
7943
|
type: "text",
|
|
7505
|
-
text: `Lesson “${value.title}” — ${value.courseTitle} / ${value.sectionTitle}\nstatus ${value.status}${value.masteryPct === null ? "" : `, mastery ${value.masteryPct}%`}, ${value.correctCount}/${value.attempts} answers correct\nstrategy: ${value.strategy}\n` + (value.concepts === null ? "" : `concepts: ${value.concepts.map((c) => `${c.title} ${c.masteryPct}%${c.weak ? " ⚡weak" : ""}`).join(" · ")}\n`) + (value.examGuide === void 0 ? "" : `exam: ${value.examGuide.questionCount} questions (${value.examGuide.kcCount} KCs); per-question time ${value.examGuide.timeLimitRule}; stars ${value.examGuide.starsRule}\n`) + `starters: ${value.starters.map((s) => s.label).join(" / ")}\n\n
|
|
7944
|
+
text: `Lesson “${value.title}” — ${value.courseTitle} / ${value.sectionTitle}\nstatus ${value.status}${value.masteryPct === null ? "" : `, mastery ${value.masteryPct}%`}, ${value.correctCount}/${value.attempts} answers correct\nstrategy: ${value.strategy}\n` + (value.concepts === null ? "" : `concepts: ${value.concepts.map((c) => `${c.title} ${c.masteryPct}%${c.weak ? " ⚡weak" : ""}`).join(" · ")}\n`) + (value.examGuide === void 0 ? "" : `exam: ${value.examGuide.questionCount} questions (${value.examGuide.kcCount} KCs); per-question time ${value.examGuide.timeLimitRule}; stars ${value.examGuide.starsRule}\n`) + `starters: ${value.starters.map((s) => s.label).join(" / ")}\n\n` + (value.body.length > LESSON_BODY_PROMPT_CAP ? `${value.body.slice(0, LESSON_BODY_PROMPT_CAP)}\n…(lesson body truncated at ${LESSON_BODY_PROMPT_CAP} chars — the full body renders in the study panel)` : value.body) + `${value.nextLessonId === null ? "\n\n(this is the last lesson)" : `\n\n(next lesson: ${value.nextLessonId})`}`
|
|
7506
7945
|
}]
|
|
7507
7946
|
},
|
|
7508
7947
|
async execute(args) {
|
|
7509
7948
|
return mutate((state) => {
|
|
7510
|
-
const
|
|
7949
|
+
const ref = findLesson(state, args.lessonId);
|
|
7950
|
+
if (ref.lesson.kind !== "exam") attemptLesson(state, args.lessonId, /* @__PURE__ */ new Date());
|
|
7511
7951
|
state.focus = { lessonId: ref.lesson.id };
|
|
7512
7952
|
return toLessonValue(ref, state);
|
|
7513
7953
|
});
|
|
@@ -7550,6 +7990,7 @@ ${p.text}`));
|
|
|
7550
7990
|
}
|
|
7551
7991
|
},
|
|
7552
7992
|
output: {
|
|
7993
|
+
presentationMeta: (_args, value) => [answerLine(value)],
|
|
7553
7994
|
schema: {
|
|
7554
7995
|
type: "object",
|
|
7555
7996
|
additionalProperties: false,
|
|
@@ -7660,7 +8101,6 @@ ${p.text}`));
|
|
|
7660
8101
|
card: "generic",
|
|
7661
8102
|
title: `Record answer (${args.correct ? "correct" : "incorrect"}): ${args.lessonId}`
|
|
7662
8103
|
}),
|
|
7663
|
-
presentationMeta: (_args, value) => [answerLine(value)],
|
|
7664
8104
|
presentResult: (_args, result) => ({
|
|
7665
8105
|
card: "generic",
|
|
7666
8106
|
content: textBlocks(result.meta)
|
|
@@ -7687,6 +8127,7 @@ ${p.text}`));
|
|
|
7687
8127
|
}
|
|
7688
8128
|
},
|
|
7689
8129
|
output: {
|
|
8130
|
+
presentationMeta: (_args, value) => [`${value.stars}★`],
|
|
7690
8131
|
schema: {
|
|
7691
8132
|
type: "object",
|
|
7692
8133
|
additionalProperties: false,
|
|
@@ -7752,7 +8193,7 @@ ${p.text}`));
|
|
|
7752
8193
|
text: `Exam result: ${value.stars}★ (best ${value.bestStars}★, attempt ${value.attempts}). Offer the learner: ${value.actions.map((a) => a.label).join(" / ")}.` + (value.nextLessonId === null ? "" : ` Next topic: ${value.nextLessonId}.`)
|
|
7753
8194
|
}]
|
|
7754
8195
|
},
|
|
7755
|
-
execute(args) {
|
|
8196
|
+
async execute(args) {
|
|
7756
8197
|
return mutate((state) => {
|
|
7757
8198
|
const r = recordExamResult(state, args.lessonId, args.correct, args.total);
|
|
7758
8199
|
const actions = getPostQuizActions({
|
|
@@ -7789,7 +8230,6 @@ ${p.text}`));
|
|
|
7789
8230
|
card: "generic",
|
|
7790
8231
|
title: `Exam result: ${args.correct}/${args.total}`
|
|
7791
8232
|
}),
|
|
7792
|
-
presentationMeta: (_args, value) => [`${value.stars}★`],
|
|
7793
8233
|
presentResult: (_args, result) => ({
|
|
7794
8234
|
card: "generic",
|
|
7795
8235
|
content: textBlocks(result.meta)
|
|
@@ -7797,7 +8237,7 @@ ${p.text}`));
|
|
|
7797
8237
|
});
|
|
7798
8238
|
const examBankApplyTool = defineTool({
|
|
7799
8239
|
name: "study_exam_bank_apply",
|
|
7800
|
-
description: "Author the question bank for a section exam node (exam-v2). The exam page asks for a bank when the learner opens it; call this once with the full multiple-choice set. Question count follows planExamQuota on the section's KC union (clamp 5-15); each kcTitle MUST be one of the section lessons' concept titles (anti-hallucination — unknown KC titles are rejected; read the section with
|
|
8240
|
+
description: "Author the question bank for a section exam node (exam-v2). The exam page asks for a bank when the learner opens it; call this once with the full multiple-choice set. Question count follows planExamQuota on the section's KC union (clamp 5-15); each kcTitle MUST be one of the section lessons' concept titles (anti-hallucination — unknown KC titles are rejected; read the section lessons with study_lesson first). On a validation error the tutor fixes the bank and simply calls again. The learner answers in the exam page; grading is automatic (unanswered = wrong, best-of stars kept).",
|
|
7801
8241
|
parameters: {
|
|
7802
8242
|
lessonId: {
|
|
7803
8243
|
type: "string",
|
|
@@ -7841,6 +8281,7 @@ ${p.text}`));
|
|
|
7841
8281
|
}
|
|
7842
8282
|
},
|
|
7843
8283
|
output: {
|
|
8284
|
+
presentationMeta: (_args, value) => [`${value.questionCount}Q`],
|
|
7844
8285
|
schema: {
|
|
7845
8286
|
type: "object",
|
|
7846
8287
|
additionalProperties: false,
|
|
@@ -7868,7 +8309,7 @@ ${p.text}`));
|
|
|
7868
8309
|
text: `Exam bank ready: ${value.questionCount} questions across ${value.kcCount} knowledge components. The exam page settles into its ready state — tell the learner to hit 开始考试.`
|
|
7869
8310
|
}]
|
|
7870
8311
|
},
|
|
7871
|
-
execute(args) {
|
|
8312
|
+
async execute(args) {
|
|
7872
8313
|
return mutate((state) => {
|
|
7873
8314
|
const r = applyExamBank(state, args.lessonId, args.questions, /* @__PURE__ */ new Date());
|
|
7874
8315
|
return {
|
|
@@ -7883,7 +8324,6 @@ ${p.text}`));
|
|
|
7883
8324
|
card: "generic",
|
|
7884
8325
|
title: `Exam bank: ${Array.isArray(args.questions) ? String(args.questions.length) : "?"} questions`
|
|
7885
8326
|
}),
|
|
7886
|
-
presentationMeta: (_args, value) => [`${value.questionCount}Q`],
|
|
7887
8327
|
presentResult: (_args, result) => ({
|
|
7888
8328
|
card: "generic",
|
|
7889
8329
|
content: textBlocks(result.meta)
|
|
@@ -7898,6 +8338,7 @@ ${p.text}`));
|
|
|
7898
8338
|
description: "Lesson to complete."
|
|
7899
8339
|
} },
|
|
7900
8340
|
output: {
|
|
8341
|
+
presentationMeta: (_args, value) => completeLines(value),
|
|
7901
8342
|
schema: {
|
|
7902
8343
|
type: "object",
|
|
7903
8344
|
additionalProperties: false,
|
|
@@ -7952,7 +8393,6 @@ ${p.text}`));
|
|
|
7952
8393
|
card: "generic",
|
|
7953
8394
|
title: `Complete lesson: ${args.lessonId}`
|
|
7954
8395
|
}),
|
|
7955
|
-
presentationMeta: (_args, value) => completeLines(value),
|
|
7956
8396
|
presentResult: (_args, result) => ({
|
|
7957
8397
|
card: "generic",
|
|
7958
8398
|
content: textBlocks(result.meta)
|
|
@@ -7966,6 +8406,7 @@ ${p.text}`));
|
|
|
7966
8406
|
description: "Restrict to one course; omit to scan all courses."
|
|
7967
8407
|
} },
|
|
7968
8408
|
output: {
|
|
8409
|
+
presentationMeta: (_args, value) => dueLines(value),
|
|
7969
8410
|
schema: {
|
|
7970
8411
|
type: "object",
|
|
7971
8412
|
additionalProperties: false,
|
|
@@ -8027,7 +8468,6 @@ ${p.text}`));
|
|
|
8027
8468
|
title: "List due reviews",
|
|
8028
8469
|
kind: "search"
|
|
8029
8470
|
}),
|
|
8030
|
-
presentationMeta: (_args, value) => dueLines(value),
|
|
8031
8471
|
presentResult: (_args, result) => ({
|
|
8032
8472
|
card: "generic",
|
|
8033
8473
|
content: textBlocks(result.meta)
|
|
@@ -8050,6 +8490,7 @@ ${p.text}`));
|
|
|
8050
8490
|
}
|
|
8051
8491
|
},
|
|
8052
8492
|
output: {
|
|
8493
|
+
presentationMeta: (_args, value) => [reviewLine(value)],
|
|
8053
8494
|
schema: {
|
|
8054
8495
|
type: "object",
|
|
8055
8496
|
additionalProperties: false,
|
|
@@ -8107,7 +8548,6 @@ ${p.text}`));
|
|
|
8107
8548
|
card: "generic",
|
|
8108
8549
|
title: `Record review (quality ${args.quality}): ${args.lessonId}`
|
|
8109
8550
|
}),
|
|
8110
|
-
presentationMeta: (_args, value) => [reviewLine(value)],
|
|
8111
8551
|
presentResult: (_args, result) => ({
|
|
8112
8552
|
card: "generic",
|
|
8113
8553
|
content: textBlocks(result.meta)
|
|
@@ -8115,37 +8555,86 @@ ${p.text}`));
|
|
|
8115
8555
|
});
|
|
8116
8556
|
const deleteCourseTool = defineTool({
|
|
8117
8557
|
name: "study_delete_course",
|
|
8118
|
-
description: "Delete one course and all its progress
|
|
8119
|
-
parameters: {
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
8558
|
+
description: "Delete one course and all its progress — TWO steps by design (audit B7): a call without confirmToken only queues the deletion and returns the token; ask the learner, and re-call with the token verbatim to execute. Deleted courses land in the restorable trash (study_restore_course), so nothing is gone forever.",
|
|
8559
|
+
parameters: {
|
|
8560
|
+
courseId: {
|
|
8561
|
+
type: "string",
|
|
8562
|
+
required: true,
|
|
8563
|
+
description: "Course to delete."
|
|
8564
|
+
},
|
|
8565
|
+
confirmToken: {
|
|
8566
|
+
type: "string",
|
|
8567
|
+
description: "The token from the confirm_required result, echoed verbatim after the learner confirms."
|
|
8568
|
+
}
|
|
8569
|
+
},
|
|
8124
8570
|
output: {
|
|
8125
8571
|
schema: {
|
|
8126
8572
|
type: "object",
|
|
8127
8573
|
additionalProperties: false,
|
|
8128
8574
|
properties: {
|
|
8129
|
-
|
|
8575
|
+
status: {
|
|
8576
|
+
type: "string",
|
|
8577
|
+
required: true,
|
|
8578
|
+
description: "confirm_required | deleted"
|
|
8579
|
+
},
|
|
8580
|
+
courseId: {
|
|
8130
8581
|
type: "string",
|
|
8131
8582
|
required: true
|
|
8132
8583
|
},
|
|
8584
|
+
title: {
|
|
8585
|
+
type: "string",
|
|
8586
|
+
description: "Present on confirm_required."
|
|
8587
|
+
},
|
|
8588
|
+
lessons: {
|
|
8589
|
+
type: "integer",
|
|
8590
|
+
description: "Present on confirm_required."
|
|
8591
|
+
},
|
|
8592
|
+
confirmToken: {
|
|
8593
|
+
type: "string",
|
|
8594
|
+
description: "Present on confirm_required — echo it in the second call."
|
|
8595
|
+
},
|
|
8596
|
+
deletedCourseId: {
|
|
8597
|
+
type: "string",
|
|
8598
|
+
description: "Present on deleted."
|
|
8599
|
+
},
|
|
8133
8600
|
remaining: {
|
|
8134
8601
|
type: "integer",
|
|
8135
|
-
|
|
8602
|
+
description: "Present on deleted."
|
|
8136
8603
|
}
|
|
8137
8604
|
}
|
|
8138
8605
|
},
|
|
8139
|
-
render: (_args, value) => [{
|
|
8606
|
+
render: (_args, value) => value.status === "confirm_required" ? [{
|
|
8140
8607
|
type: "text",
|
|
8141
|
-
text: `
|
|
8608
|
+
text: `Course “${value.title}” (${value.lessons} lessons, all progress) is queued for deletion. Ask the learner; on their explicit confirmation re-call study_delete_course with the confirmToken. Deletions land in the restorable trash (study_restore_course).`
|
|
8609
|
+
}] : [{
|
|
8610
|
+
type: "text",
|
|
8611
|
+
text: `Deleted course ${value.deletedCourseId}. ${value.remaining} courses remain. Recoverable from the trash via study_restore_course.`
|
|
8142
8612
|
}]
|
|
8143
8613
|
},
|
|
8144
8614
|
async execute(args) {
|
|
8145
8615
|
return mutate((state) => {
|
|
8146
|
-
findCourse(state, args.courseId);
|
|
8616
|
+
const course = findCourse(state, args.courseId);
|
|
8617
|
+
const pending = pendingDeletes.get(args.courseId);
|
|
8618
|
+
const now = Date.now();
|
|
8619
|
+
if (typeof args.confirmToken !== "string" || pending === void 0 || pending.token !== args.confirmToken || pending.expires < now) {
|
|
8620
|
+
const token = randomBytes(8).toString("hex");
|
|
8621
|
+
pendingDeletes.set(args.courseId, {
|
|
8622
|
+
token,
|
|
8623
|
+
expires: now + 3e5
|
|
8624
|
+
});
|
|
8625
|
+
return {
|
|
8626
|
+
status: "confirm_required",
|
|
8627
|
+
courseId: course.id,
|
|
8628
|
+
title: course.title,
|
|
8629
|
+
lessons: course.sections.flatMap((s) => s.lessons).length,
|
|
8630
|
+
confirmToken: token
|
|
8631
|
+
};
|
|
8632
|
+
}
|
|
8633
|
+
pendingDeletes.delete(args.courseId);
|
|
8147
8634
|
deleteCourse(state, args.courseId);
|
|
8148
8635
|
return {
|
|
8636
|
+
status: "deleted",
|
|
8637
|
+
courseId: args.courseId,
|
|
8149
8638
|
deletedCourseId: args.courseId,
|
|
8150
8639
|
remaining: state.courses.length
|
|
8151
8640
|
};
|
|
@@ -8158,6 +8647,59 @@ ${p.text}`));
|
|
|
8158
8647
|
rawInput: args.courseId
|
|
8159
8648
|
})
|
|
8160
8649
|
});
|
|
8650
|
+
const restoreCourseTool = defineTool({
|
|
8651
|
+
name: "study_restore_course",
|
|
8652
|
+
description: "Restore a deleted course from the trash — progress, notes, exam banks, and thread groups all come back. A miss lists the restorable ids in its error. The trash keeps the 10 most recent deletes.",
|
|
8653
|
+
parameters: { courseId: {
|
|
8654
|
+
type: "string",
|
|
8655
|
+
required: true,
|
|
8656
|
+
description: "Trashed course id to restore."
|
|
8657
|
+
} },
|
|
8658
|
+
output: {
|
|
8659
|
+
schema: {
|
|
8660
|
+
type: "object",
|
|
8661
|
+
additionalProperties: false,
|
|
8662
|
+
properties: {
|
|
8663
|
+
restoredCourseId: {
|
|
8664
|
+
type: "string",
|
|
8665
|
+
required: true
|
|
8666
|
+
},
|
|
8667
|
+
title: {
|
|
8668
|
+
type: "string",
|
|
8669
|
+
required: true
|
|
8670
|
+
},
|
|
8671
|
+
lessonCount: {
|
|
8672
|
+
type: "integer",
|
|
8673
|
+
required: true
|
|
8674
|
+
},
|
|
8675
|
+
remainingTrash: {
|
|
8676
|
+
type: "integer",
|
|
8677
|
+
required: true
|
|
8678
|
+
}
|
|
8679
|
+
}
|
|
8680
|
+
},
|
|
8681
|
+
render: (_args, value) => [{
|
|
8682
|
+
type: "text",
|
|
8683
|
+
text: `Restored course “${value.title}” from the trash (${value.lessonCount} lessons back; ${value.remainingTrash} still trashed).`
|
|
8684
|
+
}]
|
|
8685
|
+
},
|
|
8686
|
+
async execute(args) {
|
|
8687
|
+
return mutate((state) => {
|
|
8688
|
+
const course = restoreCourse(state, args.courseId);
|
|
8689
|
+
return {
|
|
8690
|
+
restoredCourseId: course.id,
|
|
8691
|
+
title: course.title,
|
|
8692
|
+
lessonCount: course.sections.flatMap((s) => s.lessons).length,
|
|
8693
|
+
remainingTrash: (state.trash ?? []).length
|
|
8694
|
+
};
|
|
8695
|
+
});
|
|
8696
|
+
},
|
|
8697
|
+
presentCall: (args) => ({
|
|
8698
|
+
card: "generic",
|
|
8699
|
+
title: `Restore course: ${args.courseId}`,
|
|
8700
|
+
kind: "read"
|
|
8701
|
+
})
|
|
8702
|
+
});
|
|
8161
8703
|
const defineConceptsTool = defineTool({
|
|
8162
8704
|
name: "study_define_concepts",
|
|
8163
8705
|
description: "Define a lesson's knowledge components — the 2–7 independently quizzable units mastery tracks. Call this the FIRST time you teach a lesson, derived from its content. Titles ≤10 characters; descriptions say what understanding this concept means. Lesson mastery is the WEAKEST concept; cover weak ones (⚡) first when quizzing.",
|
|
@@ -8261,6 +8803,12 @@ ${p.text}`));
|
|
|
8261
8803
|
}
|
|
8262
8804
|
},
|
|
8263
8805
|
output: {
|
|
8806
|
+
presentationMeta: (_args, value) => ({
|
|
8807
|
+
kind: "study-proposal-created",
|
|
8808
|
+
proposalId: value.proposalId,
|
|
8809
|
+
lessonTitle: value.lessonTitle,
|
|
8810
|
+
rationale: value.rationale
|
|
8811
|
+
}),
|
|
8264
8812
|
schema: {
|
|
8265
8813
|
type: "object",
|
|
8266
8814
|
additionalProperties: false,
|
|
@@ -8309,12 +8857,6 @@ ${p.text}`));
|
|
|
8309
8857
|
card: "generic",
|
|
8310
8858
|
title: `Propose mastery: ${args.lessonId}`
|
|
8311
8859
|
}),
|
|
8312
|
-
presentationMeta: (_args, value) => ({
|
|
8313
|
-
kind: "study-proposal-created",
|
|
8314
|
-
proposalId: value.proposalId,
|
|
8315
|
-
lessonTitle: value.lessonTitle,
|
|
8316
|
-
rationale: value.rationale
|
|
8317
|
-
}),
|
|
8318
8860
|
presentResult: (_args, result) => ({
|
|
8319
8861
|
card: "generic",
|
|
8320
8862
|
content: textBlocks([`🎓 Proposed mastery for “${result.meta?.lessonTitle ?? "lesson"}”: ${result.meta?.rationale ?? ""}`])
|
|
@@ -8336,6 +8878,11 @@ ${p.text}`));
|
|
|
8336
8878
|
}
|
|
8337
8879
|
},
|
|
8338
8880
|
output: {
|
|
8881
|
+
presentationMeta: (_args, value) => ({
|
|
8882
|
+
kind: "study-proposal-resolved",
|
|
8883
|
+
proposalId: value.proposalId,
|
|
8884
|
+
status: value.status
|
|
8885
|
+
}),
|
|
8339
8886
|
schema: {
|
|
8340
8887
|
type: "object",
|
|
8341
8888
|
additionalProperties: false,
|
|
@@ -8374,11 +8921,6 @@ ${p.text}`));
|
|
|
8374
8921
|
card: "generic",
|
|
8375
8922
|
title: `Resolve proposal: ${args.proposalId}`
|
|
8376
8923
|
}),
|
|
8377
|
-
presentationMeta: (_args, value) => ({
|
|
8378
|
-
kind: "study-proposal-resolved",
|
|
8379
|
-
proposalId: value.proposalId,
|
|
8380
|
-
status: value.status
|
|
8381
|
-
}),
|
|
8382
8924
|
presentResult: (_args, result) => ({
|
|
8383
8925
|
card: "generic",
|
|
8384
8926
|
content: textBlocks([`Proposal ${result.meta?.proposalId ?? "?"} ${result.meta?.status ?? ""}.`])
|
|
@@ -8527,7 +9069,7 @@ ${p.text}`));
|
|
|
8527
9069
|
text: `Translation stored for “${value.lessonTitle}” (${value.lang}, ${value.chars} chars) — the blackboard now interleaves the original with it.`
|
|
8528
9070
|
}]
|
|
8529
9071
|
},
|
|
8530
|
-
execute(args) {
|
|
9072
|
+
async execute(args) {
|
|
8531
9073
|
return mutate((state) => {
|
|
8532
9074
|
const ref = findLesson(state, args.lessonId);
|
|
8533
9075
|
if (args.markdown.trim() === "") throw new Error("lookatstudy-plugin: translation markdown is empty");
|
|
@@ -8617,7 +9159,7 @@ ${p.text}`));
|
|
|
8617
9159
|
text: `Consolidation window since ${value.since ?? "(beginning)"}: ${value.counts.friction} friction, ${value.counts.practice} practice entries.` + (value.entries.length === 0 ? " Nothing to distill — tell the learner their memory is up to date." : " Distill these into 0–3 study_remember writes (global / pattern / lesson), then summarize in one line.")
|
|
8618
9160
|
}]
|
|
8619
9161
|
},
|
|
8620
|
-
execute() {
|
|
9162
|
+
async execute() {
|
|
8621
9163
|
return mutate((state) => {
|
|
8622
9164
|
const window = gatherConsolidationWindow(state);
|
|
8623
9165
|
const watermark = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -8667,26 +9209,30 @@ ${p.text}`));
|
|
|
8667
9209
|
markdown: {
|
|
8668
9210
|
type: "string",
|
|
8669
9211
|
required: true
|
|
9212
|
+
},
|
|
9213
|
+
truncated: {
|
|
9214
|
+
type: "boolean",
|
|
9215
|
+
description: "true when the in-chat markdown was capped — the complete pack downloads from the study panel instead."
|
|
8670
9216
|
}
|
|
8671
9217
|
}
|
|
8672
9218
|
},
|
|
8673
9219
|
render: (_args, value) => [{
|
|
8674
9220
|
type: "text",
|
|
8675
|
-
text: `Course pack “${value.title}” — ${value.lessonCount} lessons, ${value.chars} chars. Give the learner the markdown below (copyable); importing it goes through study_import_markdown.
|
|
8676
|
-
|
|
8677
|
-
` + value.markdown
|
|
9221
|
+
text: `Course pack “${value.title}” — ${value.lessonCount} lessons, ${value.chars} chars. ` + (value.truncated === true ? "The pack exceeds the in-chat cap — only its head is shown; the complete file downloads from the study panel (course card → 导出课程包).\n\n" : "Give the learner the markdown below (copyable); importing it goes through study_import_markdown.\n\n") + value.markdown
|
|
8678
9222
|
}]
|
|
8679
9223
|
},
|
|
8680
|
-
execute(args) {
|
|
9224
|
+
async execute(args) {
|
|
8681
9225
|
const course = findCourse(store.get(), args.courseId);
|
|
8682
|
-
const
|
|
9226
|
+
const full = courseToPackMarkdown(course);
|
|
8683
9227
|
const lessonCount = course.sections.reduce((n, sec) => n + sec.lessons.filter((l) => l.kind !== "exam").length, 0);
|
|
9228
|
+
const truncated = full.length > EXPORT_PROMPT_CAP;
|
|
8684
9229
|
return {
|
|
8685
9230
|
courseId: course.id,
|
|
8686
9231
|
title: course.title,
|
|
8687
9232
|
lessonCount,
|
|
8688
|
-
chars:
|
|
8689
|
-
markdown
|
|
9233
|
+
chars: full.length,
|
|
9234
|
+
markdown: truncated ? `${full.slice(0, EXPORT_PROMPT_CAP)}\n…(pack truncated at ${EXPORT_PROMPT_CAP} chars)` : full,
|
|
9235
|
+
truncated
|
|
8690
9236
|
};
|
|
8691
9237
|
},
|
|
8692
9238
|
isConcurrencySafe: () => true,
|
|
@@ -9019,24 +9565,28 @@ ${p.text}`));
|
|
|
9019
9565
|
title: `Generate practice card: ${typeof args.title === "string" ? args.title : "练习"} (${args.questions?.length ?? 0} questions)`
|
|
9020
9566
|
})
|
|
9021
9567
|
});
|
|
9022
|
-
/** Record one sanitized artifact on its lesson (shared by the artifact tools).
|
|
9023
|
-
|
|
9024
|
-
|
|
9025
|
-
|
|
9026
|
-
|
|
9027
|
-
|
|
9028
|
-
|
|
9029
|
-
|
|
9030
|
-
|
|
9031
|
-
|
|
9568
|
+
/** Record one sanitized artifact on its lesson (shared by the artifact tools).
|
|
9569
|
+
* C23: generic over the sanitized shape so each tool's execute returns its
|
|
9570
|
+
* own literal (checked against the tool's inferred output type). */
|
|
9571
|
+
const recordSanitized = async (lessonId, result, type) => {
|
|
9572
|
+
return await mutate((state) => {
|
|
9573
|
+
const id = artifactId(type, result.data);
|
|
9574
|
+
const stored = recordArtifact(state, lessonId, {
|
|
9575
|
+
id,
|
|
9576
|
+
artifactType: type,
|
|
9577
|
+
title: typeof result.data.title === "string" ? result.data.title : type,
|
|
9578
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9579
|
+
hash: id.slice(type.length + 1),
|
|
9580
|
+
data: result.data
|
|
9581
|
+
});
|
|
9582
|
+
return {
|
|
9583
|
+
artifactType: type,
|
|
9584
|
+
artifactId: stored.artifact.id,
|
|
9585
|
+
created: stored.created,
|
|
9586
|
+
...stored.artifact.data
|
|
9587
|
+
};
|
|
9032
9588
|
});
|
|
9033
|
-
|
|
9034
|
-
artifactType: type,
|
|
9035
|
-
artifactId: stored.artifact.id,
|
|
9036
|
-
created: stored.created,
|
|
9037
|
-
...stored.artifact.data
|
|
9038
|
-
};
|
|
9039
|
-
});
|
|
9589
|
+
};
|
|
9040
9590
|
return [
|
|
9041
9591
|
importMarkdown,
|
|
9042
9592
|
importFolder,
|
|
@@ -9053,6 +9603,7 @@ ${p.text}`));
|
|
|
9053
9603
|
dueReviewsTool,
|
|
9054
9604
|
recordReviewTool,
|
|
9055
9605
|
deleteCourseTool,
|
|
9606
|
+
restoreCourseTool,
|
|
9056
9607
|
defineConceptsTool,
|
|
9057
9608
|
proposeMasteryTool,
|
|
9058
9609
|
resolveProposalTool,
|
|
@@ -9558,6 +10109,18 @@ function soulText(state) {
|
|
|
9558
10109
|
return state.active ? SOULS[state.mode] : "";
|
|
9559
10110
|
}
|
|
9560
10111
|
/**
|
|
10112
|
+
* Audit B7: imported titles, friction summaries, and proposal rationales are
|
|
10113
|
+
* untrusted text (repo names, article titles, repo-derived content) landing
|
|
10114
|
+
* in the per-turn system prompt — flatten to one line, strip control
|
|
10115
|
+
* characters, and cap the length so nothing can forge prompt structure
|
|
10116
|
+
* mid-section (newlines + fake section markers).
|
|
10117
|
+
*/
|
|
10118
|
+
function sanitizePromptText(value, max = 120) {
|
|
10119
|
+
if (value === null || value === void 0) return "";
|
|
10120
|
+
const flat = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
10121
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
10122
|
+
}
|
|
10123
|
+
/**
|
|
9561
10124
|
* Render the learner snapshot (LookatStudy's per-turn volatile tail) as the
|
|
9562
10125
|
* dynamic runtime context: focus, strategy band, concepts with weak flags,
|
|
9563
10126
|
* recent friction, memory slots, due count, pending proposal. Dormant
|
|
@@ -9568,18 +10131,18 @@ function snapshotSectionText(state) {
|
|
|
9568
10131
|
const snap = learnerSnapshot(state, /* @__PURE__ */ new Date());
|
|
9569
10132
|
if (snap.focus === null) return snap.dueCount === 0 ? "" : `【学习者当前状态】\n今日待复习: ${snap.dueCount} 项(study_due_reviews)`;
|
|
9570
10133
|
const lines = ["【学习者当前状态】"];
|
|
9571
|
-
lines.push(`焦点: ${snap.focus.courseTitle} [courseId ${snap.focus.courseId}] / ${snap.focus.lessonTitle} [lessonId ${snap.focus.lessonId}](${snap.focus.status}${snap.focus.masteryPct === null ? "" : `, 掌握度 ${snap.focus.masteryPct}%`})`);
|
|
10134
|
+
lines.push(`焦点: ${sanitizePromptText(snap.focus.courseTitle)} [courseId ${snap.focus.courseId}] / ${sanitizePromptText(snap.focus.lessonTitle)} [lessonId ${snap.focus.lessonId}](${snap.focus.status}${snap.focus.masteryPct === null ? "" : `, 掌握度 ${snap.focus.masteryPct}%`})`);
|
|
9572
10135
|
if (snap.strategy !== null) lines.push(`教学策略: ${snap.strategy}`);
|
|
9573
|
-
if (snap.concepts !== null && snap.concepts.length > 0) lines.push(`知识点(课级掌握度 = 最薄弱知识点): ${snap.concepts.map((c) => `${c.title} ${c.masteryPct}%${c.weak ? " ⚡薄弱" : ""}`).join(" · ")}`);
|
|
9574
|
-
if (snap.friction.length > 0) lines.push(`近期卡点(共 ${snap.friction.length} 条): ${snap.friction.map((f) => `${f.category}${f.summary === null ? "" : `: ${f.summary}`}`).join(" / ")}`);
|
|
10136
|
+
if (snap.concepts !== null && snap.concepts.length > 0) lines.push(`知识点(课级掌握度 = 最薄弱知识点): ${snap.concepts.map((c) => `${sanitizePromptText(c.title)} ${c.masteryPct}%${c.weak ? " ⚡薄弱" : ""}`).join(" · ")}`);
|
|
10137
|
+
if (snap.friction.length > 0) lines.push(`近期卡点(共 ${snap.friction.length} 条): ${snap.friction.map((f) => `${f.category}${f.summary === null ? "" : `: ${sanitizePromptText(f.summary, 80)}`}`).join(" / ")}`);
|
|
9575
10138
|
const memory = [
|
|
9576
|
-
snap.memoryGlobal === null ? "" : `整体: ${snap.memoryGlobal}`,
|
|
9577
|
-
snap.memoryLesson === null ? "" : `本课: ${snap.memoryLesson}`,
|
|
9578
|
-
snap.memoryPattern === null ? "" : `模式: ${snap.memoryPattern}`
|
|
10139
|
+
snap.memoryGlobal === null ? "" : `整体: ${sanitizePromptText(snap.memoryGlobal, 200)}`,
|
|
10140
|
+
snap.memoryLesson === null ? "" : `本课: ${sanitizePromptText(snap.memoryLesson, 200)}`,
|
|
10141
|
+
snap.memoryPattern === null ? "" : `模式: ${sanitizePromptText(snap.memoryPattern, 200)}`
|
|
9579
10142
|
].filter(Boolean);
|
|
9580
10143
|
if (memory.length > 0) lines.push(`记忆: ${memory.join(" | ")}`);
|
|
9581
10144
|
if (snap.dueCount > 0) lines.push(`今日待复习: ${snap.dueCount} 项`);
|
|
9582
|
-
if (snap.pendingProposal !== null) lines.push(`待决提案 ${snap.pendingProposal.id}: ${snap.pendingProposal.rationale}(等学习者表态)`);
|
|
10145
|
+
if (snap.pendingProposal !== null) lines.push(`待决提案 ${snap.pendingProposal.id}: ${sanitizePromptText(snap.pendingProposal.rationale, 160)}(等学习者表态)`);
|
|
9583
10146
|
const focusCourse = state.courses.find((c) => c.id === snap.focus?.courseId);
|
|
9584
10147
|
if (focusCourse?.languageTarget != null && focusCourse.languageTarget !== "") {
|
|
9585
10148
|
const t = localeToLanguageName(focusCourse.languageTarget);
|