dsh-plugin-lookatstudy 0.22.1 → 0.23.0
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 +475 -110
- 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 +883 -186
- 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": "导师思考中…",
|
|
@@ -231,6 +228,13 @@ const ZH = {
|
|
|
231
228
|
"review.banner": "正在复习:{lesson}",
|
|
232
229
|
"review.threadTitle": "复习:{lesson}",
|
|
233
230
|
"threads.label": "课时线程",
|
|
231
|
+
"threads.scope": "跨课时连续对话",
|
|
232
|
+
"threads.scope.on": "已开启",
|
|
233
|
+
"threads.scope.off": "未开启",
|
|
234
|
+
"threads.coverage": "覆盖",
|
|
235
|
+
"ask.title": "导师想先确认一下",
|
|
236
|
+
"ask.free": "在下方输入框直接回复即可",
|
|
237
|
+
"ask.answered": "已回答",
|
|
234
238
|
"meter.label": "上下文用量",
|
|
235
239
|
"meter.aria": "上下文已用 {pct}%",
|
|
236
240
|
"meter.brk.system": "系统提示",
|
|
@@ -400,7 +404,7 @@ const ZH = {
|
|
|
400
404
|
"exam.leave.message": "考试进行中——现在离开,未答的题将按错计分。",
|
|
401
405
|
"exam.leave.confirm": "离开并结算",
|
|
402
406
|
"exam.leave.cancel": "继续答题",
|
|
403
|
-
"prompt.exam.bank": "请为「{section}」的章节测验出题:先用
|
|
407
|
+
"prompt.exam.bank": "请为「{section}」的章节测验出题:先用 study_lesson 读一遍本节课时,再按各课时的知识点用 study_exam_bank_apply 一次性提交整份题库(单选 5-15 题,kcTitle 必须用本节的概念标题,每题附一句话解析)。题库就绪后告诉我可以开考。",
|
|
404
408
|
"prompt.review": "开始今天的复习,从最到期的课时开始",
|
|
405
409
|
"prompt.proposal.accept": "接受提案 {id} —— 确认标记这课为已掌握",
|
|
406
410
|
"prompt.proposal.decline": "拒绝提案 {id} —— 我想再练练"
|
|
@@ -445,7 +449,6 @@ const EN = {
|
|
|
445
449
|
"quiz.right": "Correct",
|
|
446
450
|
"quiz.wrong": "Wrong",
|
|
447
451
|
"quiz.youChose": "You chose",
|
|
448
|
-
"quiz.answer": "Answer",
|
|
449
452
|
"quiz.hook": "Practice card finished: {correct}/{total} correct. Please comment briefly and walk through the wrong ones.",
|
|
450
453
|
"quiz.action.explain-wrong": "Explain my wrong answers",
|
|
451
454
|
"quiz.action.retry": "Another set",
|
|
@@ -481,8 +484,6 @@ const EN = {
|
|
|
481
484
|
"review.nudge.go": "Review now",
|
|
482
485
|
"rail.search.jump": "Open this lesson",
|
|
483
486
|
"proposal.banner": "{lesson} is ready to graduate early",
|
|
484
|
-
"proposal.accept": "Accept",
|
|
485
|
-
"proposal.decline": "Keep practicing",
|
|
486
487
|
"proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
|
|
487
488
|
"proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
|
|
488
489
|
"tutor.thinking": "The tutor is thinking…",
|
|
@@ -582,6 +583,13 @@ const EN = {
|
|
|
582
583
|
"palette.kind.action": "Action",
|
|
583
584
|
"palette.empty": "No matches",
|
|
584
585
|
"threads.label": "Lesson threads",
|
|
586
|
+
"threads.scope": "Continuous course thread",
|
|
587
|
+
"threads.scope.on": "on",
|
|
588
|
+
"threads.scope.off": "off",
|
|
589
|
+
"threads.coverage": "covers",
|
|
590
|
+
"ask.title": "The tutor wants a quick confirm",
|
|
591
|
+
"ask.free": "Answer in the composer below",
|
|
592
|
+
"ask.answered": "answered",
|
|
585
593
|
"threads.open": "{n} threads",
|
|
586
594
|
"threads.now": "current",
|
|
587
595
|
"threads.new": "New thread (your next message starts it)",
|
|
@@ -763,7 +771,7 @@ const EN = {
|
|
|
763
771
|
"exam.leave.message": "The exam is in progress — leaving now counts unanswered questions as wrong.",
|
|
764
772
|
"exam.leave.confirm": "Leave & settle",
|
|
765
773
|
"exam.leave.cancel": "Keep answering",
|
|
766
|
-
"prompt.exam.bank": "Author the section exam bank for \"{section}\": read this section's lessons with
|
|
774
|
+
"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
775
|
"prompt.review": "Start today's review, from the most overdue lesson first",
|
|
768
776
|
"prompt.proposal.accept": "Accept proposal {id} — confirm marking this lesson as mastered",
|
|
769
777
|
"prompt.proposal.decline": "Reject proposal {id} — I want to practice more"
|
|
@@ -1072,7 +1080,7 @@ function coursePackMarkdown(course) {
|
|
|
1072
1080
|
for (const lesson of section.lessons) {
|
|
1073
1081
|
if (lesson.kind === "exam") continue;
|
|
1074
1082
|
out.push(`### ${lesson.title}`, "");
|
|
1075
|
-
const body = lesson.body.replace(/\s+$/, "");
|
|
1083
|
+
const body = lesson.body.replace(/\s+$/, "").split("\n").map((line) => /^#{1,3}(\s|$)/.test(line) ? `\\${line}` : line).join("\n");
|
|
1076
1084
|
if (body !== "") out.push(body, "");
|
|
1077
1085
|
}
|
|
1078
1086
|
}
|
|
@@ -1488,6 +1496,23 @@ function computeStreakTransition(state, now = /* @__PURE__ */ new Date()) {
|
|
|
1488
1496
|
};
|
|
1489
1497
|
}
|
|
1490
1498
|
//#endregion
|
|
1499
|
+
//#region src/thread-key.ts
|
|
1500
|
+
/** Resolve the thread-group map key for a send from `lessonId`. Absent scope
|
|
1501
|
+
* is legacy lesson behavior — old files never move. */
|
|
1502
|
+
function threadOwnerKey(scope, courseId, lessonId) {
|
|
1503
|
+
return scope === "course" ? `course:${courseId}` : lessonId;
|
|
1504
|
+
}
|
|
1505
|
+
/** True when a thread-group key names a course-scoped group. Disambiguated
|
|
1506
|
+
* by COLON COUNT, not by prefix alone: a course titled "Course" slugs to
|
|
1507
|
+
* `course`, and its lesson keys (`course:0:0`) live inside the naive
|
|
1508
|
+
* `course:` prefix space (caught live by the B7 trash test). Lesson ids are
|
|
1509
|
+
* always `<slug>:<si>:<li>` — three segments, since slugs strip the colon —
|
|
1510
|
+
* while a course key is exactly `course:<slug>`, two segments. */
|
|
1511
|
+
function isCourseOwnerKey(key) {
|
|
1512
|
+
if (!key.startsWith("course:")) return false;
|
|
1513
|
+
return !key.slice(7).includes(":");
|
|
1514
|
+
}
|
|
1515
|
+
//#endregion
|
|
1491
1516
|
//#region src/state.ts
|
|
1492
1517
|
/**
|
|
1493
1518
|
* Learning state: courses → sections → lessons with mastery-driven gating,
|
|
@@ -1513,10 +1538,12 @@ function emptyState() {
|
|
|
1513
1538
|
focus: null,
|
|
1514
1539
|
memoryGlobal: null,
|
|
1515
1540
|
memoryPatterns: {},
|
|
1541
|
+
frictionGlobal: [],
|
|
1516
1542
|
artifacts: {},
|
|
1517
1543
|
proposals: [],
|
|
1518
1544
|
lessonSessions: {},
|
|
1519
1545
|
lessonThreads: {},
|
|
1546
|
+
trash: [],
|
|
1520
1547
|
lastConsolidatedAt: null,
|
|
1521
1548
|
xp: {
|
|
1522
1549
|
total: 0,
|
|
@@ -1543,14 +1570,48 @@ function resolveStatePath(configured) {
|
|
|
1543
1570
|
return join(dshHome, "lookatstudy-plugin", "state.json");
|
|
1544
1571
|
}
|
|
1545
1572
|
/**
|
|
1546
|
-
* Load persisted state; a missing file yields empty state,
|
|
1573
|
+
* Load persisted state; a missing file yields empty state, and an unusable
|
|
1574
|
+
* file (torn bytes, future version) recovers from `.bak` or degrades to a
|
|
1575
|
+
* fresh state instead of throwing out of the host's apply() (audit A3).
|
|
1547
1576
|
* v1 → v2 migration: `completed` lessons become `mastered`, lessons gain `kind`
|
|
1548
|
-
* (default `study`).
|
|
1577
|
+
* (default `study`).
|
|
1549
1578
|
* @param path - state-file path.
|
|
1550
1579
|
* @returns the loaded state.
|
|
1551
1580
|
*/
|
|
1552
1581
|
function loadState(path) {
|
|
1553
|
-
if (
|
|
1582
|
+
if (existsSync(path)) try {
|
|
1583
|
+
return parseStateFile(path);
|
|
1584
|
+
} catch (error) {
|
|
1585
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1586
|
+
const backup = loadBackup(path);
|
|
1587
|
+
if (backup !== null) {
|
|
1588
|
+
console.error(`lookatstudy-plugin: state file unusable (${message}); recovered from ${path}.bak`);
|
|
1589
|
+
return backup;
|
|
1590
|
+
}
|
|
1591
|
+
console.error(`lookatstudy-plugin: state file unusable (${message}) and no backup survived — quarantining it and starting fresh (${path})`);
|
|
1592
|
+
quarantine(path);
|
|
1593
|
+
return emptyState();
|
|
1594
|
+
}
|
|
1595
|
+
return loadBackup(path) ?? emptyState();
|
|
1596
|
+
}
|
|
1597
|
+
function loadBackup(path) {
|
|
1598
|
+
const bak = `${path}.bak`;
|
|
1599
|
+
if (!existsSync(bak)) return null;
|
|
1600
|
+
try {
|
|
1601
|
+
return parseStateFile(bak);
|
|
1602
|
+
} catch {
|
|
1603
|
+
return null;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
function quarantine(path) {
|
|
1607
|
+
try {
|
|
1608
|
+
renameSync(path, `${path}.corrupt-${Date.now().toString(36)}`);
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
console.error(`lookatstudy-plugin: could not quarantine the unusable state file (${String(error)})`);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
/** Parse + migrate one state file; malformed bytes, shapes, and future versions throw. */
|
|
1614
|
+
function parseStateFile(path) {
|
|
1554
1615
|
let parsed;
|
|
1555
1616
|
try {
|
|
1556
1617
|
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
@@ -1588,6 +1649,7 @@ function loadState(path) {
|
|
|
1588
1649
|
focus: raw.focus ?? null,
|
|
1589
1650
|
memoryGlobal: raw.memoryGlobal ?? null,
|
|
1590
1651
|
memoryPatterns: raw.memoryPatterns ?? {},
|
|
1652
|
+
frictionGlobal: raw.frictionGlobal ?? [],
|
|
1591
1653
|
proposals: raw.proposals ?? [],
|
|
1592
1654
|
lessonSessions: raw.lessonSessions ?? {},
|
|
1593
1655
|
lessonThreads: (() => {
|
|
@@ -1620,6 +1682,7 @@ function loadState(path) {
|
|
|
1620
1682
|
return groups;
|
|
1621
1683
|
})(),
|
|
1622
1684
|
artifacts: raw.artifacts ?? {},
|
|
1685
|
+
trash: raw.trash ?? [],
|
|
1623
1686
|
lastConsolidatedAt: raw.lastConsolidatedAt ?? null,
|
|
1624
1687
|
xp: raw.xp ?? {
|
|
1625
1688
|
total: 0,
|
|
@@ -1635,15 +1698,41 @@ function loadState(path) {
|
|
|
1635
1698
|
};
|
|
1636
1699
|
}
|
|
1637
1700
|
/**
|
|
1638
|
-
* Persist state atomically
|
|
1701
|
+
* Persist state atomically. The tmp file carries pid + random bytes so
|
|
1702
|
+
* processes sharing one state.json (multi-profile installs) never interleave
|
|
1703
|
+
* on the same temp path — a fixed name let one writer rename the other's
|
|
1704
|
+
* half-written bytes into place; the previous generation is kept one deep as
|
|
1705
|
+
* `.bak` for loadState recovery; the rename retries through the Windows
|
|
1706
|
+
* AV/indexer EPERM window (audit A3).
|
|
1639
1707
|
* @param path - state-file path.
|
|
1640
1708
|
* @param state - state to persist.
|
|
1641
1709
|
*/
|
|
1642
1710
|
function saveState(path, state) {
|
|
1643
1711
|
mkdirSync(dirname(path), { recursive: true });
|
|
1644
|
-
const tmp = `${path}.tmp`;
|
|
1712
|
+
const tmp = `${path}.${process.pid}-${randomBytes(4).toString("hex")}.tmp`;
|
|
1645
1713
|
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
1646
|
-
|
|
1714
|
+
try {
|
|
1715
|
+
const fd = openSync(tmp, "r+");
|
|
1716
|
+
try {
|
|
1717
|
+
fsyncSync(fd);
|
|
1718
|
+
} finally {
|
|
1719
|
+
closeSync(fd);
|
|
1720
|
+
}
|
|
1721
|
+
} catch {}
|
|
1722
|
+
if (existsSync(path)) try {
|
|
1723
|
+
copyFileSync(path, `${path}.bak`);
|
|
1724
|
+
} catch {}
|
|
1725
|
+
for (let attempt = 0;; attempt++) try {
|
|
1726
|
+
renameSync(tmp, path);
|
|
1727
|
+
return;
|
|
1728
|
+
} catch (error) {
|
|
1729
|
+
const code = error.code;
|
|
1730
|
+
if ((code === "EPERM" || code === "EACCES" || code === "EBUSY") && attempt < 5) {
|
|
1731
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * (attempt + 1));
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
throw error;
|
|
1735
|
+
}
|
|
1647
1736
|
}
|
|
1648
1737
|
/**
|
|
1649
1738
|
* Slugify a course title into an id prefix: lowercase alphanumerics joined by `-`.
|
|
@@ -1693,8 +1782,13 @@ function freshLesson(title, anchor, body, kind = "study") {
|
|
|
1693
1782
|
*/
|
|
1694
1783
|
function importCourse(state, parsed, source, sourceRef, languageTarget) {
|
|
1695
1784
|
const base = slugify(parsed.title);
|
|
1696
|
-
|
|
1697
|
-
|
|
1785
|
+
if (source !== "markdown") {
|
|
1786
|
+
const sameIdentity = state.courses.find((c) => c.source === source && c.sourceRef === sourceRef && c.title === parsed.title);
|
|
1787
|
+
if (sameIdentity !== void 0) return sameIdentity;
|
|
1788
|
+
} else {
|
|
1789
|
+
const sameTitle = state.courses.find((c) => c.source === "markdown" && c.title === parsed.title);
|
|
1790
|
+
if (sameTitle !== void 0) return sameTitle;
|
|
1791
|
+
}
|
|
1698
1792
|
let id = base;
|
|
1699
1793
|
for (let n = 2; state.courses.some((c) => c.id === id); n++) id = `${base}-${String(n)}`;
|
|
1700
1794
|
const course = {
|
|
@@ -1738,16 +1832,92 @@ function importCourse(state, parsed, source, sourceRef, languageTarget) {
|
|
|
1738
1832
|
return course;
|
|
1739
1833
|
}
|
|
1740
1834
|
/**
|
|
1741
|
-
*
|
|
1835
|
+
* Delete one course — into the restorable trash (audit B7), carrying its
|
|
1836
|
+
* proposals, artifacts, thread groups, legacy bindings, pattern memory, and
|
|
1837
|
+
* focus so a restore is total (this also leaves no orphan sediment behind —
|
|
1838
|
+
* audit C17). Unknown ids fail loud.
|
|
1742
1839
|
* @param state - state to mutate.
|
|
1743
1840
|
* @param courseId - course to remove.
|
|
1744
1841
|
*/
|
|
1745
1842
|
function deleteCourse(state, courseId) {
|
|
1746
1843
|
const i = state.courses.findIndex((c) => c.id === courseId);
|
|
1747
1844
|
if (i < 0) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
|
|
1748
|
-
state.courses.splice(i, 1);
|
|
1749
|
-
|
|
1750
|
-
|
|
1845
|
+
const [course] = state.courses.splice(i, 1);
|
|
1846
|
+
const prefix = `${courseId}:`;
|
|
1847
|
+
const lessonThreads = [];
|
|
1848
|
+
for (const key of Object.keys(state.lessonThreads)) if (key.startsWith(prefix) || key === `course:${courseId}`) {
|
|
1849
|
+
lessonThreads.push([key, state.lessonThreads[key]]);
|
|
1850
|
+
delete state.lessonThreads[key];
|
|
1851
|
+
}
|
|
1852
|
+
const lessonSessions = [];
|
|
1853
|
+
for (const key of Object.keys(state.lessonSessions)) if (key.startsWith(prefix)) {
|
|
1854
|
+
lessonSessions.push([key, state.lessonSessions[key]]);
|
|
1855
|
+
delete state.lessonSessions[key];
|
|
1856
|
+
}
|
|
1857
|
+
const artifacts = [];
|
|
1858
|
+
for (const key of Object.keys(state.artifacts)) if (key.startsWith(prefix)) {
|
|
1859
|
+
artifacts.push([key, state.artifacts[key]]);
|
|
1860
|
+
delete state.artifacts[key];
|
|
1861
|
+
}
|
|
1862
|
+
const proposals = state.proposals.filter((p) => p.lessonId.startsWith(prefix));
|
|
1863
|
+
state.proposals = state.proposals.filter((p) => !p.lessonId.startsWith(prefix));
|
|
1864
|
+
const pattern = state.memoryPatterns[courseId];
|
|
1865
|
+
const memoryPattern = pattern !== void 0 ? [courseId, pattern] : null;
|
|
1866
|
+
if (pattern !== void 0) delete state.memoryPatterns[courseId];
|
|
1867
|
+
const focusLessonId = state.focus !== null && state.focus.lessonId.startsWith(prefix) ? state.focus.lessonId : null;
|
|
1868
|
+
if (focusLessonId !== null) state.focus = null;
|
|
1869
|
+
state.trash = [{
|
|
1870
|
+
deletedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1871
|
+
course,
|
|
1872
|
+
proposals,
|
|
1873
|
+
artifacts,
|
|
1874
|
+
lessonThreads,
|
|
1875
|
+
lessonSessions,
|
|
1876
|
+
memoryPattern,
|
|
1877
|
+
focusLessonId
|
|
1878
|
+
}, ...state.trash ?? []].slice(0, 10);
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Restore one course from the trash with all its sediment. The slug may have
|
|
1882
|
+
* been re-taken while trashed — the tree is re-id'd and every carried key
|
|
1883
|
+
* remapped in that case. Unknown ids fail loud with the restorable list.
|
|
1884
|
+
* @param state - state to mutate.
|
|
1885
|
+
* @param courseId - trashed course to restore.
|
|
1886
|
+
* @returns the restored course.
|
|
1887
|
+
*/
|
|
1888
|
+
function restoreCourse(state, courseId) {
|
|
1889
|
+
state.trash ??= [];
|
|
1890
|
+
const i = state.trash.findIndex((t) => t.course.id === courseId);
|
|
1891
|
+
if (i < 0) throw new Error(`lookatstudy-plugin: no trashed course ${JSON.stringify(courseId)} (restorable: ${state.trash.map((t) => t.course.id).join(", ") || "none"})`);
|
|
1892
|
+
const entry = state.trash.splice(i, 1)[0];
|
|
1893
|
+
const course = entry.course;
|
|
1894
|
+
const oldId = course.id;
|
|
1895
|
+
let id = oldId;
|
|
1896
|
+
for (let n = 2; state.courses.some((c) => c.id === id); n++) id = `${oldId}-${String(n)}`;
|
|
1897
|
+
if (id !== oldId) {
|
|
1898
|
+
course.id = id;
|
|
1899
|
+
const remap = (key) => key.startsWith(`${oldId}:`) ? `${id}${key.slice(oldId.length)}` : key;
|
|
1900
|
+
course.sections.forEach((section, si) => section.lessons.forEach((lesson, li) => {
|
|
1901
|
+
lesson.id = `${id}:${si}:${li}`;
|
|
1902
|
+
}));
|
|
1903
|
+
for (const p of entry.proposals) p.lessonId = remap(p.lessonId);
|
|
1904
|
+
entry.artifacts = entry.artifacts.map(([k, v]) => [remap(k), v]);
|
|
1905
|
+
entry.lessonThreads = entry.lessonThreads.map(([k, v]) => {
|
|
1906
|
+
if (!isCourseOwnerKey(k)) return [remap(k), v];
|
|
1907
|
+
for (const t of v.threads) if (t.touchedLessons !== void 0) t.touchedLessons = t.touchedLessons.map(remap);
|
|
1908
|
+
return [`course:${id}`, v];
|
|
1909
|
+
});
|
|
1910
|
+
entry.lessonSessions = entry.lessonSessions.map(([k, v]) => [remap(k), v]);
|
|
1911
|
+
if (entry.focusLessonId !== null) entry.focusLessonId = remap(entry.focusLessonId);
|
|
1912
|
+
}
|
|
1913
|
+
state.courses.push(course);
|
|
1914
|
+
for (const p of entry.proposals) state.proposals.push(p);
|
|
1915
|
+
for (const [key, list] of entry.artifacts) state.artifacts[key] = [...state.artifacts[key] ?? [], ...list];
|
|
1916
|
+
for (const [key, group] of entry.lessonThreads) state.lessonThreads[key] = group;
|
|
1917
|
+
for (const [key, sessionId] of entry.lessonSessions) state.lessonSessions[key] = sessionId;
|
|
1918
|
+
if (entry.memoryPattern !== null) state.memoryPatterns[course.id] = entry.memoryPattern[1];
|
|
1919
|
+
if (entry.focusLessonId !== null && state.focus === null) state.focus = { lessonId: entry.focusLessonId };
|
|
1920
|
+
return course;
|
|
1751
1921
|
}
|
|
1752
1922
|
/**
|
|
1753
1923
|
* Locate a course; unknown ids fail loud.
|
|
@@ -1897,6 +2067,7 @@ function applyProgression(ref, now) {
|
|
|
1897
2067
|
*/
|
|
1898
2068
|
function attemptLesson(state, lessonId, now) {
|
|
1899
2069
|
const ref = findLesson(state, lessonId);
|
|
2070
|
+
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
2071
|
if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; complete earlier lessons first`);
|
|
1901
2072
|
if (ref.lesson.status !== "available") return {
|
|
1902
2073
|
ref,
|
|
@@ -1969,7 +2140,7 @@ function applyExamBank(state, lessonId, questions, now) {
|
|
|
1969
2140
|
if (typeof q?.prompt !== "string" || q.prompt.trim() === "") throw new Error(`lookatstudy-plugin: question ${String(i)} has an empty prompt`);
|
|
1970
2141
|
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
2142
|
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
|
|
2143
|
+
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
2144
|
return {
|
|
1974
2145
|
id: `q${String(i)}`,
|
|
1975
2146
|
prompt: q.prompt,
|
|
@@ -1989,10 +2160,12 @@ function applyExamBank(state, lessonId, questions, now) {
|
|
|
1989
2160
|
kcCount: new Set(bank.map((q) => q.kcTitle).filter((k) => k !== null)).size
|
|
1990
2161
|
};
|
|
1991
2162
|
}
|
|
1992
|
-
/** Drop the bank back to idle (regenerate;
|
|
2163
|
+
/** Drop the bank back to idle (regenerate; open attempts settle against their
|
|
2164
|
+
* snapshots first — audit C16: never cross-grade a live attempt). */
|
|
1993
2165
|
function regenerateExamBank(state, lessonId) {
|
|
1994
2166
|
const ref = findLesson(state, lessonId);
|
|
1995
2167
|
if (ref.lesson.kind !== "exam") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is not an exam node`);
|
|
2168
|
+
settleDanglingAttempts(state, lessonId);
|
|
1996
2169
|
ref.lesson.examBank = {
|
|
1997
2170
|
status: "idle",
|
|
1998
2171
|
questions: []
|
|
@@ -2001,7 +2174,12 @@ function regenerateExamBank(state, lessonId) {
|
|
|
2001
2174
|
/** Settle every dangling attempt of the lesson (grade dead — unanswered = wrong). */
|
|
2002
2175
|
function settleDanglingAttempts(state, lessonId) {
|
|
2003
2176
|
const ref = findLesson(state, lessonId);
|
|
2004
|
-
|
|
2177
|
+
let changed = false;
|
|
2178
|
+
for (const attempt of ref.lesson.examAttemptLog ?? []) if (attempt.finishedAt === null) {
|
|
2179
|
+
submitExamAttempt(state, lessonId, attempt.id, true, /* @__PURE__ */ new Date());
|
|
2180
|
+
changed = true;
|
|
2181
|
+
}
|
|
2182
|
+
return changed;
|
|
2005
2183
|
}
|
|
2006
2184
|
/** Open a new attempt against the ready bank (dangling ones are graded dead first). */
|
|
2007
2185
|
function startExamAttempt(state, lessonId, now) {
|
|
@@ -2021,9 +2199,14 @@ function startExamAttempt(state, lessonId, now) {
|
|
|
2021
2199
|
totalCount: null,
|
|
2022
2200
|
stars: null,
|
|
2023
2201
|
terminated: false,
|
|
2024
|
-
perQuestion: null
|
|
2202
|
+
perQuestion: null,
|
|
2203
|
+
snapshot: ref.lesson.examBank.questions.map((q) => ({
|
|
2204
|
+
...q,
|
|
2205
|
+
options: [...q.options]
|
|
2206
|
+
}))
|
|
2025
2207
|
};
|
|
2026
|
-
|
|
2208
|
+
const kept = [...log, attempt];
|
|
2209
|
+
ref.lesson.examAttemptLog = kept.length > 20 ? kept.slice(kept.length - 20) : kept;
|
|
2027
2210
|
return {
|
|
2028
2211
|
attemptId: attempt.id,
|
|
2029
2212
|
questions: ref.lesson.examBank.questions
|
|
@@ -2035,7 +2218,7 @@ function recordExamAnswer(state, lessonId, attemptId, questionId, answer) {
|
|
|
2035
2218
|
const attempt = ref.lesson.examAttemptLog?.find((a) => a.id === attemptId);
|
|
2036
2219
|
if (attempt === void 0) throw new Error(`lookatstudy-plugin: exam attempt ${JSON.stringify(attemptId)} not found on ${JSON.stringify(lessonId)}`);
|
|
2037
2220
|
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);
|
|
2221
|
+
const question = (attempt.snapshot ?? (ref.lesson.examBank?.status === "ready" ? ref.lesson.examBank.questions : [])).find((q) => q.id === questionId);
|
|
2039
2222
|
if (question === void 0) throw new Error(`lookatstudy-plugin: exam question ${JSON.stringify(questionId)} not in the bank`);
|
|
2040
2223
|
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
2224
|
attempt.answers = {
|
|
@@ -2049,8 +2232,7 @@ function submitExamAttempt(state, lessonId, attemptId, terminated, now) {
|
|
|
2049
2232
|
const attempt = ref.lesson.examAttemptLog?.find((a) => a.id === attemptId);
|
|
2050
2233
|
if (attempt === void 0) throw new Error(`lookatstudy-plugin: exam attempt ${JSON.stringify(attemptId)} not found on ${JSON.stringify(lessonId)}`);
|
|
2051
2234
|
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) => {
|
|
2235
|
+
const perQuestion = (attempt.snapshot ?? (ref.lesson.examBank?.status === "ready" ? ref.lesson.examBank.questions : [])).map((q) => {
|
|
2054
2236
|
const user = attempt.answers[q.id] ?? "";
|
|
2055
2237
|
return {
|
|
2056
2238
|
exerciseId: q.id,
|
|
@@ -2100,6 +2282,7 @@ function submitExamAttempt(state, lessonId, attemptId, terminated, now) {
|
|
|
2100
2282
|
*/
|
|
2101
2283
|
function recordAnswer(state, lessonId, correct, concept, now) {
|
|
2102
2284
|
const ref = findLesson(state, lessonId);
|
|
2285
|
+
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
2286
|
if (ref.lesson.status === "locked") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is locked; open it with study_lesson first`);
|
|
2104
2287
|
if (ref.lesson.status === "available") ref.lesson.status = "in_progress";
|
|
2105
2288
|
const kcIndex = concept === void 0 ? void 0 : ref.lesson.concepts?.findIndex((c) => c.title === concept);
|
|
@@ -2155,24 +2338,67 @@ function recordAnswer(state, lessonId, correct, concept, now) {
|
|
|
2155
2338
|
* @returns completion result including the unlocked lessons.
|
|
2156
2339
|
*/
|
|
2157
2340
|
/**
|
|
2341
|
+
* Set a course's thread granularity (0.23.0). Switching never touches the
|
|
2342
|
+
* sediment: both sides' groups stay put and are resumed when the scope flips
|
|
2343
|
+
* back. Unknown ids fail loud.
|
|
2344
|
+
*/
|
|
2345
|
+
function setCourseThreadScope(state, courseId, scope) {
|
|
2346
|
+
const course = state.courses.find((c) => c.id === courseId);
|
|
2347
|
+
if (course === void 0) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
|
|
2348
|
+
course.threadScope = scope;
|
|
2349
|
+
}
|
|
2350
|
+
/**
|
|
2351
|
+
* Resolve the thread-group map key a send from `lessonId` belongs to — the
|
|
2352
|
+
* 0.23.0 granularity funnel: lesson scope keeps legacy lessonId keys, course
|
|
2353
|
+
* scope namespaces the course. Every thread-group access in this module goes
|
|
2354
|
+
* through here (or through an ownerKey already derived from it).
|
|
2355
|
+
*/
|
|
2356
|
+
function ownerKeyFor(state, lessonId) {
|
|
2357
|
+
const ref = findLesson(state, lessonId);
|
|
2358
|
+
const scope = ref.course.threadScope === "course" ? "course" : "lesson";
|
|
2359
|
+
return {
|
|
2360
|
+
key: threadOwnerKey(scope, ref.course.id, lessonId),
|
|
2361
|
+
scope
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Under course scope, mirrors drift per lesson (each remembers the last
|
|
2366
|
+
* thread IT used) — drift onto a LIVE thread is fine. The moment a thread
|
|
2367
|
+
* leaves the live set (archived, deleted, or rolled off the active pointer),
|
|
2368
|
+
* every mirror still naming it is a dead reference — re-point them at the
|
|
2369
|
+
* group's current active thread (or drop them when none is left). Lesson
|
|
2370
|
+
* scope keeps the legacy exact-mirror behavior at its own key.
|
|
2371
|
+
*/
|
|
2372
|
+
function repointMirrorsOff(state, key, threadId, group) {
|
|
2373
|
+
if (threadId === null || !isCourseOwnerKey(key)) return;
|
|
2374
|
+
for (const lessonId of Object.keys(state.lessonSessions)) if (state.lessonSessions[lessonId] === threadId) {
|
|
2375
|
+
if (group.active === null) delete state.lessonSessions[lessonId];
|
|
2376
|
+
else state.lessonSessions[lessonId] = group.active;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2158
2380
|
* Bind a dsh session into a lesson's thread group (the issue-#11 write, the
|
|
2159
2381
|
* upstream sendMessage/ensureThreadForSend translation): an UNKNOWN session id
|
|
2160
2382
|
* appends a new thread (titled by the caller — the first message's text or a
|
|
2161
2383
|
* short action label) and becomes active; a KNOWN id re-activates it (bumping
|
|
2162
2384
|
* lastAt); `null` clears the active pointer (the +新建 affordance — the next
|
|
2163
2385
|
* send mints a fresh thread). The legacy lessonSessions map stays in lockstep
|
|
2164
|
-
* with the active pointer so pre-0.22 readers never disagree.
|
|
2386
|
+
* with the active pointer so pre-0.22 readers never disagree. Under course
|
|
2387
|
+
* scope (0.23.0) the group is the course's, the send-from lesson joins the
|
|
2388
|
+
* thread's touchedLessons coverage, and mirrors sync per
|
|
2389
|
+
* {@link repointRolledMirrors}.
|
|
2165
2390
|
*/
|
|
2166
2391
|
function bindLessonThread(state, lessonId, sessionId, title) {
|
|
2392
|
+
const { key, scope } = ownerKeyFor(state, lessonId);
|
|
2167
2393
|
state.lessonThreads ??= {};
|
|
2168
|
-
const group = state.lessonThreads[
|
|
2394
|
+
const group = state.lessonThreads[key] ?? {
|
|
2169
2395
|
active: null,
|
|
2170
2396
|
threads: []
|
|
2171
2397
|
};
|
|
2172
2398
|
if (sessionId === null) {
|
|
2173
2399
|
group.active = null;
|
|
2174
|
-
state.lessonThreads[
|
|
2175
|
-
delete state.lessonSessions[lessonId];
|
|
2400
|
+
state.lessonThreads[key] = group;
|
|
2401
|
+
if (scope === "lesson") delete state.lessonSessions[lessonId];
|
|
2176
2402
|
return group;
|
|
2177
2403
|
}
|
|
2178
2404
|
const existing = group.threads.find((t) => t.id === sessionId);
|
|
@@ -2182,31 +2408,37 @@ function bindLessonThread(state, lessonId, sessionId, title) {
|
|
|
2182
2408
|
id: sessionId,
|
|
2183
2409
|
title: (title ?? "").trim() !== "" ? title.trim() : lessonId,
|
|
2184
2410
|
createdAt: now,
|
|
2185
|
-
lastAt: now
|
|
2411
|
+
lastAt: now,
|
|
2412
|
+
touchedLessons: [lessonId]
|
|
2186
2413
|
});
|
|
2187
2414
|
} else {
|
|
2188
2415
|
existing.lastAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2189
2416
|
if (existing.status === "archived") existing.status = "active";
|
|
2417
|
+
const touched = existing.touchedLessons ??= [];
|
|
2418
|
+
if (!touched.includes(lessonId)) touched.push(lessonId);
|
|
2190
2419
|
}
|
|
2191
2420
|
group.active = sessionId;
|
|
2192
|
-
state.lessonThreads[
|
|
2421
|
+
state.lessonThreads[key] = group;
|
|
2193
2422
|
state.lessonSessions[lessonId] = sessionId;
|
|
2194
2423
|
return group;
|
|
2195
2424
|
}
|
|
2196
2425
|
/** Roll the group's active pointer to the freshest ACTIVE thread (or null)
|
|
2197
2426
|
* and keep the legacy lessonSessions mirror in lockstep. Shared by the
|
|
2198
2427
|
* archive/delete operations when they take out the current thread. */
|
|
2199
|
-
function rollActiveToFresh(state,
|
|
2428
|
+
function rollActiveToFresh(state, key, group) {
|
|
2429
|
+
const prev = group.active;
|
|
2200
2430
|
const live = group.threads.filter((t) => t.status !== "archived");
|
|
2201
2431
|
group.active = live.length > 0 ? live.reduce((a, b) => a.lastAt > b.lastAt ? a : b).id : null;
|
|
2202
|
-
if (
|
|
2203
|
-
else
|
|
2432
|
+
if (isCourseOwnerKey(key)) repointMirrorsOff(state, key, prev, group);
|
|
2433
|
+
else if (group.active !== null) state.lessonSessions[key] = group.active;
|
|
2434
|
+
else delete state.lessonSessions[key];
|
|
2204
2435
|
}
|
|
2205
2436
|
/** Rename a thread's stored title (issue #11 management: upstream gear menu).
|
|
2206
2437
|
* The dsh session's own title is renamed host-side by the client (best
|
|
2207
2438
|
* effort) — this is the durable plugin-side half. Throws on unknown ids. */
|
|
2208
2439
|
function renameLessonThread(state, lessonId, sessionId, title) {
|
|
2209
|
-
const
|
|
2440
|
+
const key = ownerKeyFor(state, lessonId).key;
|
|
2441
|
+
const group = state.lessonThreads?.[key];
|
|
2210
2442
|
if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
|
|
2211
2443
|
const thread = group.threads.find((t) => t.id === sessionId);
|
|
2212
2444
|
if (thread === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
|
|
@@ -2219,12 +2451,14 @@ function renameLessonThread(state, lessonId, sessionId, title) {
|
|
|
2219
2451
|
* threads leave the switcher list entirely (no unarchive entry there; the
|
|
2220
2452
|
* state API keeps `archived: false` for symmetry/future UI). */
|
|
2221
2453
|
function archiveLessonThread(state, lessonId, sessionId, archived) {
|
|
2222
|
-
const
|
|
2454
|
+
const key = ownerKeyFor(state, lessonId).key;
|
|
2455
|
+
const group = state.lessonThreads?.[key];
|
|
2223
2456
|
if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
|
|
2224
2457
|
const thread = group.threads.find((t) => t.id === sessionId);
|
|
2225
2458
|
if (thread === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
|
|
2226
2459
|
thread.status = archived ? "archived" : "active";
|
|
2227
|
-
if (archived && group.active === sessionId) rollActiveToFresh(state,
|
|
2460
|
+
if (archived && group.active === sessionId) rollActiveToFresh(state, key, group);
|
|
2461
|
+
if (archived) repointMirrorsOff(state, key, sessionId, group);
|
|
2228
2462
|
if (!archived && group.active === null) {
|
|
2229
2463
|
group.active = sessionId;
|
|
2230
2464
|
state.lessonSessions[lessonId] = sessionId;
|
|
@@ -2236,12 +2470,14 @@ function archiveLessonThread(state, lessonId, sessionId, archived) {
|
|
|
2236
2470
|
* the host session face (no delete primitive) — it stays in the host's session
|
|
2237
2471
|
* list; the UI discloses this. Throws on unknown ids. */
|
|
2238
2472
|
function deleteLessonThread(state, lessonId, sessionId) {
|
|
2239
|
-
const
|
|
2473
|
+
const key = ownerKeyFor(state, lessonId).key;
|
|
2474
|
+
const group = state.lessonThreads?.[key];
|
|
2240
2475
|
if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
|
|
2241
2476
|
const idx = group.threads.findIndex((t) => t.id === sessionId);
|
|
2242
2477
|
if (idx < 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
|
|
2243
2478
|
group.threads.splice(idx, 1);
|
|
2244
|
-
if (group.active === sessionId) rollActiveToFresh(state,
|
|
2479
|
+
if (group.active === sessionId) rollActiveToFresh(state, key, group);
|
|
2480
|
+
repointMirrorsOff(state, key, sessionId, group);
|
|
2245
2481
|
return group;
|
|
2246
2482
|
}
|
|
2247
2483
|
function completeLesson(state, lessonId, now) {
|
|
@@ -2287,7 +2523,10 @@ function addFriction(state, lessonId, category, summary, now) {
|
|
|
2287
2523
|
summary,
|
|
2288
2524
|
at: now.toISOString()
|
|
2289
2525
|
};
|
|
2290
|
-
if (lessonId === null)
|
|
2526
|
+
if (lessonId === null) {
|
|
2527
|
+
state.frictionGlobal = [...state.frictionGlobal ?? [], entry].slice(-50);
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2291
2530
|
const ref = findLesson(state, lessonId);
|
|
2292
2531
|
ref.lesson.friction.push(entry);
|
|
2293
2532
|
if (ref.lesson.friction.length > FRICTION_CAP) ref.lesson.friction.splice(0, ref.lesson.friction.length - FRICTION_CAP);
|
|
@@ -2308,7 +2547,8 @@ function setMemory(state, category, content, lessonId) {
|
|
|
2308
2547
|
return prev;
|
|
2309
2548
|
}
|
|
2310
2549
|
if (category === "pattern") {
|
|
2311
|
-
const
|
|
2550
|
+
const cut = lessonId === void 0 ? -1 : lessonId.indexOf(":");
|
|
2551
|
+
const course = findCourse(state, cut === -1 ? lessonId ?? "" : lessonId.slice(0, cut));
|
|
2312
2552
|
const prev = state.memoryPatterns[course.id] ?? null;
|
|
2313
2553
|
state.memoryPatterns[course.id] = content;
|
|
2314
2554
|
return prev;
|
|
@@ -2371,7 +2611,7 @@ function recordArtifact(state, lessonId, artifact) {
|
|
|
2371
2611
|
created: false
|
|
2372
2612
|
};
|
|
2373
2613
|
const next = [...existing, artifact];
|
|
2374
|
-
state.artifacts[lessonId] = next;
|
|
2614
|
+
state.artifacts[lessonId] = next.length > 100 ? next.slice(next.length - 100) : next;
|
|
2375
2615
|
return {
|
|
2376
2616
|
artifact,
|
|
2377
2617
|
created: true
|
|
@@ -2452,6 +2692,11 @@ function resolveProposal(state, proposalId, accept, now) {
|
|
|
2452
2692
|
}
|
|
2453
2693
|
}
|
|
2454
2694
|
proposal.status = accept ? "applied" : "rejected";
|
|
2695
|
+
const resolved = state.proposals.filter((p) => p.status !== "pending");
|
|
2696
|
+
if (resolved.length > 20) {
|
|
2697
|
+
const keep = new Set(resolved.slice(resolved.length - 20).map((p) => p.id));
|
|
2698
|
+
state.proposals = state.proposals.filter((p) => p.status === "pending" || keep.has(p.id));
|
|
2699
|
+
}
|
|
2455
2700
|
return proposal;
|
|
2456
2701
|
}
|
|
2457
2702
|
/**
|
|
@@ -2640,6 +2885,18 @@ function gatherConsolidationWindow(state, capPerLesson = 10) {
|
|
|
2640
2885
|
const entries = [];
|
|
2641
2886
|
let frictionCount = 0;
|
|
2642
2887
|
let practiceCount = 0;
|
|
2888
|
+
for (const f of state.frictionGlobal ?? []) {
|
|
2889
|
+
if (since !== null && f.at <= since) continue;
|
|
2890
|
+
entries.push({
|
|
2891
|
+
lessonId: "",
|
|
2892
|
+
lessonTitle: "(课级)",
|
|
2893
|
+
kind: "friction",
|
|
2894
|
+
category: f.category,
|
|
2895
|
+
text: f.summary ?? "(no summary)",
|
|
2896
|
+
at: f.at
|
|
2897
|
+
});
|
|
2898
|
+
frictionCount++;
|
|
2899
|
+
}
|
|
2643
2900
|
for (const course of state.courses) for (const section of course.sections) for (const lesson of section.lessons) {
|
|
2644
2901
|
let perLesson = 0;
|
|
2645
2902
|
const collect = (kind, at, push) => {
|
|
@@ -2795,6 +3052,7 @@ function workbenchState(state, now) {
|
|
|
2795
3052
|
return {
|
|
2796
3053
|
courseId: course.id,
|
|
2797
3054
|
title: course.title,
|
|
3055
|
+
threadScope: course.threadScope === "course" ? "course" : "lesson",
|
|
2798
3056
|
mastered: lessons.filter((l) => l.status === "mastered").length,
|
|
2799
3057
|
total: lessons.length,
|
|
2800
3058
|
avgMasteryPct: answered.length === 0 ? null : Math.round(answered.reduce((sum, l) => sum + (l.mastery ?? 0), 0) / answered.length * 100),
|
|
@@ -2918,6 +3176,49 @@ function workbenchState(state, now) {
|
|
|
2918
3176
|
};
|
|
2919
3177
|
}
|
|
2920
3178
|
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
|
|
3179
|
+
/** Local hostnames the panel is ever legitimately served from (audit A2). */
|
|
3180
|
+
const LOCAL_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
3181
|
+
"localhost",
|
|
3182
|
+
"127.0.0.1",
|
|
3183
|
+
"[::1]",
|
|
3184
|
+
"::1"
|
|
3185
|
+
]);
|
|
3186
|
+
function headerValue(req, name) {
|
|
3187
|
+
const raw = req.headers?.[name];
|
|
3188
|
+
const value = Array.isArray(raw) ? raw[0] ?? "" : raw;
|
|
3189
|
+
return typeof value === "string" ? value : "";
|
|
3190
|
+
}
|
|
3191
|
+
/** Hostname of the Host header with the port stripped (IPv6 brackets kept). */
|
|
3192
|
+
function hostNameOf(req) {
|
|
3193
|
+
const host = headerValue(req, "host").toLowerCase().trim();
|
|
3194
|
+
if (host.startsWith("[")) return host.slice(0, host.indexOf("]") + 1);
|
|
3195
|
+
return host.split(":")[0] ?? "";
|
|
3196
|
+
}
|
|
3197
|
+
function originIsLocal(origin) {
|
|
3198
|
+
try {
|
|
3199
|
+
return LOCAL_HOSTNAMES.has(new URL(origin).hostname.toLowerCase());
|
|
3200
|
+
} catch {
|
|
3201
|
+
return false;
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
/**
|
|
3205
|
+
* Audit A2: the host does not token-guard plugin routes, so the dashboard
|
|
3206
|
+
* gates itself. Every request must name a local Host header (kills DNS
|
|
3207
|
+
* rebinding reads); mutating POSTs must additionally carry the plugin marker
|
|
3208
|
+
* header `x-lks-request` — a drive-by page can still smuggle a no-preflight
|
|
3209
|
+
* text/plain POST at us, but it cannot add that header. GETs stay
|
|
3210
|
+
* marker-free because `<img>` tags (attachment thumbnails) cannot send
|
|
3211
|
+
* custom headers; the Host check still applies to them.
|
|
3212
|
+
*/
|
|
3213
|
+
function requestPassesGate(req) {
|
|
3214
|
+
if (!LOCAL_HOSTNAMES.has(hostNameOf(req))) return false;
|
|
3215
|
+
if (req.method === "POST") {
|
|
3216
|
+
if (headerValue(req, "x-lks-request") !== "1") return false;
|
|
3217
|
+
const origin = headerValue(req, "origin").trim();
|
|
3218
|
+
if (origin !== "" && !originIsLocal(origin)) return false;
|
|
3219
|
+
}
|
|
3220
|
+
return true;
|
|
3221
|
+
}
|
|
2921
3222
|
function sendJson(res, status, value) {
|
|
2922
3223
|
res.writeHead(status, JSON_HEADERS).end(JSON.stringify(value));
|
|
2923
3224
|
}
|
|
@@ -2930,7 +3231,9 @@ function sendJson(res, status, value) {
|
|
|
2930
3231
|
*/
|
|
2931
3232
|
async function readJsonBodySafe(req, res, maxBytes = 65536) {
|
|
2932
3233
|
try {
|
|
2933
|
-
|
|
3234
|
+
const value = await readJsonBody(req, maxBytes);
|
|
3235
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("request body must be a JSON object");
|
|
3236
|
+
return value;
|
|
2934
3237
|
} catch (error) {
|
|
2935
3238
|
sendJson(res, 400, {
|
|
2936
3239
|
ok: false,
|
|
@@ -2943,20 +3246,30 @@ async function readJsonBodySafe(req, res, maxBytes = 65536) {
|
|
|
2943
3246
|
function readJsonBody(req, maxBytes = 65536) {
|
|
2944
3247
|
return new Promise((resolve, reject) => {
|
|
2945
3248
|
const chunks = [];
|
|
3249
|
+
let received = 0;
|
|
3250
|
+
let settled = false;
|
|
3251
|
+
const done = (err, value) => {
|
|
3252
|
+
if (settled) return;
|
|
3253
|
+
settled = true;
|
|
3254
|
+
if (err !== null) reject(err);
|
|
3255
|
+
else resolve(value);
|
|
3256
|
+
};
|
|
2946
3257
|
req.on("data", (chunk) => {
|
|
3258
|
+
if (settled) return;
|
|
2947
3259
|
chunks.push(chunk);
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
return;
|
|
2951
|
-
}
|
|
3260
|
+
received += chunk.length;
|
|
3261
|
+
if (received > maxBytes) done(/* @__PURE__ */ new Error("request body too large"));
|
|
2952
3262
|
});
|
|
2953
3263
|
req.on("end", () => {
|
|
3264
|
+
if (settled) return;
|
|
2954
3265
|
try {
|
|
2955
|
-
|
|
3266
|
+
done(null, JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
2956
3267
|
} catch {
|
|
2957
|
-
|
|
3268
|
+
done(/* @__PURE__ */ new Error("request body is not valid JSON"));
|
|
2958
3269
|
}
|
|
2959
3270
|
});
|
|
3271
|
+
req.on("error", () => done(/* @__PURE__ */ new Error("request aborted")));
|
|
3272
|
+
req.on("aborted", () => done(/* @__PURE__ */ new Error("request aborted")));
|
|
2960
3273
|
});
|
|
2961
3274
|
}
|
|
2962
3275
|
/**
|
|
@@ -2981,14 +3294,30 @@ function registerDashboard(webServer, deps) {
|
|
|
2981
3294
|
kind: "prefix",
|
|
2982
3295
|
path: "/lookatstudy",
|
|
2983
3296
|
handler: async (req, res) => {
|
|
3297
|
+
if (!requestPassesGate(req)) {
|
|
3298
|
+
sendJson(res, 403, {
|
|
3299
|
+
ok: false,
|
|
3300
|
+
error: "forbidden"
|
|
3301
|
+
});
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
2984
3304
|
const pathname = new URL(req.url ?? "/", "http://x").pathname;
|
|
2985
3305
|
if (req.method === "GET" && pathname === "/lookatstudy/api/state") {
|
|
2986
|
-
|
|
3306
|
+
const payload = JSON.stringify({
|
|
2987
3307
|
...workbenchState(deps.store.get(), /* @__PURE__ */ new Date()),
|
|
2988
3308
|
statePath: deps.statePath,
|
|
2989
3309
|
version: pluginVersion(),
|
|
2990
3310
|
model: await modelInfo()
|
|
2991
3311
|
});
|
|
3312
|
+
const etag = `"${createHash("sha1").update(payload).digest("hex")}"`;
|
|
3313
|
+
if (headerValue(req, "if-none-match") === etag) {
|
|
3314
|
+
res.writeHead(304, { etag }).end();
|
|
3315
|
+
return;
|
|
3316
|
+
}
|
|
3317
|
+
res.writeHead(200, {
|
|
3318
|
+
...JSON_HEADERS,
|
|
3319
|
+
etag
|
|
3320
|
+
}).end(payload);
|
|
2992
3321
|
return;
|
|
2993
3322
|
}
|
|
2994
3323
|
if (req.method === "GET" && pathname === "/lookatstudy/api/search") {
|
|
@@ -3073,6 +3402,32 @@ function registerDashboard(webServer, deps) {
|
|
|
3073
3402
|
}
|
|
3074
3403
|
return;
|
|
3075
3404
|
}
|
|
3405
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/course/restore") {
|
|
3406
|
+
const body = await readJsonBodySafe(req, res);
|
|
3407
|
+
if (body === void 0) return;
|
|
3408
|
+
if (typeof body.courseId !== "string") {
|
|
3409
|
+
sendJson(res, 400, {
|
|
3410
|
+
ok: false,
|
|
3411
|
+
error: "courseId (string) required"
|
|
3412
|
+
});
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
3415
|
+
try {
|
|
3416
|
+
const course = restoreCourse(deps.store.get(), body.courseId);
|
|
3417
|
+
deps.store.save();
|
|
3418
|
+
sendJson(res, 200, {
|
|
3419
|
+
ok: true,
|
|
3420
|
+
courseId: course.id,
|
|
3421
|
+
title: course.title
|
|
3422
|
+
});
|
|
3423
|
+
} catch (error) {
|
|
3424
|
+
sendJson(res, 404, {
|
|
3425
|
+
ok: false,
|
|
3426
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3427
|
+
});
|
|
3428
|
+
}
|
|
3429
|
+
return;
|
|
3430
|
+
}
|
|
3076
3431
|
if (req.method === "POST" && pathname === "/lookatstudy/api/note/delete") {
|
|
3077
3432
|
const body = await readJsonBodySafe(req, res);
|
|
3078
3433
|
if (body === void 0) return;
|
|
@@ -3203,8 +3558,7 @@ function registerDashboard(webServer, deps) {
|
|
|
3203
3558
|
return;
|
|
3204
3559
|
}
|
|
3205
3560
|
try {
|
|
3206
|
-
settleDanglingAttempts(deps.store.get(), lessonId);
|
|
3207
|
-
deps.store.save();
|
|
3561
|
+
if (settleDanglingAttempts(deps.store.get(), lessonId)) deps.store.save();
|
|
3208
3562
|
const ref = findLesson(deps.store.get(), lessonId);
|
|
3209
3563
|
if (ref.lesson.kind !== "exam") throw new Error("not an exam node");
|
|
3210
3564
|
const bank = ref.lesson.examBank;
|
|
@@ -3372,7 +3726,15 @@ function registerDashboard(webServer, deps) {
|
|
|
3372
3726
|
return;
|
|
3373
3727
|
}
|
|
3374
3728
|
const title = typeof body.title === "string" ? body.title : null;
|
|
3375
|
-
|
|
3729
|
+
try {
|
|
3730
|
+
bindLessonThread(deps.store.get(), body.lessonId, body.sessionId, title);
|
|
3731
|
+
} catch (error) {
|
|
3732
|
+
sendJson(res, 404, {
|
|
3733
|
+
ok: false,
|
|
3734
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3735
|
+
});
|
|
3736
|
+
return;
|
|
3737
|
+
}
|
|
3376
3738
|
deps.store.save();
|
|
3377
3739
|
sendJson(res, 200, { ok: true });
|
|
3378
3740
|
return;
|
|
@@ -3388,6 +3750,7 @@ function registerDashboard(webServer, deps) {
|
|
|
3388
3750
|
return;
|
|
3389
3751
|
}
|
|
3390
3752
|
const state = deps.store.get();
|
|
3753
|
+
let group = null;
|
|
3391
3754
|
try {
|
|
3392
3755
|
if (body.op === "rename") {
|
|
3393
3756
|
if (typeof body.title !== "string" || body.title.trim() === "") {
|
|
@@ -3397,7 +3760,7 @@ function registerDashboard(webServer, deps) {
|
|
|
3397
3760
|
});
|
|
3398
3761
|
return;
|
|
3399
3762
|
}
|
|
3400
|
-
renameLessonThread(state, body.lessonId, body.sessionId, body.title);
|
|
3763
|
+
group = renameLessonThread(state, body.lessonId, body.sessionId, body.title);
|
|
3401
3764
|
} else if (body.op === "archive") {
|
|
3402
3765
|
if (typeof body.archived !== "boolean") {
|
|
3403
3766
|
sendJson(res, 400, {
|
|
@@ -3406,8 +3769,8 @@ function registerDashboard(webServer, deps) {
|
|
|
3406
3769
|
});
|
|
3407
3770
|
return;
|
|
3408
3771
|
}
|
|
3409
|
-
archiveLessonThread(state, body.lessonId, body.sessionId, body.archived);
|
|
3410
|
-
} else deleteLessonThread(state, body.lessonId, body.sessionId);
|
|
3772
|
+
group = archiveLessonThread(state, body.lessonId, body.sessionId, body.archived);
|
|
3773
|
+
} else group = deleteLessonThread(state, body.lessonId, body.sessionId);
|
|
3411
3774
|
} catch (error) {
|
|
3412
3775
|
sendJson(res, 404, {
|
|
3413
3776
|
ok: false,
|
|
@@ -3416,7 +3779,6 @@ function registerDashboard(webServer, deps) {
|
|
|
3416
3779
|
return;
|
|
3417
3780
|
}
|
|
3418
3781
|
deps.store.save();
|
|
3419
|
-
const group = state.lessonThreads[body.lessonId];
|
|
3420
3782
|
sendJson(res, 200, {
|
|
3421
3783
|
ok: true,
|
|
3422
3784
|
active: group?.active ?? null,
|
|
@@ -3424,6 +3786,32 @@ function registerDashboard(webServer, deps) {
|
|
|
3424
3786
|
});
|
|
3425
3787
|
return;
|
|
3426
3788
|
}
|
|
3789
|
+
if (req.method === "POST" && pathname === "/lookatstudy/api/course-scope") {
|
|
3790
|
+
const body = await readJsonBodySafe(req, res);
|
|
3791
|
+
if (body === void 0) return;
|
|
3792
|
+
if (typeof body.courseId !== "string" || body.scope !== "lesson" && body.scope !== "course") {
|
|
3793
|
+
sendJson(res, 400, {
|
|
3794
|
+
ok: false,
|
|
3795
|
+
error: "courseId and scope (lesson|course) required"
|
|
3796
|
+
});
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
try {
|
|
3800
|
+
setCourseThreadScope(deps.store.get(), body.courseId, body.scope);
|
|
3801
|
+
} catch (error) {
|
|
3802
|
+
sendJson(res, 404, {
|
|
3803
|
+
ok: false,
|
|
3804
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3805
|
+
});
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
deps.store.save();
|
|
3809
|
+
sendJson(res, 200, {
|
|
3810
|
+
ok: true,
|
|
3811
|
+
scope: body.scope
|
|
3812
|
+
});
|
|
3813
|
+
return;
|
|
3814
|
+
}
|
|
3427
3815
|
if (req.method === "GET" && pathname === "/lookatstudy/api/course-pack") {
|
|
3428
3816
|
const courseId = new URL(req.url ?? "/", "http://x").searchParams.get("courseId") ?? "";
|
|
3429
3817
|
const course = findCourseForPack(deps.store.get(), courseId);
|
|
@@ -3912,7 +4300,7 @@ async function scanFolder(rootDir, onProgress, options) {
|
|
|
3912
4300
|
for (const doc of dedupedDocs) {
|
|
3913
4301
|
if (doc.kind !== "pptx") continue;
|
|
3914
4302
|
try {
|
|
3915
|
-
const { parsePptx } = await import("./pptx-parser-
|
|
4303
|
+
const { parsePptx } = await import("./pptx-parser-BooxpGV4.mjs");
|
|
3916
4304
|
const result = await parsePptx(await readFile(join(rootDir, doc.path)));
|
|
3917
4305
|
for (const img of result.images ?? []) pptxImages.push({
|
|
3918
4306
|
path: `${doc.path}#slide${img.slideNumber}.png`,
|
|
@@ -4101,19 +4489,20 @@ async function readFileWithKind(absPath, kind, parsePdf) {
|
|
|
4101
4489
|
const { parsePdfText } = await Promise.resolve().then(() => pdf_text_exports);
|
|
4102
4490
|
return parsePdfText(buf);
|
|
4103
4491
|
}
|
|
4492
|
+
if (statSync(absPath).size > 67108864) throw new Error(`文件超过 64MiB 上限 (audit B11): ${absPath}`);
|
|
4104
4493
|
if (kind === "pptx") {
|
|
4105
4494
|
const buf = await readFile(absPath);
|
|
4106
|
-
const { parsePptx } = await import("./pptx-parser-
|
|
4495
|
+
const { parsePptx } = await import("./pptx-parser-BooxpGV4.mjs");
|
|
4107
4496
|
return (await parsePptx(buf)).markdown;
|
|
4108
4497
|
}
|
|
4109
4498
|
if (kind === "docx") {
|
|
4110
4499
|
const buf = await readFile(absPath);
|
|
4111
|
-
const { parseDocx } = await import("./docx-parser-
|
|
4500
|
+
const { parseDocx } = await import("./docx-parser-BB_sCd8F.mjs");
|
|
4112
4501
|
return parseDocx(buf).replace(/^(#{1,5}) /gm, (m) => `#${m}`);
|
|
4113
4502
|
}
|
|
4114
4503
|
if (kind === "epub") {
|
|
4115
4504
|
const buf = await readFile(absPath);
|
|
4116
|
-
const { parseEpubFlat } = await import("./epub-parser-
|
|
4505
|
+
const { parseEpubFlat } = await import("./epub-parser-B7JEfgSO.mjs");
|
|
4117
4506
|
return parseEpubFlat(buf);
|
|
4118
4507
|
}
|
|
4119
4508
|
if (kind === "ipynb") {
|
|
@@ -4281,7 +4670,16 @@ function findStandaloneImages(images, docs) {
|
|
|
4281
4670
|
//#region src/vendor/repo-fetcher.ts
|
|
4282
4671
|
/** CDN URL 构造 */
|
|
4283
4672
|
function cdnUrl(owner, repo, branch, path) {
|
|
4284
|
-
|
|
4673
|
+
const segments = [];
|
|
4674
|
+
for (const seg of path.split("/")) {
|
|
4675
|
+
if (seg === "" || seg === ".") continue;
|
|
4676
|
+
if (seg === "..") {
|
|
4677
|
+
segments.pop();
|
|
4678
|
+
continue;
|
|
4679
|
+
}
|
|
4680
|
+
segments.push(seg);
|
|
4681
|
+
}
|
|
4682
|
+
return `https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${segments.join("/")}`;
|
|
4285
4683
|
}
|
|
4286
4684
|
/** 代码文件扩展名(代码即教学内容) */
|
|
4287
4685
|
const CODE_EXTENSIONS = [
|
|
@@ -4541,7 +4939,7 @@ async function fetchRepoFileTree(owner, repo, branch, _fetchFn, signal) {
|
|
|
4541
4939
|
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
|
|
4542
4940
|
try {
|
|
4543
4941
|
const r = await httpsGet(apiUrl, {
|
|
4544
|
-
rejectUnauthorized:
|
|
4942
|
+
rejectUnauthorized: process.env.LKS_INSECURE_TLS !== "1",
|
|
4545
4943
|
deadlineMs: 24e4,
|
|
4546
4944
|
signal
|
|
4547
4945
|
});
|
|
@@ -4558,7 +4956,7 @@ async function fetchRepoFileTree(owner, repo, branch, _fetchFn, signal) {
|
|
|
4558
4956
|
}
|
|
4559
4957
|
try {
|
|
4560
4958
|
const r2 = await httpsGet(`https://data.jsdelivr.com/v1/packages/gh/${owner}/${repo}@${branch}?structure=flat`, {
|
|
4561
|
-
rejectUnauthorized:
|
|
4959
|
+
rejectUnauthorized: process.env.LKS_INSECURE_TLS !== "1",
|
|
4562
4960
|
signal
|
|
4563
4961
|
});
|
|
4564
4962
|
if (r2.ok && r2.body) {
|
|
@@ -4940,6 +5338,117 @@ function normalizeUrlIdentity(url) {
|
|
|
4940
5338
|
}
|
|
4941
5339
|
}
|
|
4942
5340
|
//#endregion
|
|
5341
|
+
//#region src/vendor/net-guard.ts
|
|
5342
|
+
/**
|
|
5343
|
+
* Audit B6 (2026-09-14): the article/subtitle fetch paths take tutor- and
|
|
5344
|
+
* user-supplied URLs, and the host process runs with the user's full network
|
|
5345
|
+
* reach — an unguarded GET is an SSRF primitive against the local (formerly
|
|
5346
|
+
* unauthenticated) dashboard, cloud metadata, and LAN devices. Three guards:
|
|
5347
|
+
* 1. literal private/loopback addresses are refused (DNS names are NOT
|
|
5348
|
+
* resolved — documented boundary: a public DNS name pointing inward
|
|
5349
|
+
* still passes);
|
|
5350
|
+
* 2. redirects are followed MANUALLY with a per-hop re-check (a public URL
|
|
5351
|
+
* 302-ing to 169.254.169.254 must not be followed blindly);
|
|
5352
|
+
* 3. response bodies are stream-read under a hard byte cap before they
|
|
5353
|
+
* enter memory as text.
|
|
5354
|
+
* @module dsh-plugin-lookatstudy/net-guard
|
|
5355
|
+
*/
|
|
5356
|
+
/** Private/loopback IPv4 literal? */
|
|
5357
|
+
function isPrivateIPv4(host) {
|
|
5358
|
+
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
5359
|
+
if (m === null) return false;
|
|
5360
|
+
const octets = m.slice(1).map(Number);
|
|
5361
|
+
if (octets.some((o) => o > 255)) return false;
|
|
5362
|
+
const [a, b] = octets;
|
|
5363
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
5364
|
+
if (a === 169 && b === 254) return true;
|
|
5365
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
5366
|
+
if (a === 192 && b === 168) return true;
|
|
5367
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
5368
|
+
return false;
|
|
5369
|
+
}
|
|
5370
|
+
/** Private/loopback hostname? Literal IPs and localhost only (no DNS resolution). */
|
|
5371
|
+
function isPrivateHostname(hostname) {
|
|
5372
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
5373
|
+
if (host === "" || host === "localhost" || host.endsWith(".localhost")) return true;
|
|
5374
|
+
if (host.includes(":")) {
|
|
5375
|
+
if (host === "::1" || host === "::") return true;
|
|
5376
|
+
const first = host.split(":")[0] ?? "";
|
|
5377
|
+
if (/^fe[89ab]/.test(first)) return true;
|
|
5378
|
+
if (/^f[cd]/.test(first)) return true;
|
|
5379
|
+
const v4mapped = host.match(/(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
5380
|
+
if (v4mapped !== null) return isPrivateIPv4(v4mapped[1]);
|
|
5381
|
+
return false;
|
|
5382
|
+
}
|
|
5383
|
+
return isPrivateIPv4(host);
|
|
5384
|
+
}
|
|
5385
|
+
/** Parse and refuse non-public fetch targets; returns the parsed URL. */
|
|
5386
|
+
function assertPublicHttpUrl(url) {
|
|
5387
|
+
let parsed;
|
|
5388
|
+
try {
|
|
5389
|
+
parsed = new URL(url);
|
|
5390
|
+
} catch {
|
|
5391
|
+
throw new Error(`lookatstudy-plugin: not a valid URL: ${JSON.stringify(url)}`);
|
|
5392
|
+
}
|
|
5393
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`lookatstudy-plugin: refusing non-http(s) fetch target ${JSON.stringify(url)}`);
|
|
5394
|
+
if (isPrivateHostname(parsed.hostname)) throw new Error(`lookatstudy-plugin: refusing to fetch a private/loopback address (SSRF guard): ${parsed.hostname}`);
|
|
5395
|
+
return parsed;
|
|
5396
|
+
}
|
|
5397
|
+
/**
|
|
5398
|
+
* Fetch one URL as text under the guards: manual redirects re-checked per
|
|
5399
|
+
* hop, declared content-length pre-check, stream-read truncated at the cap.
|
|
5400
|
+
* Non-2xx (after the redirect hops) throws, matching the article path's
|
|
5401
|
+
* honest-failure contract.
|
|
5402
|
+
*/
|
|
5403
|
+
async function guardedFetchText(url, deps = {}) {
|
|
5404
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
5405
|
+
const maxBytes = deps.maxBytes ?? 2097152;
|
|
5406
|
+
const maxRedirects = deps.maxRedirects ?? 5;
|
|
5407
|
+
let current = assertPublicHttpUrl(url).toString();
|
|
5408
|
+
for (let hop = 0;; hop++) {
|
|
5409
|
+
const resp = await doFetch(current, {
|
|
5410
|
+
headers: deps.headers,
|
|
5411
|
+
redirect: "manual",
|
|
5412
|
+
signal: deps.signal
|
|
5413
|
+
});
|
|
5414
|
+
if (resp.status === 301 || resp.status === 302 || resp.status === 303 || resp.status === 307 || resp.status === 308) {
|
|
5415
|
+
const location = resp.headers.get("location");
|
|
5416
|
+
if (location === null) throw new Error(`lookatstudy-plugin: redirect without a Location header (HTTP ${resp.status})`);
|
|
5417
|
+
if (hop >= maxRedirects) throw new Error("lookatstudy-plugin: too many redirects");
|
|
5418
|
+
current = assertPublicHttpUrl(new URL(location, current).toString()).toString();
|
|
5419
|
+
continue;
|
|
5420
|
+
}
|
|
5421
|
+
if (!resp.ok) throw new Error(`lookatstudy-plugin: page fetch failed (HTTP ${resp.status}): ${current}`);
|
|
5422
|
+
const declaredRaw = resp.headers.get("content-length");
|
|
5423
|
+
if (declaredRaw !== null && /^\d+$/.test(declaredRaw) && Number(declaredRaw) > maxBytes) throw new Error(`lookatstudy-plugin: response declares ${declaredRaw} bytes, over the ${maxBytes} byte cap: ${current}`);
|
|
5424
|
+
const reader = resp.body?.getReader();
|
|
5425
|
+
if (reader === void 0) return await resp.text();
|
|
5426
|
+
const chunks = [];
|
|
5427
|
+
let total = 0;
|
|
5428
|
+
let capped = false;
|
|
5429
|
+
for (;;) {
|
|
5430
|
+
const { done, value } = await reader.read();
|
|
5431
|
+
if (done) break;
|
|
5432
|
+
chunks.push(value);
|
|
5433
|
+
total += value.length;
|
|
5434
|
+
if (total > maxBytes) {
|
|
5435
|
+
capped = true;
|
|
5436
|
+
break;
|
|
5437
|
+
}
|
|
5438
|
+
}
|
|
5439
|
+
if (capped) await reader.cancel().catch(() => {});
|
|
5440
|
+
const bytes = new Uint8Array(Math.min(total, maxBytes));
|
|
5441
|
+
let off = 0;
|
|
5442
|
+
for (const chunk of chunks) {
|
|
5443
|
+
if (off >= bytes.length) break;
|
|
5444
|
+
const take = Math.min(chunk.length, bytes.length - off);
|
|
5445
|
+
bytes.set(chunk.subarray(0, take), off);
|
|
5446
|
+
off += take;
|
|
5447
|
+
}
|
|
5448
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5451
|
+
//#endregion
|
|
4943
5452
|
//#region src/vendor/pdf-text.ts
|
|
4944
5453
|
var pdf_text_exports = /* @__PURE__ */ __exportAll({
|
|
4945
5454
|
normalizeRadicals: () => normalizeRadicals,
|
|
@@ -5505,10 +6014,18 @@ async function fetchBilibiliSubtitles(url, fetchFn, signal, opts) {
|
|
|
5505
6014
|
missing.push(t.name);
|
|
5506
6015
|
continue;
|
|
5507
6016
|
}
|
|
5508
|
-
const
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
6017
|
+
const subUrl = lane.url.startsWith("//") ? `https:${lane.url}` : lane.url;
|
|
6018
|
+
let raw = "";
|
|
6019
|
+
try {
|
|
6020
|
+
raw = await guardedFetchText(subUrl, {
|
|
6021
|
+
fetchImpl: fetchFn,
|
|
6022
|
+
signal,
|
|
6023
|
+
headers: BILI_HEADERS
|
|
6024
|
+
});
|
|
6025
|
+
} catch {
|
|
6026
|
+
missing.push(t.name);
|
|
6027
|
+
continue;
|
|
6028
|
+
}
|
|
5512
6029
|
let text = "";
|
|
5513
6030
|
try {
|
|
5514
6031
|
text = (JSON.parse(raw).body ?? []).map((b) => (b.content ?? "").trim()).filter(Boolean).join("");
|
|
@@ -5826,6 +6343,8 @@ function extractHeadings(content) {
|
|
|
5826
6343
|
function findTitleIndex(headings, anchor) {
|
|
5827
6344
|
const anchorClean = anchor.replace(/^#{1,3}\s+/, "").toLowerCase().trim();
|
|
5828
6345
|
if (anchorClean === "") return -1;
|
|
6346
|
+
const exact = headings.filter((h) => h.title.toLowerCase().trim() === anchorClean);
|
|
6347
|
+
if (exact.length === 1) return headings.indexOf(exact[0]);
|
|
5829
6348
|
for (let i = 0; i < headings.length; i++) {
|
|
5830
6349
|
const titleLower = headings[i].title.toLowerCase();
|
|
5831
6350
|
if (titleLower.includes(anchorClean) || anchorClean.includes(titleLower)) return i;
|
|
@@ -6100,8 +6619,15 @@ function sanitizeQuiz(raw) {
|
|
|
6100
6619
|
list.slice(0, MAX_QUESTIONS).forEach((q, index) => {
|
|
6101
6620
|
const item = q ?? {};
|
|
6102
6621
|
const prompt = typeof item.prompt === "string" ? item.prompt.trim() : "";
|
|
6103
|
-
const
|
|
6104
|
-
const
|
|
6622
|
+
const kept = [];
|
|
6623
|
+
const indexMap = /* @__PURE__ */ new Map();
|
|
6624
|
+
for (const [i, o] of (Array.isArray(item.options) ? item.options : []).entries()) if (typeof o === "string" && o.trim() !== "") {
|
|
6625
|
+
indexMap.set(i, kept.length);
|
|
6626
|
+
kept.push(o.trim());
|
|
6627
|
+
}
|
|
6628
|
+
const options = kept;
|
|
6629
|
+
const rawAnswer = typeof item.answer === "number" && Number.isInteger(item.answer) ? item.answer : -1;
|
|
6630
|
+
const answer = indexMap.get(rawAnswer) ?? -1;
|
|
6105
6631
|
const explanation = typeof item.explanation === "string" ? item.explanation.trim() : "";
|
|
6106
6632
|
if (prompt === "") {
|
|
6107
6633
|
warnings.push(`question ${index + 1}: empty prompt dropped`);
|
|
@@ -6442,6 +6968,12 @@ function signalFetch(signal, baseFetch) {
|
|
|
6442
6968
|
signal
|
|
6443
6969
|
});
|
|
6444
6970
|
}
|
|
6971
|
+
/** Audit B7: imported bodies are untrusted input landing in the tutor's
|
|
6972
|
+
* context — the in-prompt lesson body and the in-chat course pack are capped
|
|
6973
|
+
* (the panel always renders the full content; oversized packs download from
|
|
6974
|
+
* the panel's course export instead of riding the conversation). */
|
|
6975
|
+
const LESSON_BODY_PROMPT_CAP = 24e3;
|
|
6976
|
+
const EXPORT_PROMPT_CAP = 1e5;
|
|
6445
6977
|
/** Total over a missing `meta` (events logged before a presentationMeta existed): renders nothing instead of throwing into the presenter fallback. */
|
|
6446
6978
|
const textBlocks = (lines) => (lines ?? []).map((text) => ({
|
|
6447
6979
|
type: "text",
|
|
@@ -6464,6 +6996,8 @@ function studyTools(store, deps = {}) {
|
|
|
6464
6996
|
let pendingDesign = null;
|
|
6465
6997
|
/** Which context-budget part of the pending brief the tutor last asked for. */
|
|
6466
6998
|
let pendingPart = 1;
|
|
6999
|
+
/** Audit B7: outstanding delete confirmations (courseId → token + expiry). */
|
|
7000
|
+
const pendingDeletes = /* @__PURE__ */ new Map();
|
|
6467
7001
|
/** Run a mutating state operation and persist. */
|
|
6468
7002
|
const mutate = (fn) => {
|
|
6469
7003
|
const result = fn(store.get());
|
|
@@ -6500,13 +7034,11 @@ function studyTools(store, deps = {}) {
|
|
|
6500
7034
|
}
|
|
6501
7035
|
}
|
|
6502
7036
|
} };
|
|
6503
|
-
const
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
|
|
6508
|
-
})
|
|
6509
|
-
};
|
|
7037
|
+
const importPresentationMeta = (_args, value) => importLines(value);
|
|
7038
|
+
const importPresent = { presentResult: (_args, result) => ({
|
|
7039
|
+
card: "generic",
|
|
7040
|
+
content: textBlocks(result.meta)
|
|
7041
|
+
}) };
|
|
6510
7042
|
const importMarkdown = defineTool({
|
|
6511
7043
|
name: "study_import_markdown",
|
|
6512
7044
|
description: "Import pasted markdown as a structured course: H2 (##) becomes a section, H3 (###) a lesson. Use for notes, single long documents, or content fetched by other means.",
|
|
@@ -6523,6 +7055,7 @@ function studyTools(store, deps = {}) {
|
|
|
6523
7055
|
},
|
|
6524
7056
|
output: {
|
|
6525
7057
|
...importOutput,
|
|
7058
|
+
presentationMeta: importPresentationMeta,
|
|
6526
7059
|
render: (_args, value) => [{
|
|
6527
7060
|
type: "text",
|
|
6528
7061
|
text: `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
|
|
@@ -6605,6 +7138,10 @@ function studyTools(store, deps = {}) {
|
|
|
6605
7138
|
type: "integer",
|
|
6606
7139
|
required: true
|
|
6607
7140
|
},
|
|
7141
|
+
designSeq: {
|
|
7142
|
+
type: "integer",
|
|
7143
|
+
description: "Pending-design sequence (audit C29): renders detect stale briefs."
|
|
7144
|
+
},
|
|
6608
7145
|
part: {
|
|
6609
7146
|
type: "integer",
|
|
6610
7147
|
description: "Brief part this call rendered (context-budget batching)."
|
|
@@ -6616,17 +7153,23 @@ function studyTools(store, deps = {}) {
|
|
|
6616
7153
|
}
|
|
6617
7154
|
}] } };
|
|
6618
7155
|
/** Shared render: the brief rides the design_required branch; imported stays the old summary. */
|
|
7156
|
+
/** Audit C29: every brief carries a sequence number; renders of a stale
|
|
7157
|
+
* value (a newer import replaced the pending design) say so instead of
|
|
7158
|
+
* presenting the wrong brief. */
|
|
7159
|
+
let designSeq = 0;
|
|
7160
|
+
const setPendingDesign = (pd) => {
|
|
7161
|
+
pendingDesign = pd;
|
|
7162
|
+
designSeq += 1;
|
|
7163
|
+
};
|
|
6619
7164
|
const designOrImportedRender = (_args, value) => [{
|
|
6620
7165
|
type: "text",
|
|
6621
|
-
text: value.status === "design_required" ? pendingDesign === null ? "Course design required — the brief is no longer pending; call the import tool again to re-fetch it." : renderDesignBrief(pendingDesign, pendingPart) : `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
|
|
7166
|
+
text: value.status === "design_required" ? pendingDesign === null ? "Course design required — the brief is no longer pending; call the import tool again to re-fetch it." : typeof value.designSeq === "number" && value.designSeq !== designSeq ? "This design brief is STALE — a newer import replaced the pending design. Call the import tool again for the fresh brief; do not design against this one." : renderDesignBrief(pendingDesign, pendingPart) : `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
|
|
6622
7167
|
}];
|
|
6623
|
-
const
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
})
|
|
6629
|
-
};
|
|
7168
|
+
const designOrImportedPresentationMeta = (_args, value) => value.status === "design_required" ? designBriefLines(value) : importLines(value);
|
|
7169
|
+
const designOrImportedPresent = { presentResult: (_args, result) => ({
|
|
7170
|
+
card: "generic",
|
|
7171
|
+
content: textBlocks(result.meta)
|
|
7172
|
+
}) };
|
|
6630
7173
|
const importFolder = defineTool({
|
|
6631
7174
|
name: "study_import_folder",
|
|
6632
7175
|
description: "Start importing a local folder: scans markdown, txt, html, Jupyter notebooks, rst/Rmd/org/adoc, and 30+ code file types (PDF/PPTX unsupported), then returns a design brief — the TUTOR designs the course structure from it and applies the result with study_apply_design (fully offline). Re-importing an already-imported path returns the existing course directly.",
|
|
@@ -6647,6 +7190,7 @@ function studyTools(store, deps = {}) {
|
|
|
6647
7190
|
},
|
|
6648
7191
|
output: {
|
|
6649
7192
|
...designOrImportedOutput,
|
|
7193
|
+
presentationMeta: designOrImportedPresentationMeta,
|
|
6650
7194
|
render: designOrImportedRender
|
|
6651
7195
|
},
|
|
6652
7196
|
async execute(args) {
|
|
@@ -6661,10 +7205,11 @@ function studyTools(store, deps = {}) {
|
|
|
6661
7205
|
const docs = inventory.docs;
|
|
6662
7206
|
if (docs.length === 0) throw new Error(`lookatstudy-plugin: no importable files found in ${args.path}`);
|
|
6663
7207
|
const title = args.title ?? basename(args.path.replaceAll("\\", "/"));
|
|
6664
|
-
|
|
7208
|
+
const pd = buildPendingDesignFromFolder(args.path, title, docs, {
|
|
6665
7209
|
translations: inventory.translations,
|
|
6666
7210
|
images: inventory.images
|
|
6667
7211
|
});
|
|
7212
|
+
setPendingDesign(pd);
|
|
6668
7213
|
const localImages = /* @__PURE__ */ new Map();
|
|
6669
7214
|
for (const img of inventory.images) {
|
|
6670
7215
|
if (img.absPath === "" || img.source === "pdf_page") continue;
|
|
@@ -6674,9 +7219,9 @@ function studyTools(store, deps = {}) {
|
|
|
6674
7219
|
localImages.set(img.path, `data:${img.mime};base64,${buf.toString("base64")}`);
|
|
6675
7220
|
} catch {}
|
|
6676
7221
|
}
|
|
6677
|
-
if (localImages.size > 0)
|
|
7222
|
+
if (localImages.size > 0) pd.localImages = localImages;
|
|
6678
7223
|
pendingPart = part;
|
|
6679
|
-
return designRequiredValue(
|
|
7224
|
+
return designRequiredValue(pd, part);
|
|
6680
7225
|
},
|
|
6681
7226
|
timeoutMs: 6e4,
|
|
6682
7227
|
presentCall: (args) => ({
|
|
@@ -6694,6 +7239,7 @@ function studyTools(store, deps = {}) {
|
|
|
6694
7239
|
pd.partCount = partCount;
|
|
6695
7240
|
return {
|
|
6696
7241
|
status: "design_required",
|
|
7242
|
+
designSeq,
|
|
6697
7243
|
repo: pd.repo,
|
|
6698
7244
|
branch: pd.branch,
|
|
6699
7245
|
courseTitle: partCount > 1 ? `${pd.courseTitle} (part ${part})` : pd.courseTitle,
|
|
@@ -6709,18 +7255,20 @@ function studyTools(store, deps = {}) {
|
|
|
6709
7255
|
* study_import_url's github branch (routing is the only difference). */
|
|
6710
7256
|
const runGithubImport = async (url, branch, exec, part = 1) => {
|
|
6711
7257
|
const { owner, repo } = parseGithubUrl(url);
|
|
7258
|
+
const canonicalRef = `https://github.com/${owner}/${repo}`;
|
|
6712
7259
|
const resolvedBranch = branch ?? "main";
|
|
6713
7260
|
const fetchFn = signalFetch(exec.signal, baseFetch);
|
|
6714
|
-
const existing = part <= 1 ? store.get().courses.find((c) => c.source === "github" && c.sourceRef ===
|
|
7261
|
+
const existing = part <= 1 ? store.get().courses.find((c) => c.source === "github" && c.sourceRef === canonicalRef) : void 0;
|
|
6715
7262
|
if (existing !== void 0) return {
|
|
6716
7263
|
status: "imported",
|
|
6717
7264
|
...toImportValue(existing)
|
|
6718
7265
|
};
|
|
6719
7266
|
const inventory = await fetchRepoInventory(owner, repo, resolvedBranch, fetchFn, void 0, exec.signal);
|
|
6720
|
-
|
|
6721
|
-
|
|
7267
|
+
const pd = buildPendingDesign(canonicalRef, owner, repo, inventory, await fetchFileOutlines(inventory.fileList.map((f) => f.path), owner, repo, inventory.branch, fetchFn, void 0, exec.signal));
|
|
7268
|
+
setPendingDesign(pd);
|
|
7269
|
+
if (pd.files.length === 0) throw new Error("lookatstudy-plugin: course files were discovered but no outlines could be fetched (CDN unreachable?)");
|
|
6722
7270
|
pendingPart = part;
|
|
6723
|
-
return designRequiredValue(
|
|
7271
|
+
return designRequiredValue(pd, part);
|
|
6724
7272
|
};
|
|
6725
7273
|
const importGithub = defineTool({
|
|
6726
7274
|
name: "study_import_github",
|
|
@@ -6742,6 +7290,7 @@ function studyTools(store, deps = {}) {
|
|
|
6742
7290
|
},
|
|
6743
7291
|
output: {
|
|
6744
7292
|
...designOrImportedOutput,
|
|
7293
|
+
presentationMeta: designOrImportedPresentationMeta,
|
|
6745
7294
|
render: designOrImportedRender
|
|
6746
7295
|
},
|
|
6747
7296
|
execute: (args, exec) => runGithubImport(args.url, args.branch, exec, args.part ?? 1),
|
|
@@ -6769,6 +7318,7 @@ function studyTools(store, deps = {}) {
|
|
|
6769
7318
|
},
|
|
6770
7319
|
output: {
|
|
6771
7320
|
...designOrImportedOutput,
|
|
7321
|
+
presentationMeta: designOrImportedPresentationMeta,
|
|
6772
7322
|
render: designOrImportedRender
|
|
6773
7323
|
},
|
|
6774
7324
|
async execute(args, exec) {
|
|
@@ -6794,26 +7344,28 @@ function studyTools(store, deps = {}) {
|
|
|
6794
7344
|
|
|
6795
7345
|
${p.text}`));
|
|
6796
7346
|
if (docs.length === 0) throw new Error("lookatstudy-plugin: CC subtitle text was empty after chunking");
|
|
6797
|
-
|
|
7347
|
+
const pd = buildPendingDesignFromUrl(identity, cc.title, docs);
|
|
7348
|
+
setPendingDesign(pd);
|
|
6798
7349
|
pendingPart = part;
|
|
6799
|
-
return designRequiredValue(
|
|
7350
|
+
return designRequiredValue(pd, part);
|
|
6800
7351
|
}
|
|
6801
7352
|
throw new Error(`lookatstudy-plugin: ${route.url} 是视频链接(YouTube/抖音需要 yt-dlp,插件环境不可用)。请让学习者把字幕/文稿粘贴进来,用 study_import_markdown 导入。`);
|
|
6802
7353
|
}
|
|
6803
7354
|
if (route.flavor === "arxiv") {
|
|
7355
|
+
const part = args.part ?? 1;
|
|
7356
|
+
const existingEarly = part <= 1 ? store.get().courses.find((c) => c.source === "url" && c.sourceRef === route.url) : void 0;
|
|
7357
|
+
if (existingEarly !== void 0) return {
|
|
7358
|
+
status: "imported",
|
|
7359
|
+
...toImportValue(existingEarly)
|
|
7360
|
+
};
|
|
6804
7361
|
const text = parsePdfText(await downloadToBuffer(route.pdfUrl, fetchFn, { signal: exec.signal }));
|
|
6805
7362
|
if (!text || text.replace(/\s+/g, "").length < 200) throw new Error("lookatstudy-plugin: arXiv PDF has no extractable text layer (scanned/image PDF) — try the HTML version or paste the abstract as markdown");
|
|
6806
7363
|
const docs = prepareSingleDoc(`arxiv-${route.arxivId}`, `# arXiv:${route.arxivId}\n\n${text}`);
|
|
6807
7364
|
if (docs.length === 0) throw new Error("lookatstudy-plugin: arXiv PDF text was empty after chunking");
|
|
6808
|
-
const
|
|
6809
|
-
|
|
6810
|
-
if (existing !== void 0) return {
|
|
6811
|
-
status: "imported",
|
|
6812
|
-
...toImportValue(existing)
|
|
6813
|
-
};
|
|
6814
|
-
pendingDesign = buildPendingDesignFromUrl(route.url, `arXiv:${route.arxivId}`, docs);
|
|
7365
|
+
const pd = buildPendingDesignFromUrl(route.url, `arXiv:${route.arxivId}`, docs);
|
|
7366
|
+
setPendingDesign(pd);
|
|
6815
7367
|
pendingPart = part;
|
|
6816
|
-
return designRequiredValue(
|
|
7368
|
+
return designRequiredValue(pd, part);
|
|
6817
7369
|
}
|
|
6818
7370
|
const part = args.part ?? 1;
|
|
6819
7371
|
const identity = normalizeUrlIdentity(route.url);
|
|
@@ -6822,16 +7374,18 @@ ${p.text}`));
|
|
|
6822
7374
|
status: "imported",
|
|
6823
7375
|
...toImportValue(existing)
|
|
6824
7376
|
};
|
|
6825
|
-
const
|
|
6826
|
-
|
|
6827
|
-
|
|
7377
|
+
const html = await guardedFetchText(route.url, {
|
|
7378
|
+
fetchImpl: fetchFn,
|
|
7379
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; LookatStudyPlugin/0.9)" }
|
|
7380
|
+
});
|
|
6828
7381
|
const article = extractArticle(html, route.url);
|
|
6829
7382
|
if (article === null) throw new Error(`lookatstudy-plugin: ${route.url} does not look like an article page (no readable body found) — login walls, indexes and app shells are rejected honestly rather than imported as noise`);
|
|
6830
7383
|
const docs = prepareSingleDoc(article.title.slice(0, 60), article.markdown);
|
|
6831
7384
|
if (docs.length === 0) throw new Error("lookatstudy-plugin: article body was empty after chunking");
|
|
6832
|
-
|
|
7385
|
+
const pd = buildPendingDesignFromUrl(identity, article.title, docs);
|
|
7386
|
+
setPendingDesign(pd);
|
|
6833
7387
|
pendingPart = part;
|
|
6834
|
-
return designRequiredValue(
|
|
7388
|
+
return designRequiredValue(pd, part);
|
|
6835
7389
|
},
|
|
6836
7390
|
timeoutMs: 18e4,
|
|
6837
7391
|
presentCall: (args) => ({
|
|
@@ -6916,11 +7470,11 @@ ${p.text}`));
|
|
|
6916
7470
|
required: true
|
|
6917
7471
|
},
|
|
6918
7472
|
firstLessonId: {
|
|
6919
|
-
type: "string",
|
|
7473
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
6920
7474
|
required: true
|
|
6921
7475
|
},
|
|
6922
7476
|
firstLessonTitle: {
|
|
6923
|
-
type: "string",
|
|
7477
|
+
oneOf: [{ type: "string" }, { type: "null" }],
|
|
6924
7478
|
required: true
|
|
6925
7479
|
},
|
|
6926
7480
|
droppedLessons: {
|
|
@@ -6977,7 +7531,7 @@ ${p.text}`));
|
|
|
6977
7531
|
created: state.courses.length > before
|
|
6978
7532
|
};
|
|
6979
7533
|
});
|
|
6980
|
-
|
|
7534
|
+
setPendingDesign(null);
|
|
6981
7535
|
pendingPart = 1;
|
|
6982
7536
|
return {
|
|
6983
7537
|
...value,
|
|
@@ -6991,7 +7545,6 @@ ${p.text}`));
|
|
|
6991
7545
|
title: "Apply course design",
|
|
6992
7546
|
kind: "edit"
|
|
6993
7547
|
}),
|
|
6994
|
-
presentationMeta: (_args, value) => [...importLines(value), ...value.droppedLessons > 0 ? [`${value.droppedLessons} dropped (files outside the brief)`] : []],
|
|
6995
7548
|
presentResult: (_args, result) => ({
|
|
6996
7549
|
card: "generic",
|
|
6997
7550
|
content: textBlocks(result.meta)
|
|
@@ -7183,6 +7736,7 @@ ${p.text}`));
|
|
|
7183
7736
|
description: "Course id from an import result or study_courses."
|
|
7184
7737
|
} },
|
|
7185
7738
|
output: {
|
|
7739
|
+
presentationMeta: (_args, value) => mapLines(value),
|
|
7186
7740
|
schema: {
|
|
7187
7741
|
type: "object",
|
|
7188
7742
|
additionalProperties: false,
|
|
@@ -7285,7 +7839,6 @@ ${p.text}`));
|
|
|
7285
7839
|
title: `Course map: ${args.courseId}`,
|
|
7286
7840
|
kind: "read"
|
|
7287
7841
|
}),
|
|
7288
|
-
presentationMeta: (_args, value) => mapLines(value),
|
|
7289
7842
|
presentResult: (_args, result) => ({
|
|
7290
7843
|
card: "generic",
|
|
7291
7844
|
content: textBlocks(result.meta)
|
|
@@ -7502,12 +8055,13 @@ ${p.text}`));
|
|
|
7502
8055
|
},
|
|
7503
8056
|
render: (_args, value) => [{
|
|
7504
8057
|
type: "text",
|
|
7505
|
-
text: `Lesson “${value.title}” — ${value.courseTitle} / ${value.sectionTitle}\nstatus ${value.status}${value.masteryPct === null ? "" : `, mastery ${value.masteryPct}%`}, ${value.correctCount}/${value.attempts} answers correct\nstrategy: ${value.strategy}\n` + (value.concepts === null ? "" : `concepts: ${value.concepts.map((c) => `${c.title} ${c.masteryPct}%${c.weak ? " ⚡weak" : ""}`).join(" · ")}\n`) + (value.examGuide === void 0 ? "" : `exam: ${value.examGuide.questionCount} questions (${value.examGuide.kcCount} KCs); per-question time ${value.examGuide.timeLimitRule}; stars ${value.examGuide.starsRule}\n`) + `starters: ${value.starters.map((s) => s.label).join(" / ")}\n\n
|
|
8058
|
+
text: `Lesson “${value.title}” — ${value.courseTitle} / ${value.sectionTitle}\nstatus ${value.status}${value.masteryPct === null ? "" : `, mastery ${value.masteryPct}%`}, ${value.correctCount}/${value.attempts} answers correct\nstrategy: ${value.strategy}\n` + (value.concepts === null ? "" : `concepts: ${value.concepts.map((c) => `${c.title} ${c.masteryPct}%${c.weak ? " ⚡weak" : ""}`).join(" · ")}\n`) + (value.examGuide === void 0 ? "" : `exam: ${value.examGuide.questionCount} questions (${value.examGuide.kcCount} KCs); per-question time ${value.examGuide.timeLimitRule}; stars ${value.examGuide.starsRule}\n`) + `starters: ${value.starters.map((s) => s.label).join(" / ")}\n\n` + (value.body.length > LESSON_BODY_PROMPT_CAP ? `${value.body.slice(0, LESSON_BODY_PROMPT_CAP)}\n…(lesson body truncated at ${LESSON_BODY_PROMPT_CAP} chars — the full body renders in the study panel)` : value.body) + `${value.nextLessonId === null ? "\n\n(this is the last lesson)" : `\n\n(next lesson: ${value.nextLessonId})`}`
|
|
7506
8059
|
}]
|
|
7507
8060
|
},
|
|
7508
8061
|
async execute(args) {
|
|
7509
8062
|
return mutate((state) => {
|
|
7510
|
-
const
|
|
8063
|
+
const ref = findLesson(state, args.lessonId);
|
|
8064
|
+
if (ref.lesson.kind !== "exam") attemptLesson(state, args.lessonId, /* @__PURE__ */ new Date());
|
|
7511
8065
|
state.focus = { lessonId: ref.lesson.id };
|
|
7512
8066
|
return toLessonValue(ref, state);
|
|
7513
8067
|
});
|
|
@@ -7550,6 +8104,7 @@ ${p.text}`));
|
|
|
7550
8104
|
}
|
|
7551
8105
|
},
|
|
7552
8106
|
output: {
|
|
8107
|
+
presentationMeta: (_args, value) => [answerLine(value)],
|
|
7553
8108
|
schema: {
|
|
7554
8109
|
type: "object",
|
|
7555
8110
|
additionalProperties: false,
|
|
@@ -7660,7 +8215,6 @@ ${p.text}`));
|
|
|
7660
8215
|
card: "generic",
|
|
7661
8216
|
title: `Record answer (${args.correct ? "correct" : "incorrect"}): ${args.lessonId}`
|
|
7662
8217
|
}),
|
|
7663
|
-
presentationMeta: (_args, value) => [answerLine(value)],
|
|
7664
8218
|
presentResult: (_args, result) => ({
|
|
7665
8219
|
card: "generic",
|
|
7666
8220
|
content: textBlocks(result.meta)
|
|
@@ -7687,6 +8241,7 @@ ${p.text}`));
|
|
|
7687
8241
|
}
|
|
7688
8242
|
},
|
|
7689
8243
|
output: {
|
|
8244
|
+
presentationMeta: (_args, value) => [`${value.stars}★`],
|
|
7690
8245
|
schema: {
|
|
7691
8246
|
type: "object",
|
|
7692
8247
|
additionalProperties: false,
|
|
@@ -7752,7 +8307,7 @@ ${p.text}`));
|
|
|
7752
8307
|
text: `Exam result: ${value.stars}★ (best ${value.bestStars}★, attempt ${value.attempts}). Offer the learner: ${value.actions.map((a) => a.label).join(" / ")}.` + (value.nextLessonId === null ? "" : ` Next topic: ${value.nextLessonId}.`)
|
|
7753
8308
|
}]
|
|
7754
8309
|
},
|
|
7755
|
-
execute(args) {
|
|
8310
|
+
async execute(args) {
|
|
7756
8311
|
return mutate((state) => {
|
|
7757
8312
|
const r = recordExamResult(state, args.lessonId, args.correct, args.total);
|
|
7758
8313
|
const actions = getPostQuizActions({
|
|
@@ -7789,7 +8344,6 @@ ${p.text}`));
|
|
|
7789
8344
|
card: "generic",
|
|
7790
8345
|
title: `Exam result: ${args.correct}/${args.total}`
|
|
7791
8346
|
}),
|
|
7792
|
-
presentationMeta: (_args, value) => [`${value.stars}★`],
|
|
7793
8347
|
presentResult: (_args, result) => ({
|
|
7794
8348
|
card: "generic",
|
|
7795
8349
|
content: textBlocks(result.meta)
|
|
@@ -7797,7 +8351,7 @@ ${p.text}`));
|
|
|
7797
8351
|
});
|
|
7798
8352
|
const examBankApplyTool = defineTool({
|
|
7799
8353
|
name: "study_exam_bank_apply",
|
|
7800
|
-
description: "Author the question bank for a section exam node (exam-v2). The exam page asks for a bank when the learner opens it; call this once with the full multiple-choice set. Question count follows planExamQuota on the section's KC union (clamp 5-15); each kcTitle MUST be one of the section lessons' concept titles (anti-hallucination — unknown KC titles are rejected; read the section with
|
|
8354
|
+
description: "Author the question bank for a section exam node (exam-v2). The exam page asks for a bank when the learner opens it; call this once with the full multiple-choice set. Question count follows planExamQuota on the section's KC union (clamp 5-15); each kcTitle MUST be one of the section lessons' concept titles (anti-hallucination — unknown KC titles are rejected; read the section lessons with study_lesson first). On a validation error the tutor fixes the bank and simply calls again. The learner answers in the exam page; grading is automatic (unanswered = wrong, best-of stars kept).",
|
|
7801
8355
|
parameters: {
|
|
7802
8356
|
lessonId: {
|
|
7803
8357
|
type: "string",
|
|
@@ -7841,6 +8395,7 @@ ${p.text}`));
|
|
|
7841
8395
|
}
|
|
7842
8396
|
},
|
|
7843
8397
|
output: {
|
|
8398
|
+
presentationMeta: (_args, value) => [`${value.questionCount}Q`],
|
|
7844
8399
|
schema: {
|
|
7845
8400
|
type: "object",
|
|
7846
8401
|
additionalProperties: false,
|
|
@@ -7868,7 +8423,7 @@ ${p.text}`));
|
|
|
7868
8423
|
text: `Exam bank ready: ${value.questionCount} questions across ${value.kcCount} knowledge components. The exam page settles into its ready state — tell the learner to hit 开始考试.`
|
|
7869
8424
|
}]
|
|
7870
8425
|
},
|
|
7871
|
-
execute(args) {
|
|
8426
|
+
async execute(args) {
|
|
7872
8427
|
return mutate((state) => {
|
|
7873
8428
|
const r = applyExamBank(state, args.lessonId, args.questions, /* @__PURE__ */ new Date());
|
|
7874
8429
|
return {
|
|
@@ -7883,7 +8438,6 @@ ${p.text}`));
|
|
|
7883
8438
|
card: "generic",
|
|
7884
8439
|
title: `Exam bank: ${Array.isArray(args.questions) ? String(args.questions.length) : "?"} questions`
|
|
7885
8440
|
}),
|
|
7886
|
-
presentationMeta: (_args, value) => [`${value.questionCount}Q`],
|
|
7887
8441
|
presentResult: (_args, result) => ({
|
|
7888
8442
|
card: "generic",
|
|
7889
8443
|
content: textBlocks(result.meta)
|
|
@@ -7898,6 +8452,7 @@ ${p.text}`));
|
|
|
7898
8452
|
description: "Lesson to complete."
|
|
7899
8453
|
} },
|
|
7900
8454
|
output: {
|
|
8455
|
+
presentationMeta: (_args, value) => completeLines(value),
|
|
7901
8456
|
schema: {
|
|
7902
8457
|
type: "object",
|
|
7903
8458
|
additionalProperties: false,
|
|
@@ -7952,7 +8507,6 @@ ${p.text}`));
|
|
|
7952
8507
|
card: "generic",
|
|
7953
8508
|
title: `Complete lesson: ${args.lessonId}`
|
|
7954
8509
|
}),
|
|
7955
|
-
presentationMeta: (_args, value) => completeLines(value),
|
|
7956
8510
|
presentResult: (_args, result) => ({
|
|
7957
8511
|
card: "generic",
|
|
7958
8512
|
content: textBlocks(result.meta)
|
|
@@ -7966,6 +8520,7 @@ ${p.text}`));
|
|
|
7966
8520
|
description: "Restrict to one course; omit to scan all courses."
|
|
7967
8521
|
} },
|
|
7968
8522
|
output: {
|
|
8523
|
+
presentationMeta: (_args, value) => dueLines(value),
|
|
7969
8524
|
schema: {
|
|
7970
8525
|
type: "object",
|
|
7971
8526
|
additionalProperties: false,
|
|
@@ -8027,7 +8582,6 @@ ${p.text}`));
|
|
|
8027
8582
|
title: "List due reviews",
|
|
8028
8583
|
kind: "search"
|
|
8029
8584
|
}),
|
|
8030
|
-
presentationMeta: (_args, value) => dueLines(value),
|
|
8031
8585
|
presentResult: (_args, result) => ({
|
|
8032
8586
|
card: "generic",
|
|
8033
8587
|
content: textBlocks(result.meta)
|
|
@@ -8050,6 +8604,7 @@ ${p.text}`));
|
|
|
8050
8604
|
}
|
|
8051
8605
|
},
|
|
8052
8606
|
output: {
|
|
8607
|
+
presentationMeta: (_args, value) => [reviewLine(value)],
|
|
8053
8608
|
schema: {
|
|
8054
8609
|
type: "object",
|
|
8055
8610
|
additionalProperties: false,
|
|
@@ -8107,7 +8662,6 @@ ${p.text}`));
|
|
|
8107
8662
|
card: "generic",
|
|
8108
8663
|
title: `Record review (quality ${args.quality}): ${args.lessonId}`
|
|
8109
8664
|
}),
|
|
8110
|
-
presentationMeta: (_args, value) => [reviewLine(value)],
|
|
8111
8665
|
presentResult: (_args, result) => ({
|
|
8112
8666
|
card: "generic",
|
|
8113
8667
|
content: textBlocks(result.meta)
|
|
@@ -8115,37 +8669,86 @@ ${p.text}`));
|
|
|
8115
8669
|
});
|
|
8116
8670
|
const deleteCourseTool = defineTool({
|
|
8117
8671
|
name: "study_delete_course",
|
|
8118
|
-
description: "Delete one course and all its progress
|
|
8119
|
-
parameters: {
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
8672
|
+
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.",
|
|
8673
|
+
parameters: {
|
|
8674
|
+
courseId: {
|
|
8675
|
+
type: "string",
|
|
8676
|
+
required: true,
|
|
8677
|
+
description: "Course to delete."
|
|
8678
|
+
},
|
|
8679
|
+
confirmToken: {
|
|
8680
|
+
type: "string",
|
|
8681
|
+
description: "The token from the confirm_required result, echoed verbatim after the learner confirms."
|
|
8682
|
+
}
|
|
8683
|
+
},
|
|
8124
8684
|
output: {
|
|
8125
8685
|
schema: {
|
|
8126
8686
|
type: "object",
|
|
8127
8687
|
additionalProperties: false,
|
|
8128
8688
|
properties: {
|
|
8129
|
-
|
|
8689
|
+
status: {
|
|
8690
|
+
type: "string",
|
|
8691
|
+
required: true,
|
|
8692
|
+
description: "confirm_required | deleted"
|
|
8693
|
+
},
|
|
8694
|
+
courseId: {
|
|
8130
8695
|
type: "string",
|
|
8131
8696
|
required: true
|
|
8132
8697
|
},
|
|
8698
|
+
title: {
|
|
8699
|
+
type: "string",
|
|
8700
|
+
description: "Present on confirm_required."
|
|
8701
|
+
},
|
|
8702
|
+
lessons: {
|
|
8703
|
+
type: "integer",
|
|
8704
|
+
description: "Present on confirm_required."
|
|
8705
|
+
},
|
|
8706
|
+
confirmToken: {
|
|
8707
|
+
type: "string",
|
|
8708
|
+
description: "Present on confirm_required — echo it in the second call."
|
|
8709
|
+
},
|
|
8710
|
+
deletedCourseId: {
|
|
8711
|
+
type: "string",
|
|
8712
|
+
description: "Present on deleted."
|
|
8713
|
+
},
|
|
8133
8714
|
remaining: {
|
|
8134
8715
|
type: "integer",
|
|
8135
|
-
|
|
8716
|
+
description: "Present on deleted."
|
|
8136
8717
|
}
|
|
8137
8718
|
}
|
|
8138
8719
|
},
|
|
8139
|
-
render: (_args, value) => [{
|
|
8720
|
+
render: (_args, value) => value.status === "confirm_required" ? [{
|
|
8721
|
+
type: "text",
|
|
8722
|
+
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).`
|
|
8723
|
+
}] : [{
|
|
8140
8724
|
type: "text",
|
|
8141
|
-
text: `Deleted course ${value.deletedCourseId}. ${value.remaining} courses remain.`
|
|
8725
|
+
text: `Deleted course ${value.deletedCourseId}. ${value.remaining} courses remain. Recoverable from the trash via study_restore_course.`
|
|
8142
8726
|
}]
|
|
8143
8727
|
},
|
|
8144
8728
|
async execute(args) {
|
|
8145
8729
|
return mutate((state) => {
|
|
8146
|
-
findCourse(state, args.courseId);
|
|
8730
|
+
const course = findCourse(state, args.courseId);
|
|
8731
|
+
const pending = pendingDeletes.get(args.courseId);
|
|
8732
|
+
const now = Date.now();
|
|
8733
|
+
if (typeof args.confirmToken !== "string" || pending === void 0 || pending.token !== args.confirmToken || pending.expires < now) {
|
|
8734
|
+
const token = randomBytes(8).toString("hex");
|
|
8735
|
+
pendingDeletes.set(args.courseId, {
|
|
8736
|
+
token,
|
|
8737
|
+
expires: now + 3e5
|
|
8738
|
+
});
|
|
8739
|
+
return {
|
|
8740
|
+
status: "confirm_required",
|
|
8741
|
+
courseId: course.id,
|
|
8742
|
+
title: course.title,
|
|
8743
|
+
lessons: course.sections.flatMap((s) => s.lessons).length,
|
|
8744
|
+
confirmToken: token
|
|
8745
|
+
};
|
|
8746
|
+
}
|
|
8747
|
+
pendingDeletes.delete(args.courseId);
|
|
8147
8748
|
deleteCourse(state, args.courseId);
|
|
8148
8749
|
return {
|
|
8750
|
+
status: "deleted",
|
|
8751
|
+
courseId: args.courseId,
|
|
8149
8752
|
deletedCourseId: args.courseId,
|
|
8150
8753
|
remaining: state.courses.length
|
|
8151
8754
|
};
|
|
@@ -8158,6 +8761,59 @@ ${p.text}`));
|
|
|
8158
8761
|
rawInput: args.courseId
|
|
8159
8762
|
})
|
|
8160
8763
|
});
|
|
8764
|
+
const restoreCourseTool = defineTool({
|
|
8765
|
+
name: "study_restore_course",
|
|
8766
|
+
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.",
|
|
8767
|
+
parameters: { courseId: {
|
|
8768
|
+
type: "string",
|
|
8769
|
+
required: true,
|
|
8770
|
+
description: "Trashed course id to restore."
|
|
8771
|
+
} },
|
|
8772
|
+
output: {
|
|
8773
|
+
schema: {
|
|
8774
|
+
type: "object",
|
|
8775
|
+
additionalProperties: false,
|
|
8776
|
+
properties: {
|
|
8777
|
+
restoredCourseId: {
|
|
8778
|
+
type: "string",
|
|
8779
|
+
required: true
|
|
8780
|
+
},
|
|
8781
|
+
title: {
|
|
8782
|
+
type: "string",
|
|
8783
|
+
required: true
|
|
8784
|
+
},
|
|
8785
|
+
lessonCount: {
|
|
8786
|
+
type: "integer",
|
|
8787
|
+
required: true
|
|
8788
|
+
},
|
|
8789
|
+
remainingTrash: {
|
|
8790
|
+
type: "integer",
|
|
8791
|
+
required: true
|
|
8792
|
+
}
|
|
8793
|
+
}
|
|
8794
|
+
},
|
|
8795
|
+
render: (_args, value) => [{
|
|
8796
|
+
type: "text",
|
|
8797
|
+
text: `Restored course “${value.title}” from the trash (${value.lessonCount} lessons back; ${value.remainingTrash} still trashed).`
|
|
8798
|
+
}]
|
|
8799
|
+
},
|
|
8800
|
+
async execute(args) {
|
|
8801
|
+
return mutate((state) => {
|
|
8802
|
+
const course = restoreCourse(state, args.courseId);
|
|
8803
|
+
return {
|
|
8804
|
+
restoredCourseId: course.id,
|
|
8805
|
+
title: course.title,
|
|
8806
|
+
lessonCount: course.sections.flatMap((s) => s.lessons).length,
|
|
8807
|
+
remainingTrash: (state.trash ?? []).length
|
|
8808
|
+
};
|
|
8809
|
+
});
|
|
8810
|
+
},
|
|
8811
|
+
presentCall: (args) => ({
|
|
8812
|
+
card: "generic",
|
|
8813
|
+
title: `Restore course: ${args.courseId}`,
|
|
8814
|
+
kind: "read"
|
|
8815
|
+
})
|
|
8816
|
+
});
|
|
8161
8817
|
const defineConceptsTool = defineTool({
|
|
8162
8818
|
name: "study_define_concepts",
|
|
8163
8819
|
description: "Define a lesson's knowledge components — the 2–7 independently quizzable units mastery tracks. Call this the FIRST time you teach a lesson, derived from its content. Titles ≤10 characters; descriptions say what understanding this concept means. Lesson mastery is the WEAKEST concept; cover weak ones (⚡) first when quizzing.",
|
|
@@ -8261,6 +8917,12 @@ ${p.text}`));
|
|
|
8261
8917
|
}
|
|
8262
8918
|
},
|
|
8263
8919
|
output: {
|
|
8920
|
+
presentationMeta: (_args, value) => ({
|
|
8921
|
+
kind: "study-proposal-created",
|
|
8922
|
+
proposalId: value.proposalId,
|
|
8923
|
+
lessonTitle: value.lessonTitle,
|
|
8924
|
+
rationale: value.rationale
|
|
8925
|
+
}),
|
|
8264
8926
|
schema: {
|
|
8265
8927
|
type: "object",
|
|
8266
8928
|
additionalProperties: false,
|
|
@@ -8309,12 +8971,6 @@ ${p.text}`));
|
|
|
8309
8971
|
card: "generic",
|
|
8310
8972
|
title: `Propose mastery: ${args.lessonId}`
|
|
8311
8973
|
}),
|
|
8312
|
-
presentationMeta: (_args, value) => ({
|
|
8313
|
-
kind: "study-proposal-created",
|
|
8314
|
-
proposalId: value.proposalId,
|
|
8315
|
-
lessonTitle: value.lessonTitle,
|
|
8316
|
-
rationale: value.rationale
|
|
8317
|
-
}),
|
|
8318
8974
|
presentResult: (_args, result) => ({
|
|
8319
8975
|
card: "generic",
|
|
8320
8976
|
content: textBlocks([`🎓 Proposed mastery for “${result.meta?.lessonTitle ?? "lesson"}”: ${result.meta?.rationale ?? ""}`])
|
|
@@ -8336,6 +8992,11 @@ ${p.text}`));
|
|
|
8336
8992
|
}
|
|
8337
8993
|
},
|
|
8338
8994
|
output: {
|
|
8995
|
+
presentationMeta: (_args, value) => ({
|
|
8996
|
+
kind: "study-proposal-resolved",
|
|
8997
|
+
proposalId: value.proposalId,
|
|
8998
|
+
status: value.status
|
|
8999
|
+
}),
|
|
8339
9000
|
schema: {
|
|
8340
9001
|
type: "object",
|
|
8341
9002
|
additionalProperties: false,
|
|
@@ -8374,11 +9035,6 @@ ${p.text}`));
|
|
|
8374
9035
|
card: "generic",
|
|
8375
9036
|
title: `Resolve proposal: ${args.proposalId}`
|
|
8376
9037
|
}),
|
|
8377
|
-
presentationMeta: (_args, value) => ({
|
|
8378
|
-
kind: "study-proposal-resolved",
|
|
8379
|
-
proposalId: value.proposalId,
|
|
8380
|
-
status: value.status
|
|
8381
|
-
}),
|
|
8382
9038
|
presentResult: (_args, result) => ({
|
|
8383
9039
|
card: "generic",
|
|
8384
9040
|
content: textBlocks([`Proposal ${result.meta?.proposalId ?? "?"} ${result.meta?.status ?? ""}.`])
|
|
@@ -8527,7 +9183,7 @@ ${p.text}`));
|
|
|
8527
9183
|
text: `Translation stored for “${value.lessonTitle}” (${value.lang}, ${value.chars} chars) — the blackboard now interleaves the original with it.`
|
|
8528
9184
|
}]
|
|
8529
9185
|
},
|
|
8530
|
-
execute(args) {
|
|
9186
|
+
async execute(args) {
|
|
8531
9187
|
return mutate((state) => {
|
|
8532
9188
|
const ref = findLesson(state, args.lessonId);
|
|
8533
9189
|
if (args.markdown.trim() === "") throw new Error("lookatstudy-plugin: translation markdown is empty");
|
|
@@ -8617,7 +9273,7 @@ ${p.text}`));
|
|
|
8617
9273
|
text: `Consolidation window since ${value.since ?? "(beginning)"}: ${value.counts.friction} friction, ${value.counts.practice} practice entries.` + (value.entries.length === 0 ? " Nothing to distill — tell the learner their memory is up to date." : " Distill these into 0–3 study_remember writes (global / pattern / lesson), then summarize in one line.")
|
|
8618
9274
|
}]
|
|
8619
9275
|
},
|
|
8620
|
-
execute() {
|
|
9276
|
+
async execute() {
|
|
8621
9277
|
return mutate((state) => {
|
|
8622
9278
|
const window = gatherConsolidationWindow(state);
|
|
8623
9279
|
const watermark = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -8667,26 +9323,30 @@ ${p.text}`));
|
|
|
8667
9323
|
markdown: {
|
|
8668
9324
|
type: "string",
|
|
8669
9325
|
required: true
|
|
9326
|
+
},
|
|
9327
|
+
truncated: {
|
|
9328
|
+
type: "boolean",
|
|
9329
|
+
description: "true when the in-chat markdown was capped — the complete pack downloads from the study panel instead."
|
|
8670
9330
|
}
|
|
8671
9331
|
}
|
|
8672
9332
|
},
|
|
8673
9333
|
render: (_args, value) => [{
|
|
8674
9334
|
type: "text",
|
|
8675
|
-
text: `Course pack “${value.title}” — ${value.lessonCount} lessons, ${value.chars} chars. Give the learner the markdown below (copyable); importing it goes through study_import_markdown.
|
|
8676
|
-
|
|
8677
|
-
` + value.markdown
|
|
9335
|
+
text: `Course pack “${value.title}” — ${value.lessonCount} lessons, ${value.chars} chars. ` + (value.truncated === true ? "The pack exceeds the in-chat cap — only its head is shown; the complete file downloads from the study panel (course card → 导出课程包).\n\n" : "Give the learner the markdown below (copyable); importing it goes through study_import_markdown.\n\n") + value.markdown
|
|
8678
9336
|
}]
|
|
8679
9337
|
},
|
|
8680
|
-
execute(args) {
|
|
9338
|
+
async execute(args) {
|
|
8681
9339
|
const course = findCourse(store.get(), args.courseId);
|
|
8682
|
-
const
|
|
9340
|
+
const full = courseToPackMarkdown(course);
|
|
8683
9341
|
const lessonCount = course.sections.reduce((n, sec) => n + sec.lessons.filter((l) => l.kind !== "exam").length, 0);
|
|
9342
|
+
const truncated = full.length > EXPORT_PROMPT_CAP;
|
|
8684
9343
|
return {
|
|
8685
9344
|
courseId: course.id,
|
|
8686
9345
|
title: course.title,
|
|
8687
9346
|
lessonCount,
|
|
8688
|
-
chars:
|
|
8689
|
-
markdown
|
|
9347
|
+
chars: full.length,
|
|
9348
|
+
markdown: truncated ? `${full.slice(0, EXPORT_PROMPT_CAP)}\n…(pack truncated at ${EXPORT_PROMPT_CAP} chars)` : full,
|
|
9349
|
+
truncated
|
|
8690
9350
|
};
|
|
8691
9351
|
},
|
|
8692
9352
|
isConcurrencySafe: () => true,
|
|
@@ -9019,24 +9679,28 @@ ${p.text}`));
|
|
|
9019
9679
|
title: `Generate practice card: ${typeof args.title === "string" ? args.title : "练习"} (${args.questions?.length ?? 0} questions)`
|
|
9020
9680
|
})
|
|
9021
9681
|
});
|
|
9022
|
-
/** Record one sanitized artifact on its lesson (shared by the artifact tools).
|
|
9023
|
-
|
|
9024
|
-
|
|
9025
|
-
|
|
9026
|
-
|
|
9027
|
-
|
|
9028
|
-
|
|
9029
|
-
|
|
9030
|
-
|
|
9031
|
-
|
|
9682
|
+
/** Record one sanitized artifact on its lesson (shared by the artifact tools).
|
|
9683
|
+
* C23: generic over the sanitized shape so each tool's execute returns its
|
|
9684
|
+
* own literal (checked against the tool's inferred output type). */
|
|
9685
|
+
const recordSanitized = async (lessonId, result, type) => {
|
|
9686
|
+
return await mutate((state) => {
|
|
9687
|
+
const id = artifactId(type, result.data);
|
|
9688
|
+
const stored = recordArtifact(state, lessonId, {
|
|
9689
|
+
id,
|
|
9690
|
+
artifactType: type,
|
|
9691
|
+
title: typeof result.data.title === "string" ? result.data.title : type,
|
|
9692
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9693
|
+
hash: id.slice(type.length + 1),
|
|
9694
|
+
data: result.data
|
|
9695
|
+
});
|
|
9696
|
+
return {
|
|
9697
|
+
artifactType: type,
|
|
9698
|
+
artifactId: stored.artifact.id,
|
|
9699
|
+
created: stored.created,
|
|
9700
|
+
...stored.artifact.data
|
|
9701
|
+
};
|
|
9032
9702
|
});
|
|
9033
|
-
|
|
9034
|
-
artifactType: type,
|
|
9035
|
-
artifactId: stored.artifact.id,
|
|
9036
|
-
created: stored.created,
|
|
9037
|
-
...stored.artifact.data
|
|
9038
|
-
};
|
|
9039
|
-
});
|
|
9703
|
+
};
|
|
9040
9704
|
return [
|
|
9041
9705
|
importMarkdown,
|
|
9042
9706
|
importFolder,
|
|
@@ -9053,6 +9717,7 @@ ${p.text}`));
|
|
|
9053
9717
|
dueReviewsTool,
|
|
9054
9718
|
recordReviewTool,
|
|
9055
9719
|
deleteCourseTool,
|
|
9720
|
+
restoreCourseTool,
|
|
9056
9721
|
defineConceptsTool,
|
|
9057
9722
|
proposeMasteryTool,
|
|
9058
9723
|
resolveProposalTool,
|
|
@@ -9558,6 +10223,18 @@ function soulText(state) {
|
|
|
9558
10223
|
return state.active ? SOULS[state.mode] : "";
|
|
9559
10224
|
}
|
|
9560
10225
|
/**
|
|
10226
|
+
* Audit B7: imported titles, friction summaries, and proposal rationales are
|
|
10227
|
+
* untrusted text (repo names, article titles, repo-derived content) landing
|
|
10228
|
+
* in the per-turn system prompt — flatten to one line, strip control
|
|
10229
|
+
* characters, and cap the length so nothing can forge prompt structure
|
|
10230
|
+
* mid-section (newlines + fake section markers).
|
|
10231
|
+
*/
|
|
10232
|
+
function sanitizePromptText(value, max = 120) {
|
|
10233
|
+
if (value === null || value === void 0) return "";
|
|
10234
|
+
const flat = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
10235
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
10236
|
+
}
|
|
10237
|
+
/**
|
|
9561
10238
|
* Render the learner snapshot (LookatStudy's per-turn volatile tail) as the
|
|
9562
10239
|
* dynamic runtime context: focus, strategy band, concepts with weak flags,
|
|
9563
10240
|
* recent friction, memory slots, due count, pending proposal. Dormant
|
|
@@ -9568,19 +10245,39 @@ function snapshotSectionText(state) {
|
|
|
9568
10245
|
const snap = learnerSnapshot(state, /* @__PURE__ */ new Date());
|
|
9569
10246
|
if (snap.focus === null) return snap.dueCount === 0 ? "" : `【学习者当前状态】\n今日待复习: ${snap.dueCount} 项(study_due_reviews)`;
|
|
9570
10247
|
const lines = ["【学习者当前状态】"];
|
|
9571
|
-
lines.push(`焦点: ${snap.focus.courseTitle} [courseId ${snap.focus.courseId}] / ${snap.focus.lessonTitle} [lessonId ${snap.focus.lessonId}](${snap.focus.status}${snap.focus.masteryPct === null ? "" : `, 掌握度 ${snap.focus.masteryPct}%`})`);
|
|
10248
|
+
lines.push(`焦点: ${sanitizePromptText(snap.focus.courseTitle)} [courseId ${snap.focus.courseId}] / ${sanitizePromptText(snap.focus.lessonTitle)} [lessonId ${snap.focus.lessonId}](${snap.focus.status}${snap.focus.masteryPct === null ? "" : `, 掌握度 ${snap.focus.masteryPct}%`})`);
|
|
9572
10249
|
if (snap.strategy !== null) lines.push(`教学策略: ${snap.strategy}`);
|
|
9573
|
-
if (snap.concepts !== null && snap.concepts.length > 0) lines.push(`知识点(课级掌握度 = 最薄弱知识点): ${snap.concepts.map((c) => `${c.title} ${c.masteryPct}%${c.weak ? " ⚡薄弱" : ""}`).join(" · ")}`);
|
|
9574
|
-
if (snap.friction.length > 0) lines.push(`近期卡点(共 ${snap.friction.length} 条): ${snap.friction.map((f) => `${f.category}${f.summary === null ? "" : `: ${f.summary}`}`).join(" / ")}`);
|
|
10250
|
+
if (snap.concepts !== null && snap.concepts.length > 0) lines.push(`知识点(课级掌握度 = 最薄弱知识点): ${snap.concepts.map((c) => `${sanitizePromptText(c.title)} ${c.masteryPct}%${c.weak ? " ⚡薄弱" : ""}`).join(" · ")}`);
|
|
10251
|
+
if (snap.friction.length > 0) lines.push(`近期卡点(共 ${snap.friction.length} 条): ${snap.friction.map((f) => `${f.category}${f.summary === null ? "" : `: ${sanitizePromptText(f.summary, 80)}`}`).join(" / ")}`);
|
|
9575
10252
|
const memory = [
|
|
9576
|
-
snap.memoryGlobal === null ? "" : `整体: ${snap.memoryGlobal}`,
|
|
9577
|
-
snap.memoryLesson === null ? "" : `本课: ${snap.memoryLesson}`,
|
|
9578
|
-
snap.memoryPattern === null ? "" : `模式: ${snap.memoryPattern}`
|
|
10253
|
+
snap.memoryGlobal === null ? "" : `整体: ${sanitizePromptText(snap.memoryGlobal, 200)}`,
|
|
10254
|
+
snap.memoryLesson === null ? "" : `本课: ${sanitizePromptText(snap.memoryLesson, 200)}`,
|
|
10255
|
+
snap.memoryPattern === null ? "" : `模式: ${sanitizePromptText(snap.memoryPattern, 200)}`
|
|
9579
10256
|
].filter(Boolean);
|
|
9580
10257
|
if (memory.length > 0) lines.push(`记忆: ${memory.join(" | ")}`);
|
|
9581
10258
|
if (snap.dueCount > 0) lines.push(`今日待复习: ${snap.dueCount} 项`);
|
|
9582
|
-
if (snap.pendingProposal !== null) lines.push(`待决提案 ${snap.pendingProposal.id}: ${snap.pendingProposal.rationale}(等学习者表态)`);
|
|
10259
|
+
if (snap.pendingProposal !== null) lines.push(`待决提案 ${snap.pendingProposal.id}: ${sanitizePromptText(snap.pendingProposal.rationale, 160)}(等学习者表态)`);
|
|
9583
10260
|
const focusCourse = state.courses.find((c) => c.id === snap.focus?.courseId);
|
|
10261
|
+
if (focusCourse?.threadScope === "course" && snap.focus !== null) {
|
|
10262
|
+
const group = state.lessonThreads[threadOwnerKey("course", focusCourse.id, snap.focus.lessonId)];
|
|
10263
|
+
const touched = group?.threads.find((t) => t.id === group.active)?.touchedLessons;
|
|
10264
|
+
const titleOf = /* @__PURE__ */ new Map();
|
|
10265
|
+
for (const sec of focusCourse.sections) for (const l of sec.lessons) titleOf.set(l.id, l.title);
|
|
10266
|
+
const digest = focusCourse.sections.flatMap((sec) => sec.lessons.filter((l) => l.kind !== "exam")).slice(0, 60).map((l) => {
|
|
10267
|
+
const pct = l.mastery === null ? "" : ` ${String(Math.round(l.mastery * 100))}%`;
|
|
10268
|
+
const weak = (conceptViews(l) ?? []).some((c) => c.weak) ? " ⚡薄弱" : "";
|
|
10269
|
+
const fric = l.friction.length > 0 ? ` ⚑卡点${String(l.friction.length)}` : "";
|
|
10270
|
+
return `- ${sanitizePromptText(l.title)}[${String(l.status)}${pct}]${weak}${fric}`;
|
|
10271
|
+
});
|
|
10272
|
+
lines.push("");
|
|
10273
|
+
lines.push("【本课程进度摘要】(跨课时连续对话:本线跨课时延续,换课时不改线)");
|
|
10274
|
+
lines.push(...digest);
|
|
10275
|
+
if (touched !== void 0 && touched.length > 0) {
|
|
10276
|
+
const names = touched.map((id) => sanitizePromptText(titleOf.get(id) ?? id)).join(" · ");
|
|
10277
|
+
lines.push(`本对话线已覆盖课时: ${names}; 当前焦点: ${sanitizePromptText(snap.focus.lessonTitle)}`);
|
|
10278
|
+
}
|
|
10279
|
+
lines.push("出跨课时综合题前,先调用 study_lesson 调出相关课时正文再命题——组合题优先利用本线已有的跨课时现场。");
|
|
10280
|
+
}
|
|
9584
10281
|
if (focusCourse?.languageTarget != null && focusCourse.languageTarget !== "") {
|
|
9585
10282
|
const t = localeToLanguageName(focusCourse.languageTarget);
|
|
9586
10283
|
lines.push("");
|