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/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": "导师思考中…",
@@ -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}」的章节测验出题:先用 study_view 读一遍本节课时,再按各课时的知识点用 study_exam_bank_apply 一次性提交整份题库(单选 5-15 题,kcTitle 必须用本节的概念标题,每题附一句话解析)。题库就绪后告诉我可以开考。",
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 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.",
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, a corrupt file fails loud.
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`). Newer files than this code knows are rejected.
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 (!existsSync(path)) return emptyState();
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 (write a sibling temp file, then rename).
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
- renameSync(tmp, path);
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
- const sameTitle = state.courses.find((c) => c.id === base && c.title === parsed.title);
1697
- if (sameTitle !== void 0) return sameTitle;
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
- * Drop a course from state; unknown ids fail loud.
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
- 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];
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 study_view before authoring`);
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; history snapshots stay self-contained). */
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
- for (const attempt of ref.lesson.examAttemptLog ?? []) if (attempt.finishedAt === null) submitExamAttempt(state, lessonId, attempt.id, true, /* @__PURE__ */ new Date());
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
- ref.lesson.examAttemptLog = [...log, attempt];
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 bank = ref.lesson.examBank;
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[lessonId] ?? {
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[lessonId] = group;
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[lessonId] = group;
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, lessonId, group) {
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 (group.active !== null) state.lessonSessions[lessonId] = group.active;
2203
- else delete state.lessonSessions[lessonId];
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 group = state.lessonThreads?.[lessonId];
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 group = state.lessonThreads?.[lessonId];
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, lessonId, group);
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 group = state.lessonThreads?.[lessonId];
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, lessonId, group);
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) return;
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 course = findCourse(state, lessonId === void 0 ? "" : lessonId.slice(0, lessonId.lastIndexOf(":")));
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
- return await readJsonBody(req, maxBytes);
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
- if (chunks.reduce((n, c) => n + c.length, 0) > maxBytes) {
2949
- reject(/* @__PURE__ */ new Error("request body too large"));
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
- resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
3266
+ done(null, JSON.parse(Buffer.concat(chunks).toString("utf8")));
2956
3267
  } catch {
2957
- reject(/* @__PURE__ */ new Error("request body is not valid JSON"));
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
- sendJson(res, 200, {
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
- bindLessonThread(deps.store.get(), body.lessonId, body.sessionId, title);
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-B9IDkiGM.mjs");
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-B9IDkiGM.mjs");
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-BhyqPImb.mjs");
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-oH96guBW.mjs");
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
- return `https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${path.replace(/^\.\//, "").replace(/^\//, "")}`;
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: false,
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: false,
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 raw = await (await fetchFn(lane.url.startsWith("//") ? `https:${lane.url}` : lane.url, {
5509
- signal,
5510
- headers: BILI_HEADERS
5511
- })).text();
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 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;
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 importPresent = {
6504
- presentationMeta: (_args, value) => importLines(value),
6505
- presentResult: (_args, result) => ({
6506
- card: "generic",
6507
- content: textBlocks(result.meta)
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 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
- };
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
- pendingDesign = buildPendingDesignFromFolder(args.path, title, docs, {
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) pendingDesign.localImages = localImages;
7222
+ if (localImages.size > 0) pd.localImages = localImages;
6678
7223
  pendingPart = part;
6679
- return designRequiredValue(pendingDesign, part);
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 === url) : void 0;
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
- 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?)");
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(pendingDesign, part);
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
- pendingDesign = buildPendingDesignFromUrl(identity, cc.title, docs);
7347
+ const pd = buildPendingDesignFromUrl(identity, cc.title, docs);
7348
+ setPendingDesign(pd);
6798
7349
  pendingPart = part;
6799
- return designRequiredValue(pendingDesign, part);
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 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);
7365
+ const pd = buildPendingDesignFromUrl(route.url, `arXiv:${route.arxivId}`, docs);
7366
+ setPendingDesign(pd);
6815
7367
  pendingPart = part;
6816
- return designRequiredValue(pendingDesign, part);
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 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();
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
- pendingDesign = buildPendingDesignFromUrl(identity, article.title, docs);
7385
+ const pd = buildPendingDesignFromUrl(identity, article.title, docs);
7386
+ setPendingDesign(pd);
6833
7387
  pendingPart = part;
6834
- return designRequiredValue(pendingDesign, part);
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
- pendingDesign = null;
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${value.body}${value.nextLessonId === null ? "\n\n(this is the last lesson)" : `\n\n(next lesson: ${value.nextLessonId})`}`
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 { ref } = attemptLesson(state, args.lessonId, /* @__PURE__ */ new Date());
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 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).",
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. Ask the learner before calling.",
8119
- parameters: { courseId: {
8120
- type: "string",
8121
- required: true,
8122
- description: "Course to delete."
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
- deletedCourseId: {
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
- required: true
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 markdown = courseToPackMarkdown(course);
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: markdown.length,
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
- 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
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
- return {
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("");