dsh-plugin-lookatstudy 0.22.1 → 0.22.2

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