dsh-plugin-lookatstudy 0.22.0 → 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 +827 -181
- 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,
|
|
@@ -4967,22 +5362,105 @@ function decodeHexString(s) {
|
|
|
4967
5362
|
for (const b of bytes) if (b >= 32 && b < 127) out += String.fromCharCode(b);
|
|
4968
5363
|
return out;
|
|
4969
5364
|
}
|
|
4970
|
-
/** Pull text out of one decoded content stream.
|
|
5365
|
+
/** Pull text out of one decoded content stream. Linear hand-rolled scan —
|
|
5366
|
+
* the original regex alternation backtracked catastrophically on real
|
|
5367
|
+
* papers' content streams (an unclosed `[` made the lazy TJ arm rescan
|
|
5368
|
+
* megabytes; found 2026-09-14 when the livetest refresh wedged the host for
|
|
5369
|
+
* 10+ CPU-minutes on a real arXiv PDF while even a watchdog timer could not
|
|
5370
|
+
* fire — the loop blocked the event loop synchronously). */
|
|
5371
|
+
function readLiteral(src, i) {
|
|
5372
|
+
let depth = 1;
|
|
5373
|
+
let j = i + 1;
|
|
5374
|
+
let raw = "";
|
|
5375
|
+
while (j < src.length) {
|
|
5376
|
+
const c = src[j];
|
|
5377
|
+
if (c === "\\") {
|
|
5378
|
+
raw += src.slice(j, j + 2);
|
|
5379
|
+
j += 2;
|
|
5380
|
+
continue;
|
|
5381
|
+
}
|
|
5382
|
+
if (c === "(") {
|
|
5383
|
+
depth++;
|
|
5384
|
+
raw += c;
|
|
5385
|
+
j++;
|
|
5386
|
+
continue;
|
|
5387
|
+
}
|
|
5388
|
+
if (c === ")") {
|
|
5389
|
+
depth--;
|
|
5390
|
+
if (depth === 0) {
|
|
5391
|
+
j++;
|
|
5392
|
+
break;
|
|
5393
|
+
}
|
|
5394
|
+
raw += c;
|
|
5395
|
+
j++;
|
|
5396
|
+
continue;
|
|
5397
|
+
}
|
|
5398
|
+
raw += c;
|
|
5399
|
+
j++;
|
|
5400
|
+
}
|
|
5401
|
+
return {
|
|
5402
|
+
text: decodePdfString(raw),
|
|
5403
|
+
next: j
|
|
5404
|
+
};
|
|
5405
|
+
}
|
|
5406
|
+
function readHex(src, i) {
|
|
5407
|
+
const close = src.indexOf(">", i);
|
|
5408
|
+
if (close === -1) return {
|
|
5409
|
+
text: decodeHexString(src.slice(i + 1)),
|
|
5410
|
+
next: src.length
|
|
5411
|
+
};
|
|
5412
|
+
return {
|
|
5413
|
+
text: decodeHexString(src.slice(i + 1, close)),
|
|
5414
|
+
next: close + 1
|
|
5415
|
+
};
|
|
5416
|
+
}
|
|
5417
|
+
function skipWs(src, i) {
|
|
5418
|
+
while (i < src.length && /\s/.test(src[i])) i++;
|
|
5419
|
+
return i;
|
|
5420
|
+
}
|
|
5421
|
+
function readWord(src, i) {
|
|
5422
|
+
let j = i;
|
|
5423
|
+
while (j < src.length && /[A-Za-z0-9*'"]/.test(src[j])) j++;
|
|
5424
|
+
return src.slice(i, j);
|
|
5425
|
+
}
|
|
4971
5426
|
function textFromContentStream(content) {
|
|
4972
5427
|
if (!/BT[\s\S]*?ET/.test(content)) return "";
|
|
4973
5428
|
let out = "";
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
while (
|
|
4977
|
-
|
|
4978
|
-
if (
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
5429
|
+
let i = 0;
|
|
5430
|
+
const n = content.length;
|
|
5431
|
+
while (i < n) {
|
|
5432
|
+
const c = content[i];
|
|
5433
|
+
if (c === "[") {
|
|
5434
|
+
const parts = [];
|
|
5435
|
+
let j = i + 1;
|
|
5436
|
+
while (j < n && content[j] !== "]") {
|
|
5437
|
+
const d = content[j];
|
|
5438
|
+
if (d === "(") {
|
|
5439
|
+
const r = readLiteral(content, j);
|
|
5440
|
+
parts.push(r.text);
|
|
5441
|
+
j = r.next;
|
|
5442
|
+
} else if (d === "<") {
|
|
5443
|
+
const r = readHex(content, j);
|
|
5444
|
+
parts.push(r.text);
|
|
5445
|
+
j = r.next;
|
|
5446
|
+
} else j++;
|
|
5447
|
+
}
|
|
5448
|
+
if (j >= n) break;
|
|
5449
|
+
if (readWord(content, skipWs(content, j + 1)) === "TJ") out += parts.join("");
|
|
5450
|
+
i = j + 1;
|
|
5451
|
+
continue;
|
|
5452
|
+
}
|
|
5453
|
+
if (c === "(" || c === "<") {
|
|
5454
|
+
const r = c === "(" ? readLiteral(content, i) : readHex(content, i);
|
|
5455
|
+
const op = readWord(content, skipWs(content, r.next));
|
|
5456
|
+
if (op === "Tj" || op === "'" || op === "\"") {
|
|
5457
|
+
out += r.text;
|
|
5458
|
+
if (op !== "Tj") out += "\n";
|
|
5459
|
+
}
|
|
5460
|
+
i = r.next;
|
|
5461
|
+
continue;
|
|
5462
|
+
}
|
|
5463
|
+
i++;
|
|
4986
5464
|
}
|
|
4987
5465
|
return out;
|
|
4988
5466
|
}
|
|
@@ -5422,10 +5900,18 @@ async function fetchBilibiliSubtitles(url, fetchFn, signal, opts) {
|
|
|
5422
5900
|
missing.push(t.name);
|
|
5423
5901
|
continue;
|
|
5424
5902
|
}
|
|
5425
|
-
const
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
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
|
+
}
|
|
5429
5915
|
let text = "";
|
|
5430
5916
|
try {
|
|
5431
5917
|
text = (JSON.parse(raw).body ?? []).map((b) => (b.content ?? "").trim()).filter(Boolean).join("");
|
|
@@ -5743,6 +6229,8 @@ function extractHeadings(content) {
|
|
|
5743
6229
|
function findTitleIndex(headings, anchor) {
|
|
5744
6230
|
const anchorClean = anchor.replace(/^#{1,3}\s+/, "").toLowerCase().trim();
|
|
5745
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]);
|
|
5746
6234
|
for (let i = 0; i < headings.length; i++) {
|
|
5747
6235
|
const titleLower = headings[i].title.toLowerCase();
|
|
5748
6236
|
if (titleLower.includes(anchorClean) || anchorClean.includes(titleLower)) return i;
|
|
@@ -6017,8 +6505,15 @@ function sanitizeQuiz(raw) {
|
|
|
6017
6505
|
list.slice(0, MAX_QUESTIONS).forEach((q, index) => {
|
|
6018
6506
|
const item = q ?? {};
|
|
6019
6507
|
const prompt = typeof item.prompt === "string" ? item.prompt.trim() : "";
|
|
6020
|
-
const
|
|
6021
|
-
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;
|
|
6022
6517
|
const explanation = typeof item.explanation === "string" ? item.explanation.trim() : "";
|
|
6023
6518
|
if (prompt === "") {
|
|
6024
6519
|
warnings.push(`question ${index + 1}: empty prompt dropped`);
|
|
@@ -6359,6 +6854,12 @@ function signalFetch(signal, baseFetch) {
|
|
|
6359
6854
|
signal
|
|
6360
6855
|
});
|
|
6361
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;
|
|
6362
6863
|
/** Total over a missing `meta` (events logged before a presentationMeta existed): renders nothing instead of throwing into the presenter fallback. */
|
|
6363
6864
|
const textBlocks = (lines) => (lines ?? []).map((text) => ({
|
|
6364
6865
|
type: "text",
|
|
@@ -6381,6 +6882,8 @@ function studyTools(store, deps = {}) {
|
|
|
6381
6882
|
let pendingDesign = null;
|
|
6382
6883
|
/** Which context-budget part of the pending brief the tutor last asked for. */
|
|
6383
6884
|
let pendingPart = 1;
|
|
6885
|
+
/** Audit B7: outstanding delete confirmations (courseId → token + expiry). */
|
|
6886
|
+
const pendingDeletes = /* @__PURE__ */ new Map();
|
|
6384
6887
|
/** Run a mutating state operation and persist. */
|
|
6385
6888
|
const mutate = (fn) => {
|
|
6386
6889
|
const result = fn(store.get());
|
|
@@ -6417,13 +6920,11 @@ function studyTools(store, deps = {}) {
|
|
|
6417
6920
|
}
|
|
6418
6921
|
}
|
|
6419
6922
|
} };
|
|
6420
|
-
const
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
})
|
|
6426
|
-
};
|
|
6923
|
+
const importPresentationMeta = (_args, value) => importLines(value);
|
|
6924
|
+
const importPresent = { presentResult: (_args, result) => ({
|
|
6925
|
+
card: "generic",
|
|
6926
|
+
content: textBlocks(result.meta)
|
|
6927
|
+
}) };
|
|
6427
6928
|
const importMarkdown = defineTool({
|
|
6428
6929
|
name: "study_import_markdown",
|
|
6429
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.",
|
|
@@ -6440,6 +6941,7 @@ function studyTools(store, deps = {}) {
|
|
|
6440
6941
|
},
|
|
6441
6942
|
output: {
|
|
6442
6943
|
...importOutput,
|
|
6944
|
+
presentationMeta: importPresentationMeta,
|
|
6443
6945
|
render: (_args, value) => [{
|
|
6444
6946
|
type: "text",
|
|
6445
6947
|
text: `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
|
|
@@ -6522,6 +7024,10 @@ function studyTools(store, deps = {}) {
|
|
|
6522
7024
|
type: "integer",
|
|
6523
7025
|
required: true
|
|
6524
7026
|
},
|
|
7027
|
+
designSeq: {
|
|
7028
|
+
type: "integer",
|
|
7029
|
+
description: "Pending-design sequence (audit C29): renders detect stale briefs."
|
|
7030
|
+
},
|
|
6525
7031
|
part: {
|
|
6526
7032
|
type: "integer",
|
|
6527
7033
|
description: "Brief part this call rendered (context-budget batching)."
|
|
@@ -6533,17 +7039,23 @@ function studyTools(store, deps = {}) {
|
|
|
6533
7039
|
}
|
|
6534
7040
|
}] } };
|
|
6535
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
|
+
};
|
|
6536
7050
|
const designOrImportedRender = (_args, value) => [{
|
|
6537
7051
|
type: "text",
|
|
6538
|
-
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}).`
|
|
6539
7053
|
}];
|
|
6540
|
-
const
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
})
|
|
6546
|
-
};
|
|
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
|
+
}) };
|
|
6547
7059
|
const importFolder = defineTool({
|
|
6548
7060
|
name: "study_import_folder",
|
|
6549
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.",
|
|
@@ -6564,6 +7076,7 @@ function studyTools(store, deps = {}) {
|
|
|
6564
7076
|
},
|
|
6565
7077
|
output: {
|
|
6566
7078
|
...designOrImportedOutput,
|
|
7079
|
+
presentationMeta: designOrImportedPresentationMeta,
|
|
6567
7080
|
render: designOrImportedRender
|
|
6568
7081
|
},
|
|
6569
7082
|
async execute(args) {
|
|
@@ -6578,10 +7091,11 @@ function studyTools(store, deps = {}) {
|
|
|
6578
7091
|
const docs = inventory.docs;
|
|
6579
7092
|
if (docs.length === 0) throw new Error(`lookatstudy-plugin: no importable files found in ${args.path}`);
|
|
6580
7093
|
const title = args.title ?? basename(args.path.replaceAll("\\", "/"));
|
|
6581
|
-
|
|
7094
|
+
const pd = buildPendingDesignFromFolder(args.path, title, docs, {
|
|
6582
7095
|
translations: inventory.translations,
|
|
6583
7096
|
images: inventory.images
|
|
6584
7097
|
});
|
|
7098
|
+
setPendingDesign(pd);
|
|
6585
7099
|
const localImages = /* @__PURE__ */ new Map();
|
|
6586
7100
|
for (const img of inventory.images) {
|
|
6587
7101
|
if (img.absPath === "" || img.source === "pdf_page") continue;
|
|
@@ -6591,9 +7105,9 @@ function studyTools(store, deps = {}) {
|
|
|
6591
7105
|
localImages.set(img.path, `data:${img.mime};base64,${buf.toString("base64")}`);
|
|
6592
7106
|
} catch {}
|
|
6593
7107
|
}
|
|
6594
|
-
if (localImages.size > 0)
|
|
7108
|
+
if (localImages.size > 0) pd.localImages = localImages;
|
|
6595
7109
|
pendingPart = part;
|
|
6596
|
-
return designRequiredValue(
|
|
7110
|
+
return designRequiredValue(pd, part);
|
|
6597
7111
|
},
|
|
6598
7112
|
timeoutMs: 6e4,
|
|
6599
7113
|
presentCall: (args) => ({
|
|
@@ -6611,6 +7125,7 @@ function studyTools(store, deps = {}) {
|
|
|
6611
7125
|
pd.partCount = partCount;
|
|
6612
7126
|
return {
|
|
6613
7127
|
status: "design_required",
|
|
7128
|
+
designSeq,
|
|
6614
7129
|
repo: pd.repo,
|
|
6615
7130
|
branch: pd.branch,
|
|
6616
7131
|
courseTitle: partCount > 1 ? `${pd.courseTitle} (part ${part})` : pd.courseTitle,
|
|
@@ -6626,18 +7141,20 @@ function studyTools(store, deps = {}) {
|
|
|
6626
7141
|
* study_import_url's github branch (routing is the only difference). */
|
|
6627
7142
|
const runGithubImport = async (url, branch, exec, part = 1) => {
|
|
6628
7143
|
const { owner, repo } = parseGithubUrl(url);
|
|
7144
|
+
const canonicalRef = `https://github.com/${owner}/${repo}`;
|
|
6629
7145
|
const resolvedBranch = branch ?? "main";
|
|
6630
7146
|
const fetchFn = signalFetch(exec.signal, baseFetch);
|
|
6631
|
-
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;
|
|
6632
7148
|
if (existing !== void 0) return {
|
|
6633
7149
|
status: "imported",
|
|
6634
7150
|
...toImportValue(existing)
|
|
6635
7151
|
};
|
|
6636
7152
|
const inventory = await fetchRepoInventory(owner, repo, resolvedBranch, fetchFn, void 0, exec.signal);
|
|
6637
|
-
|
|
6638
|
-
|
|
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?)");
|
|
6639
7156
|
pendingPart = part;
|
|
6640
|
-
return designRequiredValue(
|
|
7157
|
+
return designRequiredValue(pd, part);
|
|
6641
7158
|
};
|
|
6642
7159
|
const importGithub = defineTool({
|
|
6643
7160
|
name: "study_import_github",
|
|
@@ -6659,6 +7176,7 @@ function studyTools(store, deps = {}) {
|
|
|
6659
7176
|
},
|
|
6660
7177
|
output: {
|
|
6661
7178
|
...designOrImportedOutput,
|
|
7179
|
+
presentationMeta: designOrImportedPresentationMeta,
|
|
6662
7180
|
render: designOrImportedRender
|
|
6663
7181
|
},
|
|
6664
7182
|
execute: (args, exec) => runGithubImport(args.url, args.branch, exec, args.part ?? 1),
|
|
@@ -6686,6 +7204,7 @@ function studyTools(store, deps = {}) {
|
|
|
6686
7204
|
},
|
|
6687
7205
|
output: {
|
|
6688
7206
|
...designOrImportedOutput,
|
|
7207
|
+
presentationMeta: designOrImportedPresentationMeta,
|
|
6689
7208
|
render: designOrImportedRender
|
|
6690
7209
|
},
|
|
6691
7210
|
async execute(args, exec) {
|
|
@@ -6711,26 +7230,28 @@ function studyTools(store, deps = {}) {
|
|
|
6711
7230
|
|
|
6712
7231
|
${p.text}`));
|
|
6713
7232
|
if (docs.length === 0) throw new Error("lookatstudy-plugin: CC subtitle text was empty after chunking");
|
|
6714
|
-
|
|
7233
|
+
const pd = buildPendingDesignFromUrl(identity, cc.title, docs);
|
|
7234
|
+
setPendingDesign(pd);
|
|
6715
7235
|
pendingPart = part;
|
|
6716
|
-
return designRequiredValue(
|
|
7236
|
+
return designRequiredValue(pd, part);
|
|
6717
7237
|
}
|
|
6718
7238
|
throw new Error(`lookatstudy-plugin: ${route.url} 是视频链接(YouTube/抖音需要 yt-dlp,插件环境不可用)。请让学习者把字幕/文稿粘贴进来,用 study_import_markdown 导入。`);
|
|
6719
7239
|
}
|
|
6720
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
|
+
};
|
|
6721
7247
|
const text = parsePdfText(await downloadToBuffer(route.pdfUrl, fetchFn, { signal: exec.signal }));
|
|
6722
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");
|
|
6723
7249
|
const docs = prepareSingleDoc(`arxiv-${route.arxivId}`, `# arXiv:${route.arxivId}\n\n${text}`);
|
|
6724
7250
|
if (docs.length === 0) throw new Error("lookatstudy-plugin: arXiv PDF text was empty after chunking");
|
|
6725
|
-
const
|
|
6726
|
-
|
|
6727
|
-
if (existing !== void 0) return {
|
|
6728
|
-
status: "imported",
|
|
6729
|
-
...toImportValue(existing)
|
|
6730
|
-
};
|
|
6731
|
-
pendingDesign = buildPendingDesignFromUrl(route.url, `arXiv:${route.arxivId}`, docs);
|
|
7251
|
+
const pd = buildPendingDesignFromUrl(route.url, `arXiv:${route.arxivId}`, docs);
|
|
7252
|
+
setPendingDesign(pd);
|
|
6732
7253
|
pendingPart = part;
|
|
6733
|
-
return designRequiredValue(
|
|
7254
|
+
return designRequiredValue(pd, part);
|
|
6734
7255
|
}
|
|
6735
7256
|
const part = args.part ?? 1;
|
|
6736
7257
|
const identity = normalizeUrlIdentity(route.url);
|
|
@@ -6739,16 +7260,18 @@ ${p.text}`));
|
|
|
6739
7260
|
status: "imported",
|
|
6740
7261
|
...toImportValue(existing)
|
|
6741
7262
|
};
|
|
6742
|
-
const
|
|
6743
|
-
|
|
6744
|
-
|
|
7263
|
+
const html = await guardedFetchText(route.url, {
|
|
7264
|
+
fetchImpl: fetchFn,
|
|
7265
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; LookatStudyPlugin/0.9)" }
|
|
7266
|
+
});
|
|
6745
7267
|
const article = extractArticle(html, route.url);
|
|
6746
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`);
|
|
6747
7269
|
const docs = prepareSingleDoc(article.title.slice(0, 60), article.markdown);
|
|
6748
7270
|
if (docs.length === 0) throw new Error("lookatstudy-plugin: article body was empty after chunking");
|
|
6749
|
-
|
|
7271
|
+
const pd = buildPendingDesignFromUrl(identity, article.title, docs);
|
|
7272
|
+
setPendingDesign(pd);
|
|
6750
7273
|
pendingPart = part;
|
|
6751
|
-
return designRequiredValue(
|
|
7274
|
+
return designRequiredValue(pd, part);
|
|
6752
7275
|
},
|
|
6753
7276
|
timeoutMs: 18e4,
|
|
6754
7277
|
presentCall: (args) => ({
|
|
@@ -6833,11 +7356,11 @@ ${p.text}`));
|
|
|
6833
7356
|
required: true
|
|
6834
7357
|
},
|
|
6835
7358
|
firstLessonId: {
|
|
6836
|
-
type: "string",
|
|
7359
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
6837
7360
|
required: true
|
|
6838
7361
|
},
|
|
6839
7362
|
firstLessonTitle: {
|
|
6840
|
-
type: "string",
|
|
7363
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
6841
7364
|
required: true
|
|
6842
7365
|
},
|
|
6843
7366
|
droppedLessons: {
|
|
@@ -6894,7 +7417,7 @@ ${p.text}`));
|
|
|
6894
7417
|
created: state.courses.length > before
|
|
6895
7418
|
};
|
|
6896
7419
|
});
|
|
6897
|
-
|
|
7420
|
+
setPendingDesign(null);
|
|
6898
7421
|
pendingPart = 1;
|
|
6899
7422
|
return {
|
|
6900
7423
|
...value,
|
|
@@ -6908,7 +7431,6 @@ ${p.text}`));
|
|
|
6908
7431
|
title: "Apply course design",
|
|
6909
7432
|
kind: "edit"
|
|
6910
7433
|
}),
|
|
6911
|
-
presentationMeta: (_args, value) => [...importLines(value), ...value.droppedLessons > 0 ? [`${value.droppedLessons} dropped (files outside the brief)`] : []],
|
|
6912
7434
|
presentResult: (_args, result) => ({
|
|
6913
7435
|
card: "generic",
|
|
6914
7436
|
content: textBlocks(result.meta)
|
|
@@ -7100,6 +7622,7 @@ ${p.text}`));
|
|
|
7100
7622
|
description: "Course id from an import result or study_courses."
|
|
7101
7623
|
} },
|
|
7102
7624
|
output: {
|
|
7625
|
+
presentationMeta: (_args, value) => mapLines(value),
|
|
7103
7626
|
schema: {
|
|
7104
7627
|
type: "object",
|
|
7105
7628
|
additionalProperties: false,
|
|
@@ -7202,7 +7725,6 @@ ${p.text}`));
|
|
|
7202
7725
|
title: `Course map: ${args.courseId}`,
|
|
7203
7726
|
kind: "read"
|
|
7204
7727
|
}),
|
|
7205
|
-
presentationMeta: (_args, value) => mapLines(value),
|
|
7206
7728
|
presentResult: (_args, result) => ({
|
|
7207
7729
|
card: "generic",
|
|
7208
7730
|
content: textBlocks(result.meta)
|
|
@@ -7419,12 +7941,13 @@ ${p.text}`));
|
|
|
7419
7941
|
},
|
|
7420
7942
|
render: (_args, value) => [{
|
|
7421
7943
|
type: "text",
|
|
7422
|
-
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})`}`
|
|
7423
7945
|
}]
|
|
7424
7946
|
},
|
|
7425
7947
|
async execute(args) {
|
|
7426
7948
|
return mutate((state) => {
|
|
7427
|
-
const
|
|
7949
|
+
const ref = findLesson(state, args.lessonId);
|
|
7950
|
+
if (ref.lesson.kind !== "exam") attemptLesson(state, args.lessonId, /* @__PURE__ */ new Date());
|
|
7428
7951
|
state.focus = { lessonId: ref.lesson.id };
|
|
7429
7952
|
return toLessonValue(ref, state);
|
|
7430
7953
|
});
|
|
@@ -7467,6 +7990,7 @@ ${p.text}`));
|
|
|
7467
7990
|
}
|
|
7468
7991
|
},
|
|
7469
7992
|
output: {
|
|
7993
|
+
presentationMeta: (_args, value) => [answerLine(value)],
|
|
7470
7994
|
schema: {
|
|
7471
7995
|
type: "object",
|
|
7472
7996
|
additionalProperties: false,
|
|
@@ -7577,7 +8101,6 @@ ${p.text}`));
|
|
|
7577
8101
|
card: "generic",
|
|
7578
8102
|
title: `Record answer (${args.correct ? "correct" : "incorrect"}): ${args.lessonId}`
|
|
7579
8103
|
}),
|
|
7580
|
-
presentationMeta: (_args, value) => [answerLine(value)],
|
|
7581
8104
|
presentResult: (_args, result) => ({
|
|
7582
8105
|
card: "generic",
|
|
7583
8106
|
content: textBlocks(result.meta)
|
|
@@ -7604,6 +8127,7 @@ ${p.text}`));
|
|
|
7604
8127
|
}
|
|
7605
8128
|
},
|
|
7606
8129
|
output: {
|
|
8130
|
+
presentationMeta: (_args, value) => [`${value.stars}★`],
|
|
7607
8131
|
schema: {
|
|
7608
8132
|
type: "object",
|
|
7609
8133
|
additionalProperties: false,
|
|
@@ -7669,7 +8193,7 @@ ${p.text}`));
|
|
|
7669
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}.`)
|
|
7670
8194
|
}]
|
|
7671
8195
|
},
|
|
7672
|
-
execute(args) {
|
|
8196
|
+
async execute(args) {
|
|
7673
8197
|
return mutate((state) => {
|
|
7674
8198
|
const r = recordExamResult(state, args.lessonId, args.correct, args.total);
|
|
7675
8199
|
const actions = getPostQuizActions({
|
|
@@ -7706,7 +8230,6 @@ ${p.text}`));
|
|
|
7706
8230
|
card: "generic",
|
|
7707
8231
|
title: `Exam result: ${args.correct}/${args.total}`
|
|
7708
8232
|
}),
|
|
7709
|
-
presentationMeta: (_args, value) => [`${value.stars}★`],
|
|
7710
8233
|
presentResult: (_args, result) => ({
|
|
7711
8234
|
card: "generic",
|
|
7712
8235
|
content: textBlocks(result.meta)
|
|
@@ -7714,7 +8237,7 @@ ${p.text}`));
|
|
|
7714
8237
|
});
|
|
7715
8238
|
const examBankApplyTool = defineTool({
|
|
7716
8239
|
name: "study_exam_bank_apply",
|
|
7717
|
-
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).",
|
|
7718
8241
|
parameters: {
|
|
7719
8242
|
lessonId: {
|
|
7720
8243
|
type: "string",
|
|
@@ -7758,6 +8281,7 @@ ${p.text}`));
|
|
|
7758
8281
|
}
|
|
7759
8282
|
},
|
|
7760
8283
|
output: {
|
|
8284
|
+
presentationMeta: (_args, value) => [`${value.questionCount}Q`],
|
|
7761
8285
|
schema: {
|
|
7762
8286
|
type: "object",
|
|
7763
8287
|
additionalProperties: false,
|
|
@@ -7785,7 +8309,7 @@ ${p.text}`));
|
|
|
7785
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 开始考试.`
|
|
7786
8310
|
}]
|
|
7787
8311
|
},
|
|
7788
|
-
execute(args) {
|
|
8312
|
+
async execute(args) {
|
|
7789
8313
|
return mutate((state) => {
|
|
7790
8314
|
const r = applyExamBank(state, args.lessonId, args.questions, /* @__PURE__ */ new Date());
|
|
7791
8315
|
return {
|
|
@@ -7800,7 +8324,6 @@ ${p.text}`));
|
|
|
7800
8324
|
card: "generic",
|
|
7801
8325
|
title: `Exam bank: ${Array.isArray(args.questions) ? String(args.questions.length) : "?"} questions`
|
|
7802
8326
|
}),
|
|
7803
|
-
presentationMeta: (_args, value) => [`${value.questionCount}Q`],
|
|
7804
8327
|
presentResult: (_args, result) => ({
|
|
7805
8328
|
card: "generic",
|
|
7806
8329
|
content: textBlocks(result.meta)
|
|
@@ -7815,6 +8338,7 @@ ${p.text}`));
|
|
|
7815
8338
|
description: "Lesson to complete."
|
|
7816
8339
|
} },
|
|
7817
8340
|
output: {
|
|
8341
|
+
presentationMeta: (_args, value) => completeLines(value),
|
|
7818
8342
|
schema: {
|
|
7819
8343
|
type: "object",
|
|
7820
8344
|
additionalProperties: false,
|
|
@@ -7869,7 +8393,6 @@ ${p.text}`));
|
|
|
7869
8393
|
card: "generic",
|
|
7870
8394
|
title: `Complete lesson: ${args.lessonId}`
|
|
7871
8395
|
}),
|
|
7872
|
-
presentationMeta: (_args, value) => completeLines(value),
|
|
7873
8396
|
presentResult: (_args, result) => ({
|
|
7874
8397
|
card: "generic",
|
|
7875
8398
|
content: textBlocks(result.meta)
|
|
@@ -7883,6 +8406,7 @@ ${p.text}`));
|
|
|
7883
8406
|
description: "Restrict to one course; omit to scan all courses."
|
|
7884
8407
|
} },
|
|
7885
8408
|
output: {
|
|
8409
|
+
presentationMeta: (_args, value) => dueLines(value),
|
|
7886
8410
|
schema: {
|
|
7887
8411
|
type: "object",
|
|
7888
8412
|
additionalProperties: false,
|
|
@@ -7944,7 +8468,6 @@ ${p.text}`));
|
|
|
7944
8468
|
title: "List due reviews",
|
|
7945
8469
|
kind: "search"
|
|
7946
8470
|
}),
|
|
7947
|
-
presentationMeta: (_args, value) => dueLines(value),
|
|
7948
8471
|
presentResult: (_args, result) => ({
|
|
7949
8472
|
card: "generic",
|
|
7950
8473
|
content: textBlocks(result.meta)
|
|
@@ -7967,6 +8490,7 @@ ${p.text}`));
|
|
|
7967
8490
|
}
|
|
7968
8491
|
},
|
|
7969
8492
|
output: {
|
|
8493
|
+
presentationMeta: (_args, value) => [reviewLine(value)],
|
|
7970
8494
|
schema: {
|
|
7971
8495
|
type: "object",
|
|
7972
8496
|
additionalProperties: false,
|
|
@@ -8024,7 +8548,6 @@ ${p.text}`));
|
|
|
8024
8548
|
card: "generic",
|
|
8025
8549
|
title: `Record review (quality ${args.quality}): ${args.lessonId}`
|
|
8026
8550
|
}),
|
|
8027
|
-
presentationMeta: (_args, value) => [reviewLine(value)],
|
|
8028
8551
|
presentResult: (_args, result) => ({
|
|
8029
8552
|
card: "generic",
|
|
8030
8553
|
content: textBlocks(result.meta)
|
|
@@ -8032,37 +8555,86 @@ ${p.text}`));
|
|
|
8032
8555
|
});
|
|
8033
8556
|
const deleteCourseTool = defineTool({
|
|
8034
8557
|
name: "study_delete_course",
|
|
8035
|
-
description: "Delete one course and all its progress
|
|
8036
|
-
parameters: {
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
8040
|
-
|
|
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
|
+
},
|
|
8041
8570
|
output: {
|
|
8042
8571
|
schema: {
|
|
8043
8572
|
type: "object",
|
|
8044
8573
|
additionalProperties: false,
|
|
8045
8574
|
properties: {
|
|
8046
|
-
|
|
8575
|
+
status: {
|
|
8576
|
+
type: "string",
|
|
8577
|
+
required: true,
|
|
8578
|
+
description: "confirm_required | deleted"
|
|
8579
|
+
},
|
|
8580
|
+
courseId: {
|
|
8047
8581
|
type: "string",
|
|
8048
8582
|
required: true
|
|
8049
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
|
+
},
|
|
8050
8600
|
remaining: {
|
|
8051
8601
|
type: "integer",
|
|
8052
|
-
|
|
8602
|
+
description: "Present on deleted."
|
|
8053
8603
|
}
|
|
8054
8604
|
}
|
|
8055
8605
|
},
|
|
8056
|
-
render: (_args, value) => [{
|
|
8606
|
+
render: (_args, value) => value.status === "confirm_required" ? [{
|
|
8607
|
+
type: "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
|
+
}] : [{
|
|
8057
8610
|
type: "text",
|
|
8058
|
-
text: `Deleted course ${value.deletedCourseId}. ${value.remaining} courses remain.`
|
|
8611
|
+
text: `Deleted course ${value.deletedCourseId}. ${value.remaining} courses remain. Recoverable from the trash via study_restore_course.`
|
|
8059
8612
|
}]
|
|
8060
8613
|
},
|
|
8061
8614
|
async execute(args) {
|
|
8062
8615
|
return mutate((state) => {
|
|
8063
|
-
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);
|
|
8064
8634
|
deleteCourse(state, args.courseId);
|
|
8065
8635
|
return {
|
|
8636
|
+
status: "deleted",
|
|
8637
|
+
courseId: args.courseId,
|
|
8066
8638
|
deletedCourseId: args.courseId,
|
|
8067
8639
|
remaining: state.courses.length
|
|
8068
8640
|
};
|
|
@@ -8075,6 +8647,59 @@ ${p.text}`));
|
|
|
8075
8647
|
rawInput: args.courseId
|
|
8076
8648
|
})
|
|
8077
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
|
+
});
|
|
8078
8703
|
const defineConceptsTool = defineTool({
|
|
8079
8704
|
name: "study_define_concepts",
|
|
8080
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.",
|
|
@@ -8178,6 +8803,12 @@ ${p.text}`));
|
|
|
8178
8803
|
}
|
|
8179
8804
|
},
|
|
8180
8805
|
output: {
|
|
8806
|
+
presentationMeta: (_args, value) => ({
|
|
8807
|
+
kind: "study-proposal-created",
|
|
8808
|
+
proposalId: value.proposalId,
|
|
8809
|
+
lessonTitle: value.lessonTitle,
|
|
8810
|
+
rationale: value.rationale
|
|
8811
|
+
}),
|
|
8181
8812
|
schema: {
|
|
8182
8813
|
type: "object",
|
|
8183
8814
|
additionalProperties: false,
|
|
@@ -8226,12 +8857,6 @@ ${p.text}`));
|
|
|
8226
8857
|
card: "generic",
|
|
8227
8858
|
title: `Propose mastery: ${args.lessonId}`
|
|
8228
8859
|
}),
|
|
8229
|
-
presentationMeta: (_args, value) => ({
|
|
8230
|
-
kind: "study-proposal-created",
|
|
8231
|
-
proposalId: value.proposalId,
|
|
8232
|
-
lessonTitle: value.lessonTitle,
|
|
8233
|
-
rationale: value.rationale
|
|
8234
|
-
}),
|
|
8235
8860
|
presentResult: (_args, result) => ({
|
|
8236
8861
|
card: "generic",
|
|
8237
8862
|
content: textBlocks([`🎓 Proposed mastery for “${result.meta?.lessonTitle ?? "lesson"}”: ${result.meta?.rationale ?? ""}`])
|
|
@@ -8253,6 +8878,11 @@ ${p.text}`));
|
|
|
8253
8878
|
}
|
|
8254
8879
|
},
|
|
8255
8880
|
output: {
|
|
8881
|
+
presentationMeta: (_args, value) => ({
|
|
8882
|
+
kind: "study-proposal-resolved",
|
|
8883
|
+
proposalId: value.proposalId,
|
|
8884
|
+
status: value.status
|
|
8885
|
+
}),
|
|
8256
8886
|
schema: {
|
|
8257
8887
|
type: "object",
|
|
8258
8888
|
additionalProperties: false,
|
|
@@ -8291,11 +8921,6 @@ ${p.text}`));
|
|
|
8291
8921
|
card: "generic",
|
|
8292
8922
|
title: `Resolve proposal: ${args.proposalId}`
|
|
8293
8923
|
}),
|
|
8294
|
-
presentationMeta: (_args, value) => ({
|
|
8295
|
-
kind: "study-proposal-resolved",
|
|
8296
|
-
proposalId: value.proposalId,
|
|
8297
|
-
status: value.status
|
|
8298
|
-
}),
|
|
8299
8924
|
presentResult: (_args, result) => ({
|
|
8300
8925
|
card: "generic",
|
|
8301
8926
|
content: textBlocks([`Proposal ${result.meta?.proposalId ?? "?"} ${result.meta?.status ?? ""}.`])
|
|
@@ -8444,7 +9069,7 @@ ${p.text}`));
|
|
|
8444
9069
|
text: `Translation stored for “${value.lessonTitle}” (${value.lang}, ${value.chars} chars) — the blackboard now interleaves the original with it.`
|
|
8445
9070
|
}]
|
|
8446
9071
|
},
|
|
8447
|
-
execute(args) {
|
|
9072
|
+
async execute(args) {
|
|
8448
9073
|
return mutate((state) => {
|
|
8449
9074
|
const ref = findLesson(state, args.lessonId);
|
|
8450
9075
|
if (args.markdown.trim() === "") throw new Error("lookatstudy-plugin: translation markdown is empty");
|
|
@@ -8534,7 +9159,7 @@ ${p.text}`));
|
|
|
8534
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.")
|
|
8535
9160
|
}]
|
|
8536
9161
|
},
|
|
8537
|
-
execute() {
|
|
9162
|
+
async execute() {
|
|
8538
9163
|
return mutate((state) => {
|
|
8539
9164
|
const window = gatherConsolidationWindow(state);
|
|
8540
9165
|
const watermark = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -8584,26 +9209,30 @@ ${p.text}`));
|
|
|
8584
9209
|
markdown: {
|
|
8585
9210
|
type: "string",
|
|
8586
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."
|
|
8587
9216
|
}
|
|
8588
9217
|
}
|
|
8589
9218
|
},
|
|
8590
9219
|
render: (_args, value) => [{
|
|
8591
9220
|
type: "text",
|
|
8592
|
-
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.
|
|
8593
|
-
|
|
8594
|
-
` + 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
|
|
8595
9222
|
}]
|
|
8596
9223
|
},
|
|
8597
|
-
execute(args) {
|
|
9224
|
+
async execute(args) {
|
|
8598
9225
|
const course = findCourse(store.get(), args.courseId);
|
|
8599
|
-
const
|
|
9226
|
+
const full = courseToPackMarkdown(course);
|
|
8600
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;
|
|
8601
9229
|
return {
|
|
8602
9230
|
courseId: course.id,
|
|
8603
9231
|
title: course.title,
|
|
8604
9232
|
lessonCount,
|
|
8605
|
-
chars:
|
|
8606
|
-
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
|
|
8607
9236
|
};
|
|
8608
9237
|
},
|
|
8609
9238
|
isConcurrencySafe: () => true,
|
|
@@ -8936,24 +9565,28 @@ ${p.text}`));
|
|
|
8936
9565
|
title: `Generate practice card: ${typeof args.title === "string" ? args.title : "练习"} (${args.questions?.length ?? 0} questions)`
|
|
8937
9566
|
})
|
|
8938
9567
|
});
|
|
8939
|
-
/** Record one sanitized artifact on its lesson (shared by the artifact tools).
|
|
8940
|
-
|
|
8941
|
-
|
|
8942
|
-
|
|
8943
|
-
|
|
8944
|
-
|
|
8945
|
-
|
|
8946
|
-
|
|
8947
|
-
|
|
8948
|
-
|
|
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
|
+
};
|
|
8949
9588
|
});
|
|
8950
|
-
|
|
8951
|
-
artifactType: type,
|
|
8952
|
-
artifactId: stored.artifact.id,
|
|
8953
|
-
created: stored.created,
|
|
8954
|
-
...stored.artifact.data
|
|
8955
|
-
};
|
|
8956
|
-
});
|
|
9589
|
+
};
|
|
8957
9590
|
return [
|
|
8958
9591
|
importMarkdown,
|
|
8959
9592
|
importFolder,
|
|
@@ -8970,6 +9603,7 @@ ${p.text}`));
|
|
|
8970
9603
|
dueReviewsTool,
|
|
8971
9604
|
recordReviewTool,
|
|
8972
9605
|
deleteCourseTool,
|
|
9606
|
+
restoreCourseTool,
|
|
8973
9607
|
defineConceptsTool,
|
|
8974
9608
|
proposeMasteryTool,
|
|
8975
9609
|
resolveProposalTool,
|
|
@@ -9475,6 +10109,18 @@ function soulText(state) {
|
|
|
9475
10109
|
return state.active ? SOULS[state.mode] : "";
|
|
9476
10110
|
}
|
|
9477
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
|
+
/**
|
|
9478
10124
|
* Render the learner snapshot (LookatStudy's per-turn volatile tail) as the
|
|
9479
10125
|
* dynamic runtime context: focus, strategy band, concepts with weak flags,
|
|
9480
10126
|
* recent friction, memory slots, due count, pending proposal. Dormant
|
|
@@ -9485,18 +10131,18 @@ function snapshotSectionText(state) {
|
|
|
9485
10131
|
const snap = learnerSnapshot(state, /* @__PURE__ */ new Date());
|
|
9486
10132
|
if (snap.focus === null) return snap.dueCount === 0 ? "" : `【学习者当前状态】\n今日待复习: ${snap.dueCount} 项(study_due_reviews)`;
|
|
9487
10133
|
const lines = ["【学习者当前状态】"];
|
|
9488
|
-
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}%`})`);
|
|
9489
10135
|
if (snap.strategy !== null) lines.push(`教学策略: ${snap.strategy}`);
|
|
9490
|
-
if (snap.concepts !== null && snap.concepts.length > 0) lines.push(`知识点(课级掌握度 = 最薄弱知识点): ${snap.concepts.map((c) => `${c.title} ${c.masteryPct}%${c.weak ? " ⚡薄弱" : ""}`).join(" · ")}`);
|
|
9491
|
-
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(" / ")}`);
|
|
9492
10138
|
const memory = [
|
|
9493
|
-
snap.memoryGlobal === null ? "" : `整体: ${snap.memoryGlobal}`,
|
|
9494
|
-
snap.memoryLesson === null ? "" : `本课: ${snap.memoryLesson}`,
|
|
9495
|
-
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)}`
|
|
9496
10142
|
].filter(Boolean);
|
|
9497
10143
|
if (memory.length > 0) lines.push(`记忆: ${memory.join(" | ")}`);
|
|
9498
10144
|
if (snap.dueCount > 0) lines.push(`今日待复习: ${snap.dueCount} 项`);
|
|
9499
|
-
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)}(等学习者表态)`);
|
|
9500
10146
|
const focusCourse = state.courses.find((c) => c.id === snap.focus?.courseId);
|
|
9501
10147
|
if (focusCourse?.languageTarget != null && focusCourse.languageTarget !== "") {
|
|
9502
10148
|
const t = localeToLanguageName(focusCourse.languageTarget);
|