dsh-plugin-lookatstudy 0.11.0 → 0.12.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,11 +1,26 @@
1
+ import { n as inflateZlib } from "./inflate-DIKUrTRi.mjs";
2
+ import { t as extractArticle } from "./html-article-Da8ksU0i.mjs";
1
3
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
4
  import { basename, dirname, join, relative, sep } from "node:path";
3
5
  import z from "@deepseek-ai/schemastery";
6
+ import { fileURLToPath } from "node:url";
4
7
  import { homedir } from "node:os";
5
8
  import { randomBytes } from "node:crypto";
6
- import { defineTool } from "@deepseek-ai/dsh-tools";
7
9
  import { readFile, readdir } from "node:fs/promises";
10
+ import { defineTool } from "@deepseek-ai/dsh-tools";
8
11
  import https from "node:https";
12
+ //#region \0rolldown/runtime.js
13
+ var __defProp = Object.defineProperty;
14
+ var __exportAll = (all, no_symbols) => {
15
+ let target = {};
16
+ for (var name in all) __defProp(target, name, {
17
+ get: all[name],
18
+ enumerable: true
19
+ });
20
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
21
+ return target;
22
+ };
23
+ //#endregion
9
24
  //#region src/config.ts
10
25
  /**
11
26
  * Plugin configuration schema (Schemastery) for dsh-plugin-lookatstudy.
@@ -39,9 +54,27 @@ const Config = z.object({
39
54
  function escapeHtml(text) {
40
55
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
41
56
  }
42
- /** Render inline markup (code, bold, italic, links) over escaped text. */
57
+ /** Render inline markup (code, bold, italic, links, images) over escaped text.
58
+ * Image src allowlist: https?:// or data:image/ — folder imports inline local
59
+ * images as data URLs (capped), GitHub imports reference jsDelivr; anything
60
+ * else stays literal text (no broken relative images, no exotic schemes). */
43
61
  function inline(escaped) {
44
- return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noreferrer\">$1</a>");
62
+ return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+|data:image\/[^)\s]+)\)/g, (_m, alt, src) => `<img src="${src}" alt="${alt}" loading="lazy">`).replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noreferrer\">$1</a>");
63
+ }
64
+ /** Interleave one body with its translation, paragraph by paragraph (upstream
65
+ * translation-layout "microsoft" semantics: original paragraph then its
66
+ * translation as a quote, tight pairing for bilingual reading; length
67
+ * mismatches append the remainder in order). Pure. */
68
+ function renderBilingual(bodyMd, translationMd) {
69
+ const par = (md) => md.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p !== "");
70
+ const a = par(bodyMd);
71
+ const b = par(translationMd);
72
+ const out = [];
73
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
74
+ if (a[i] !== void 0) out.push(a[i]);
75
+ if (b[i] !== void 0) out.push(`> ${b[i].split("\n").join("\n> ")}`);
76
+ }
77
+ return out.join("\n\n");
45
78
  }
46
79
  /** True when the line opens a GFM table row (pipes with a delimiter row next). */
47
80
  function isTableRow(line) {
@@ -163,6 +196,44 @@ function renderMarkdown(md) {
163
196
  return out.join("\n");
164
197
  }
165
198
  //#endregion
199
+ //#region src/vendor/math-normalize.ts
200
+ /**
201
+ * math-normalize —— 数学记法归一(v0.19 数学渲染前置,纯函数)。
202
+ *
203
+ * `\(...\)` / `\[...\]` 是 LaTeX 的原生行内/行间记法,remark-math 只认 `$`/
204
+ * `$$`。导入的内容(AI 讲解/转写稿/粘贴文本)两种记法都可能出现,渲染前统一
205
+ * 归一到 `$` 记法。幂等;**围栏代码块内不动**(代码里的 `\(` 是字面量)。
206
+ */
207
+ function normalizeMathNotation(md) {
208
+ const out = [];
209
+ let inFence = false;
210
+ for (const line of md.split("\n")) {
211
+ if (/^[ \t]*(?:```|~~~)/.test(line)) {
212
+ out.push(line);
213
+ inFence = !inFence;
214
+ continue;
215
+ }
216
+ out.push(inFence ? line : line.replace(/\\\[|\\\]|\\\(|\\\)/g, (tok) => tok === "\\[" || tok === "\\]" ? "$$" : "$"));
217
+ }
218
+ return out.join("\n");
219
+ }
220
+ //#endregion
221
+ //#region src/vendor/xp.ts
222
+ /** Level curve (upstream): level = floor(sqrt(total/50)) — quadratic spans. */
223
+ function levelFromTotalXp(totalXp) {
224
+ const safe = Math.max(0, Math.floor(totalXp));
225
+ const level = Math.floor(Math.sqrt(safe / 50));
226
+ const curStart = 50 * level * level;
227
+ const span = 50 * (level + 1) * (level + 1) - curStart;
228
+ const into = safe - curStart;
229
+ return {
230
+ level,
231
+ pct: span > 0 ? Math.min(100, Math.round(into / span * 100)) : 0,
232
+ intoLevel: into,
233
+ levelSpan: span
234
+ };
235
+ }
236
+ //#endregion
166
237
  //#region src/vendor/sm2.ts
167
238
  function computeSm2(prev, quality, now = /* @__PURE__ */ new Date()) {
168
239
  let { easeFactor, intervalDays, repetitions } = prev;
@@ -227,6 +298,85 @@ function masteryToCrown(mastery) {
227
298
  if (mastery < .9) return 4;
228
299
  return 5;
229
300
  }
301
+ /**
302
+ * 题量规划:目标题数 = clamp(ceil(KC数 × 1.5), 5, 15),round-robin 分配到各 KC。
303
+ * 返回与 kcTitles 等长的数组,每项 = 该 KC 出几题。
304
+ * 例:4 KC → 6 题 [2,2,1,1];8 KC → 12 题 [2,2,2,2,1,1,1,1];12 KC → 15 题。
305
+ */
306
+ function planExamQuota(kcTitles) {
307
+ const n = kcTitles.length;
308
+ if (n === 0) return [];
309
+ const target = Math.min(15, Math.max(5, Math.ceil(n * 3 / 2)));
310
+ const quotas = Array.from({ length: n }, () => 0);
311
+ for (let i = 0; i < target; i++) quotas[i % n]++;
312
+ return quotas;
313
+ }
314
+ /** 正确率 → 星数(1-3)。低于 60% 得 0 星(但会记录尝试)。 */
315
+ function accuracyToStars(accuracy) {
316
+ if (accuracy >= .95) return 3;
317
+ if (accuracy >= .8) return 2;
318
+ if (accuracy >= .6) return 1;
319
+ return 0;
320
+ }
321
+ const MASTERED_MASTERY_THRESHOLD = .9;
322
+ const NEAR_MASTERED_THRESHOLD = .85;
323
+ /**
324
+ * 答完题后的"下一步"动作集合。永远 >= 2 个动作(消灭死胡同)。
325
+ * mark-mastered 只在"全对 + 高掌握度"时出现(避免误判掌握)。
326
+ */
327
+ function getPostQuizActions(score, mastery) {
328
+ const { correct, total } = score;
329
+ const allCorrect = total > 0 && correct === total;
330
+ const hasWrong = total > 0 && correct < total;
331
+ const alreadyMastered = mastery != null && mastery >= MASTERED_MASTERY_THRESHOLD;
332
+ const nearMastered = mastery != null && mastery >= NEAR_MASTERED_THRESHOLD;
333
+ if (hasWrong) return [{ id: "explain-wrong" }, { id: "retry" }];
334
+ if (allCorrect && alreadyMastered) return [{ id: "next-topic" }, { id: "go-deeper" }];
335
+ if (allCorrect && nearMastered) return [
336
+ {
337
+ id: "mark-mastered",
338
+ advancesMastery: true
339
+ },
340
+ { id: "next-topic" },
341
+ { id: "go-deeper" }
342
+ ];
343
+ return [{ id: "go-deeper" }, { id: "retry" }];
344
+ }
345
+ //#endregion
346
+ //#region src/vendor/streak-transition.ts
347
+ function todayStr(now = /* @__PURE__ */ new Date()) {
348
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
349
+ }
350
+ function yesterdayStr(now = /* @__PURE__ */ new Date()) {
351
+ const y = new Date(now);
352
+ y.setDate(y.getDate() - 1);
353
+ return todayStr(y);
354
+ }
355
+ function daysBetween(a, b) {
356
+ const da = /* @__PURE__ */ new Date(a + "T00:00:00");
357
+ const db = /* @__PURE__ */ new Date(b + "T00:00:00");
358
+ return Math.round((db.getTime() - da.getTime()) / 864e5);
359
+ }
360
+ function computeStreakTransition(state, now = /* @__PURE__ */ new Date()) {
361
+ const today = todayStr(now);
362
+ const yesterday = yesterdayStr(now);
363
+ if (state.lastActiveDate === today) return state;
364
+ let newCurrent;
365
+ let newFreeze = state.freezeCount;
366
+ if (state.lastActiveDate === yesterday) newCurrent = state.currentStreak + 1;
367
+ else if (state.lastActiveDate === null) newCurrent = 1;
368
+ else if (daysBetween(state.lastActiveDate, today) === 2 && state.freezeCount > 0) {
369
+ newFreeze = state.freezeCount - 1;
370
+ newCurrent = state.currentStreak + 1;
371
+ } else newCurrent = 1;
372
+ const newLongest = Math.max(state.longestStreak, newCurrent);
373
+ return {
374
+ currentStreak: newCurrent,
375
+ longestStreak: newLongest,
376
+ lastActiveDate: today,
377
+ freezeCount: newFreeze
378
+ };
379
+ }
230
380
  //#endregion
231
381
  //#region src/state.ts
232
382
  /**
@@ -254,7 +404,19 @@ function emptyState() {
254
404
  memoryGlobal: null,
255
405
  memoryPatterns: {},
256
406
  proposals: [],
257
- lessonSessions: {}
407
+ lessonSessions: {},
408
+ lastConsolidatedAt: null,
409
+ xp: {
410
+ total: 0,
411
+ todayKey: "",
412
+ todayXp: 0
413
+ },
414
+ streak: {
415
+ currentStreak: 0,
416
+ longestStreak: 0,
417
+ lastActiveDate: null,
418
+ freezeCount: 2
419
+ }
258
420
  };
259
421
  }
260
422
  /**
@@ -315,7 +477,19 @@ function loadState(path) {
315
477
  memoryGlobal: raw.memoryGlobal ?? null,
316
478
  memoryPatterns: raw.memoryPatterns ?? {},
317
479
  proposals: raw.proposals ?? [],
318
- lessonSessions: raw.lessonSessions ?? {}
480
+ lessonSessions: raw.lessonSessions ?? {},
481
+ lastConsolidatedAt: raw.lastConsolidatedAt ?? null,
482
+ xp: raw.xp ?? {
483
+ total: 0,
484
+ todayKey: "",
485
+ todayXp: 0
486
+ },
487
+ streak: raw.streak ?? {
488
+ currentStreak: 0,
489
+ longestStreak: 0,
490
+ lastActiveDate: null,
491
+ freezeCount: 2
492
+ }
319
493
  };
320
494
  }
321
495
  /**
@@ -384,7 +558,14 @@ function importCourse(state, parsed, source, sourceRef) {
384
558
  sourceRef,
385
559
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
386
560
  sections: parsed.sections.map((section) => {
387
- const lessons = section.lessons.map((lesson) => freshLesson(lesson.title, lesson.anchor, lesson.body, lesson.world === "practice" ? "practice" : "study"));
561
+ const lessons = section.lessons.map((lesson) => (() => {
562
+ const st = freshLesson(lesson.title, lesson.anchor, lesson.body, lesson.world === "practice" ? "practice" : "study");
563
+ if (lesson.translation !== void 0) {
564
+ st.translation = lesson.translation;
565
+ st.translationLang = lesson.translationLang ?? "";
566
+ }
567
+ return st;
568
+ })());
388
569
  if (section.world !== "practice" && lessons.filter((l) => l.kind === "study").length >= 2) lessons.push(freshLesson(`${section.title} · 章节测验`, `${section.anchor}#exam`, section.examBody ?? "", "exam"));
389
570
  return {
390
571
  title: section.title,
@@ -584,6 +765,28 @@ function attemptLesson(state, lessonId, now) {
584
765
  };
585
766
  }
586
767
  /**
768
+ * Record one graded exam attempt on an exam node: stars from accuracy
769
+ * (upstream accuracyToStars thresholds), best-of retained across attempts
770
+ * (upstream crownLevel-takes-max semantics). Study/practice lessons are
771
+ * refused — this is the exam surface only.
772
+ */
773
+ function recordExamResult(state, lessonId, correct, total) {
774
+ const ref = findLesson(state, lessonId);
775
+ if (ref.lesson.kind !== "exam") throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} is not an exam node — study_exam_result is for section exams`);
776
+ if (!Number.isInteger(correct) || !Number.isInteger(total) || total <= 0 || correct < 0 || correct > total) throw new Error(`lookatstudy-plugin: invalid exam score ${correct}/${total}`);
777
+ const stars = accuracyToStars(correct / total);
778
+ const prevBest = ref.lesson.examStars ?? -1;
779
+ ref.lesson.examStars = Math.max(prevBest, stars);
780
+ ref.lesson.examAttempts = (ref.lesson.examAttempts ?? 0) + 1;
781
+ ref.lesson.lastAnsweredAt = (/* @__PURE__ */ new Date()).toISOString();
782
+ return {
783
+ ref,
784
+ stars,
785
+ bestStars: ref.lesson.examStars,
786
+ attempts: ref.lesson.examAttempts
787
+ };
788
+ }
789
+ /**
587
790
  * Record one graded answer against a lesson: attribute it to one knowledge
588
791
  * component when named, update BKT (per-KC, aggregated as the weakest),
589
792
  * nudge the SM-2 schedule when one exists, and apply mastery-driven
@@ -629,6 +832,7 @@ function recordAnswer(state, lessonId, correct, concept, now) {
629
832
  ref.lesson.dueAt = result.dueAt;
630
833
  }
631
834
  const progression = applyProgression(ref, now);
835
+ const xp = noteXpActivity(state, progression.graduated ? 50 : correct ? 10 : 1, now);
632
836
  return {
633
837
  ref,
634
838
  concept: kcIndex === void 0 ? null : {
@@ -639,7 +843,8 @@ function recordAnswer(state, lessonId, correct, concept, now) {
639
843
  newMastery: ref.lesson.mastery ?? 0,
640
844
  crown: masteryToCrown(ref.lesson.mastery),
641
845
  mastered: (ref.lesson.mastery ?? 0) >= MASTERED_THRESHOLD,
642
- progression
846
+ progression,
847
+ xp
643
848
  };
644
849
  }
645
850
  /**
@@ -667,7 +872,7 @@ function completeLesson(state, lessonId, now) {
667
872
  * @param lessonId - lesson to describe.
668
873
  * @param concepts - 2–7 short concepts.
669
874
  */
670
- function defineConcepts(state, lessonId, concepts) {
875
+ function defineConcepts(state, lessonId, concepts, summary) {
671
876
  const ref = findLesson(state, lessonId);
672
877
  if (concepts.length < 2 || concepts.length > 7) throw new Error(`lookatstudy-plugin: define 2–7 concepts (got ${concepts.length})`);
673
878
  for (const def of concepts) if (def.title.trim() === "" || def.description.trim() === "") throw new Error("lookatstudy-plugin: every concept needs a non-empty title and description");
@@ -676,6 +881,7 @@ function defineConcepts(state, lessonId, concepts) {
676
881
  description: c.description.trim()
677
882
  }));
678
883
  ref.lesson.conceptMastery = {};
884
+ if (summary !== void 0 && summary.trim() !== "") ref.lesson.summary = summary.trim();
679
885
  aggregateMastery(ref.lesson);
680
886
  }
681
887
  /**
@@ -962,6 +1168,7 @@ function learnerSnapshot(state, now) {
962
1168
  return {
963
1169
  focus: ref === null ? null : {
964
1170
  lessonId: ref.lesson.id,
1171
+ courseId: ref.course.id,
965
1172
  courseTitle: ref.course.title,
966
1173
  lessonTitle: ref.lesson.title,
967
1174
  masteryPct: ref.lesson.mastery === null ? null : Math.round(ref.lesson.mastery * 100),
@@ -985,6 +1192,125 @@ function tryFindLesson(state, lessonId) {
985
1192
  return null;
986
1193
  }
987
1194
  }
1195
+ function gatherConsolidationWindow(state, capPerLesson = 10) {
1196
+ const since = state.lastConsolidatedAt;
1197
+ const entries = [];
1198
+ let frictionCount = 0;
1199
+ let practiceCount = 0;
1200
+ for (const course of state.courses) for (const section of course.sections) for (const lesson of section.lessons) {
1201
+ let perLesson = 0;
1202
+ const collect = (kind, at, push) => {
1203
+ if (since !== null && at <= since) return;
1204
+ if (perLesson >= capPerLesson) return;
1205
+ perLesson++;
1206
+ push();
1207
+ };
1208
+ for (const f of lesson.friction) collect("friction", f.at, () => {
1209
+ entries.push({
1210
+ lessonId: lesson.id,
1211
+ lessonTitle: lesson.title,
1212
+ kind: "friction",
1213
+ category: f.category,
1214
+ text: f.summary ?? "(no summary)",
1215
+ at: f.at
1216
+ });
1217
+ frictionCount++;
1218
+ });
1219
+ for (const n of lesson.notes) {
1220
+ if (n.zone !== "practice") continue;
1221
+ collect("practice", n.at, () => {
1222
+ entries.push({
1223
+ lessonId: lesson.id,
1224
+ lessonTitle: lesson.title,
1225
+ kind: "practice",
1226
+ text: n.text.slice(0, 200),
1227
+ at: n.at
1228
+ });
1229
+ practiceCount++;
1230
+ });
1231
+ }
1232
+ }
1233
+ entries.sort((a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
1234
+ return {
1235
+ since,
1236
+ entries,
1237
+ counts: {
1238
+ friction: frictionCount,
1239
+ practice: practiceCount
1240
+ }
1241
+ };
1242
+ }
1243
+ /**
1244
+ * Record one XP event + advance the streak (upstream xp-service addXp +
1245
+ * streak.ts applyStreak, state-ified). New-day rollover resets the today
1246
+ * bucket first. The streak transition runs on every XP event — activity IS
1247
+ * the check-in (upstream 打卡 semantics).
1248
+ */
1249
+ function noteXpActivity(state, xp, now) {
1250
+ const key = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
1251
+ if (state.xp.todayKey !== key) {
1252
+ state.xp.todayKey = key;
1253
+ state.xp.todayXp = 0;
1254
+ }
1255
+ state.xp.total += xp;
1256
+ state.xp.todayXp += xp;
1257
+ state.streak = computeStreakTransition({
1258
+ ...state.streak,
1259
+ lastActiveDate: state.streak.lastActiveDate ?? null
1260
+ }, now);
1261
+ return {
1262
+ totalXp: state.xp.total,
1263
+ todayXp: state.xp.todayXp,
1264
+ streak: { ...state.streak }
1265
+ };
1266
+ }
1267
+ function searchLessons(state, query, limit = 20) {
1268
+ const keys = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
1269
+ if (keys.length === 0) return [];
1270
+ const hits = [];
1271
+ for (const course of state.courses) for (const section of course.sections) for (const lesson of section.lessons) {
1272
+ const title = lesson.title.toLowerCase();
1273
+ const body = lesson.body.toLowerCase();
1274
+ if (!keys.every((k) => title.includes(k) || body.includes(k))) continue;
1275
+ let snippet = lesson.body.replace(/\s+/g, " ").trim();
1276
+ for (const k of keys) {
1277
+ const idx = snippet.toLowerCase().indexOf(k);
1278
+ if (idx >= 0) {
1279
+ const start = Math.max(0, idx - 30);
1280
+ snippet = (start > 0 ? "…" : "") + snippet.slice(start, start + 100) + (start + 100 < snippet.length ? "…" : "");
1281
+ break;
1282
+ }
1283
+ }
1284
+ hits.push({
1285
+ courseId: course.id,
1286
+ courseTitle: course.title,
1287
+ lessonId: lesson.id,
1288
+ lessonTitle: lesson.title,
1289
+ snippet: snippet.slice(0, 120)
1290
+ });
1291
+ if (hits.length >= limit) return hits;
1292
+ }
1293
+ return hits;
1294
+ }
1295
+ /**
1296
+ * Serialize one course into a single markdown learning pack (upstream
1297
+ * pack-export's zero-LLS sharing semantics, dsh-adapted: the receiver imports
1298
+ * through the existing study_import_markdown — no new import path, no network).
1299
+ * Sections become ##, lessons ###, bodies verbatim; exam nodes keep their intro.
1300
+ * Pure.
1301
+ */
1302
+ function courseToPackMarkdown(course) {
1303
+ const parts = [`# ${course.title}`];
1304
+ for (const section of course.sections) {
1305
+ parts.push(`## ${section.title}`);
1306
+ for (const lesson of section.lessons) {
1307
+ if (lesson.kind === "exam") continue;
1308
+ parts.push(`### ${lesson.title}`);
1309
+ parts.push(lesson.body);
1310
+ }
1311
+ }
1312
+ return parts.join("\n\n").trim() + "\n";
1313
+ }
988
1314
  //#endregion
989
1315
  //#region src/dashboard.ts
990
1316
  /**
@@ -995,6 +1321,20 @@ function tryFindLesson(state, lessonId) {
995
1321
  * message channel were removed once the in-client study tab superseded them.
996
1322
  * @module dsh-plugin-lookatstudy/dashboard
997
1323
  */
1324
+ /** Wiring handed in by `apply`. */
1325
+ /**
1326
+ * The plugin's own version, read from the package.json sitting beside the
1327
+ * running module (src in dev, lib in install) — strictly the installed build,
1328
+ * no build-time inlining that could drift. Upstream v0.24.0 port (settings
1329
+ * About row); empty string on any read failure (display degrades, never throws).
1330
+ */
1331
+ function pluginVersion() {
1332
+ try {
1333
+ return String(JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version ?? "");
1334
+ } catch {
1335
+ return "";
1336
+ }
1337
+ }
998
1338
  /**
999
1339
  * Assemble the whole workbench state (pure read; the lesson HTML is rendered
1000
1340
  * server-side from the sanitized markdown pipeline).
@@ -1055,7 +1395,8 @@ function workbenchState(state, now) {
1055
1395
  source: n.source,
1056
1396
  quote: n.quote
1057
1397
  })),
1058
- html: renderMarkdown(ref.lesson.body)
1398
+ html: renderMarkdown(normalizeMathNotation(ref.lesson.translation === void 0 ? ref.lesson.body : renderBilingual(ref.lesson.body, ref.lesson.translation))),
1399
+ markdown: ref.lesson.body
1059
1400
  };
1060
1401
  } catch {
1061
1402
  lesson = null;
@@ -1097,7 +1438,20 @@ function workbenchState(state, now) {
1097
1438
  pattern: snap.memoryPattern
1098
1439
  };
1099
1440
  })(),
1100
- lessonSessions: state.lessonSessions
1441
+ lessonSessions: state.lessonSessions,
1442
+ progress: (() => {
1443
+ const xp = levelFromTotalXp(state.xp.total);
1444
+ return {
1445
+ totalXp: state.xp.total,
1446
+ level: xp.level,
1447
+ levelPct: xp.pct,
1448
+ todayXp: state.xp.todayXp,
1449
+ dailyGoal: 30,
1450
+ streak: state.streak.currentStreak,
1451
+ longestStreak: state.streak.longestStreak,
1452
+ freezeCount: state.streak.freezeCount
1453
+ };
1454
+ })()
1101
1455
  };
1102
1456
  }
1103
1457
  const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
@@ -1154,7 +1508,20 @@ function registerDashboard(webServer, deps) {
1154
1508
  handler: async (req, res) => {
1155
1509
  const pathname = new URL(req.url ?? "/", "http://x").pathname;
1156
1510
  if (req.method === "GET" && pathname === "/lookatstudy/api/state") {
1157
- sendJson(res, 200, workbenchState(deps.store.get(), /* @__PURE__ */ new Date()));
1511
+ sendJson(res, 200, {
1512
+ ...workbenchState(deps.store.get(), /* @__PURE__ */ new Date()),
1513
+ statePath: deps.statePath,
1514
+ version: pluginVersion()
1515
+ });
1516
+ return;
1517
+ }
1518
+ if (req.method === "GET" && pathname === "/lookatstudy/api/search") {
1519
+ const query = new URL(req.url ?? "/", "http://x").searchParams.get("q") ?? "";
1520
+ sendJson(res, 200, {
1521
+ ok: true,
1522
+ query,
1523
+ matches: searchLessons(deps.store.get(), query)
1524
+ });
1158
1525
  return;
1159
1526
  }
1160
1527
  if (req.method === "POST" && pathname === "/lookatstudy/api/active") {
@@ -1274,6 +1641,187 @@ function registerDashboard(webServer, deps) {
1274
1641
  };
1275
1642
  }
1276
1643
  //#endregion
1644
+ //#region src/client/locale.ts
1645
+ /** Simplified Chinese dictionary (also the fallback locale). */
1646
+ const ZH = {
1647
+ "tab.label": "学习",
1648
+ "col.rail": "课程",
1649
+ "col.tutor": "导师",
1650
+ "col.bb": "黑板",
1651
+ "pane.rail": "课程",
1652
+ "pane.tutor": "导师",
1653
+ "pane.bb": "黑板",
1654
+ "viewtab.teach": "讲解",
1655
+ "viewtab.cmap": "🕸 概念图",
1656
+ "viewtab.cmap.title": "本课概念关系(ELK 布局)",
1657
+ "loading": "加载中…",
1658
+ "start.title": "一键准备学习:建立学习工作区、开启会话并让导师就位",
1659
+ "start.go": "开始学习",
1660
+ "start.enter": "进入学习",
1661
+ "start.busy": "正在准备学习区…",
1662
+ "soul.direct": "直讲",
1663
+ "soul.direct.hint": "direct 精讲:先讲清楚,再确认懂没懂",
1664
+ "soul.guide": "引导",
1665
+ "soul.guide.hint": "guide 引导:让你自己往前推一步,导师递台阶",
1666
+ "soul.practice": "实战",
1667
+ "soul.practice.hint": "practice 实战:在真实世界的乱问题里学",
1668
+ "zone.understand": "🧠 理解区 — 知识结构",
1669
+ "zone.record": "📝 记录区 — 我的话",
1670
+ "zone.practice": "✍️ 练习区 — 答题日志",
1671
+ "rail.empty.title": "暂无课程",
1672
+ "rail.empty.hint": "粘贴 markdown → 说「导入为课程」",
1673
+ "rail.empty.hint2": "本地文件夹 → 说「导入 D:/path/to/folder」",
1674
+ "rail.empty.placeholder": "GitHub 仓库链接,如 microsoft/AI-For-Beginners",
1675
+ "rail.empty.button": "导入",
1676
+ "rail.empty.demo": "导入示例课程",
1677
+ "rail.mastered": "{mastered}/{total} 已掌握",
1678
+ "rail.avg": "平均掌握度 {pct}%",
1679
+ "rail.avg.none": "尚无掌握度数据",
1680
+ "rail.search": "搜索课时…(多关键词空格分隔)",
1681
+ "rail.locate.title": "在课程树中定位当前焦点课时(自动展开所在章节)",
1682
+ "rail.locate": "📍 回到当前课时",
1683
+ "rail.due": "🔁 待复习 {count}",
1684
+ "rail.due.over": "超{days}天",
1685
+ "rail.due.start": "开始复习",
1686
+ "rail.due.tag": "这课时的复习今天到期(SM-2)",
1687
+ "rail.delete": "删除本课程",
1688
+ "rail.delete.confirm": "确认删除?",
1689
+ "rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)",
1690
+ "rail.section.collapse": "折叠本章节",
1691
+ "rail.section.expand": "展开本章节({count} 课时)",
1692
+ "rail.section.count": "{count} 课",
1693
+ "rail.lesson.opening": "正在打开课时会话…",
1694
+ "rail.import.toggle": "+ 导入课程",
1695
+ "rail.import.close": "收起导入",
1696
+ "tag.weak": "{count} 个薄弱知识点,测验会优先考察",
1697
+ "tag.friction": "{count} 次卡点记录(你说\"不懂\"时导师记下的)",
1698
+ "tag.mastery": "课时掌握度 {pct}%(取最薄弱知识点)",
1699
+ "tag.mastery.short": "课时掌握度 = 最薄弱知识点的掌握度",
1700
+ "status.exam": "章节测验:本节全部课时掌握度 ≥50% 后开放",
1701
+ "status.mastered": "已毕业:掌握度 ≥90%(或你接受了掌握提案)",
1702
+ "status.in_progress": "学习中:已开课,掌握度从 50% 起步",
1703
+ "status.available": "可开始:已解锁,尚未学习",
1704
+ "status.locked": "未解锁:先完成前面的课时",
1705
+ "chip.unattributed": "未归因",
1706
+ "chip.correct": "✓ 答对 · {concept}",
1707
+ "chip.wrong": "✗ 答错 · {concept}",
1708
+ "chip.import": "📦 导入课程",
1709
+ "chip.concepts": "🧠 提炼知识点",
1710
+ "row.thinking": "导师思考中…",
1711
+ "row.thinking.title": "导师正在推理,回复马上就来",
1712
+ "quiz.title": "点击选项作答,也可以直接打字回答",
1713
+ "quiz.answer": "选 {letter}:{text}",
1714
+ "tutor.empty": "对话会出现在这里",
1715
+ "tutor.empty.hint": "在下方输入框和导师说话",
1716
+ "proposal.text": "导师提议你已掌握「{lesson}」:{rationale}",
1717
+ "proposal.accept": "接受",
1718
+ "proposal.decline": "再练练",
1719
+ "bb.empty": "黑板还空着",
1720
+ "bb.empty.hint": "在左侧课程树选择一课",
1721
+ "bb.mastery": "掌握度 {pct}%",
1722
+ "bb.fallback.cmap.empty": "概念图需要先由导师定义本课概念",
1723
+ "bb.fallback.cmap": "概念图渲染不可用(布局引擎加载失败) — 已回退讲解视图",
1724
+ "bb.notes": "笔记",
1725
+ "bb.notes.empty": "这一课还没有笔记",
1726
+ "active.off.title": "注册 study 工具并载入导师人格,之后普通对话和现在一样",
1727
+ "active.on.title": "注销 study 工具与导师人格;学习进度保留,可随时重新开启",
1728
+ "active.off": "▶ 开始学习",
1729
+ "active.on": "⏻ 退出学习模式",
1730
+ "prompt.kickoff": "开始学习:查看我的学习状态并打开当前焦点课时。如果我还没有课程,推荐我导入示例课程(AI-For-Beginners)并说明怎么开始。回复最后请提醒我点上方「学习」页签进入学习界面。",
1731
+ "settings.nav": "学习",
1732
+ "settings.mode": "教学风格",
1733
+ "settings.mode.hint": "导师的讲解风格;也可在学习页随时切换",
1734
+ "settings.studyMode": "学习模式",
1735
+ "settings.studyMode.hint": "开启后注册 study_* 工具并载入导师人格;进度与笔记始终保留",
1736
+ "settings.on": "已开启",
1737
+ "settings.off": "已关闭",
1738
+ "settings.turnOn": "开启",
1739
+ "settings.turnOff": "关闭",
1740
+ "settings.stats": "学习统计",
1741
+ "settings.stats.courses": "课程 {count} 个",
1742
+ "settings.stats.xp": "XP {xp} · Lv{level}({pct}%)",
1743
+ "settings.stats.today": "今日 {xp}/{goal}",
1744
+ "settings.stats.streak": "连续 {days} 天(最长 {best},剩冻结 {freeze})",
1745
+ "settings.about": "关于",
1746
+ "settings.about.hint": "当前运行的构建版本,点击查看该版更新内容。",
1747
+ "settings.stateFile": "状态文件",
1748
+ "settings.stateFile.hint": "学习进度存放于本机 JSON,换机可迁移",
1749
+ "dock.title": "学习状态:待复习 {due} · 连续 {streak} 天 · Lv{level} — 点上方「学习」页签进入",
1750
+ "dock.due": "⚡{count}",
1751
+ "dock.streak": "🔥{days}d",
1752
+ "dock.lv": "Lv{level}",
1753
+ "prompt.import": "导入课程:用 study_import_github 抓取 {url}",
1754
+ "prompt.lesson": "学习「{title}」:用 study_lesson 打开这一课开始学习。",
1755
+ "prompt.exam": "开始「{section}」的章节测验:按本节课时出题,答完逐题判分",
1756
+ "prompt.review": "开始今天的复习,从最到期的课时开始",
1757
+ "prompt.proposal.accept": "接受提案 {id} —— 确认标记这课为已掌握",
1758
+ "prompt.proposal.decline": "拒绝提案 {id} —— 我想再练练"
1759
+ };
1760
+ //#endregion
1761
+ //#region src/commands.ts
1762
+ /**
1763
+ * The `/study` slash command (host side): the keyboard/headless discovery
1764
+ * path into the study surface. Bare `/study` activates a dormant install and
1765
+ * queues the same kickoff prompt the hero button sends; `/study <text>`
1766
+ * queues that text as the learning request. Activation follows the exact
1767
+ * dashboard route's semantics (persist + tool-registry sync BEFORE the
1768
+ * prompt lands), so the model the prompt meets can already call the tools.
1769
+ *
1770
+ * Structural slices of the harness command contract keep this module free of
1771
+ * a new peer dependency; `agent.followup` is the model-visible user-message
1772
+ * path (the /goal command's attachment precedent).
1773
+ * @module dsh-plugin-lookatstudy/commands
1774
+ */
1775
+ /** Build one identified user message (createUserMessage's structural twin: uuid + freeze). */
1776
+ function userMessage(text) {
1777
+ const content = Object.freeze([{
1778
+ type: "text",
1779
+ text
1780
+ }]);
1781
+ return Object.freeze({
1782
+ id: crypto.randomUUID(),
1783
+ role: "user",
1784
+ content,
1785
+ source: Object.freeze({
1786
+ kind: "plugin",
1787
+ plugin: "dsh-plugin-lookatstudy"
1788
+ })
1789
+ });
1790
+ }
1791
+ /** The model-facing kickoff (the hero button's prompt; the tutor replies in the learner's own language). */
1792
+ function studyKickoffPrompt() {
1793
+ return ZH["prompt.kickoff"];
1794
+ }
1795
+ /** Execute `/study [<text>]` against the live store. Pure over the deps; no HTTP. */
1796
+ function executeStudyCommand(deps, invocation) {
1797
+ const state = deps.store.get();
1798
+ if (!state.active) {
1799
+ state.active = true;
1800
+ deps.store.save();
1801
+ deps.onActiveChange(true);
1802
+ }
1803
+ const text = invocation.rawInput.trim() === "" ? studyKickoffPrompt() : invocation.rawInput.trim();
1804
+ invocation.agent.followup(userMessage(text));
1805
+ return {
1806
+ kind: "success",
1807
+ text: "学习模式已就位 — 回复马上开始;点上方「学习」页签进入学习界面 / Study mode is on — open the Study tab above for the study UI."
1808
+ };
1809
+ }
1810
+ /**
1811
+ * Register `/study` on the harness command runtime (global scope).
1812
+ * @param commands - the host `commands` service.
1813
+ * @param deps - the shared store wiring.
1814
+ * @returns the registration disposer.
1815
+ */
1816
+ function registerStudyCommand(commands, deps) {
1817
+ return commands.register({
1818
+ name: "study",
1819
+ description: "start (or resume) a guided study session — activates the tutor and queues the kickoff prompt",
1820
+ input: { hint: "[<learning request>]" },
1821
+ handler: (invocation) => executeStudyCommand(deps, invocation)
1822
+ });
1823
+ }
1824
+ //#endregion
1277
1825
  //#region src/vendor/markdown-course.ts
1278
1826
  /**
1279
1827
  * GitHub 风格的 anchor 生成:小写、去一组标点(保留中文等 unicode)、每个空格单独转 -。
@@ -1281,7 +1829,7 @@ function registerDashboard(webServer, deps) {
1281
1829
  * 与 seed.ts 的锚点对齐。
1282
1830
  */
1283
1831
  function titleToAnchor(title) {
1284
- return title.toLowerCase().trim().replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, "").replace(/ /g, "-").replace(/^-|-$/g, "");
1832
+ return title.toLowerCase().trim().replace(/[!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~]/g, "").replace(/ /g, "-").replace(/^-|-$/g, "");
1285
1833
  }
1286
1834
  /**
1287
1835
  * 清洗课时/章节标题 — 去 emoji、多余空格、markdown 格式符号。
@@ -1289,7 +1837,7 @@ function titleToAnchor(title) {
1289
1837
  * "## [Pre-lecture quiz](url)" → "Pre-lecture quiz"
1290
1838
  */
1291
1839
  function cleanTitle(raw) {
1292
- return raw.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^#+\s*/, "").trim().replace(/\s+/g, " ").replace(/^[·\-\.\s]+|[·\-\.\s]+$/g, "").trim();
1840
+ return raw.replace(/[\u{1F000}-\u{1FFFF}\u{2600}-\u{27BF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}]/gu, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^#+\s*/, "").trim().replace(/\s+/g, " ").replace(/^[·\-.\s]+|[·\-.\s]+$/g, "").trim();
1293
1841
  }
1294
1842
  /**
1295
1843
  * 解析 markdown 为课程树。
@@ -1384,6 +1932,8 @@ const EXT_KIND = {
1384
1932
  htm: "html",
1385
1933
  pdf: "pdf",
1386
1934
  pptx: "pptx",
1935
+ epub: "epub",
1936
+ docx: "docx",
1387
1937
  ipynb: "ipynb",
1388
1938
  rst: "rst",
1389
1939
  rmd: "rmd",
@@ -1555,7 +2105,7 @@ async function scanFolder(rootDir, onProgress, options) {
1555
2105
  const kind = EXT_KIND[ext];
1556
2106
  if (!kind) continue;
1557
2107
  try {
1558
- const content = await readFileWithKind(f.absPath, kind);
2108
+ const content = await readFileWithKind(f.absPath, kind, options?.parsePdf);
1559
2109
  if (!content || content.trim().length < 5) continue;
1560
2110
  const lang = detectLang(f.relPath);
1561
2111
  docs.push({
@@ -1620,9 +2170,9 @@ async function scanFolder(rootDir, onProgress, options) {
1620
2170
  for (const doc of dedupedDocs) {
1621
2171
  if (doc.kind !== "pptx") continue;
1622
2172
  try {
1623
- const { parsePptx } = await import("../lib/pptx-parser.js");
2173
+ const { parsePptx } = await import("./pptx-parser-B9IDkiGM.mjs");
1624
2174
  const result = await parsePptx(await readFile(join(rootDir, doc.path)));
1625
- for (const img of result.images) pptxImages.push({
2175
+ for (const img of result.images ?? []) pptxImages.push({
1626
2176
  path: `${doc.path}#slide${img.slideNumber}.png`,
1627
2177
  absPath: "",
1628
2178
  title: `${doc.title} - 图(第${img.slideNumber}页)`,
@@ -1802,17 +2352,28 @@ async function walkDir(root, current, acc) {
1802
2352
  }
1803
2353
  }
1804
2354
  }
1805
- async function readFileWithKind(absPath, kind) {
2355
+ async function readFileWithKind(absPath, kind, parsePdf) {
1806
2356
  if (kind === "pdf") {
1807
2357
  const buf = await readFile(absPath);
1808
- const { parsePdfText } = await import("../lib/pdf-text.js");
2358
+ if (parsePdf) return parsePdf(buf);
2359
+ const { parsePdfText } = await Promise.resolve().then(() => pdf_text_exports);
1809
2360
  return parsePdfText(buf);
1810
2361
  }
1811
2362
  if (kind === "pptx") {
1812
2363
  const buf = await readFile(absPath);
1813
- const { parsePptx } = await import("../lib/pptx-parser.js");
2364
+ const { parsePptx } = await import("./pptx-parser-B9IDkiGM.mjs");
1814
2365
  return (await parsePptx(buf)).markdown;
1815
2366
  }
2367
+ if (kind === "docx") {
2368
+ const buf = await readFile(absPath);
2369
+ const { parseDocx } = await import("./docx-parser-BhyqPImb.mjs");
2370
+ return parseDocx(buf).replace(/^(#{1,5}) /gm, (m) => `#${m}`);
2371
+ }
2372
+ if (kind === "epub") {
2373
+ const buf = await readFile(absPath);
2374
+ const { parseEpubFlat } = await import("./epub-parser-oH96guBW.mjs");
2375
+ return parseEpubFlat(buf);
2376
+ }
1816
2377
  if (kind === "ipynb") {
1817
2378
  const raw = await readFile(absPath, "utf8");
1818
2379
  const { parseNotebook } = await import("./notebook-parser-ChbZBIKJ.mjs");
@@ -1859,6 +2420,121 @@ function naturalPathCompare(a, b) {
1859
2420
  }
1860
2421
  return pa.length - pb.length;
1861
2422
  }
2423
+ /**
2424
+ * 为新管线构建本地清点:scanFolder + translations + README + fullTree + standaloneImages。
2425
+ *
2426
+ * 和 GitHub 的 fetchRepoInventory 对齐:产出 readmeMd + fileList(隐含在 docs 里) +
2427
+ * fullTree,供 classifyFileRoles + designCourseStructure 使用。
2428
+ */
2429
+ async function buildLocalInventory(rootDir, onProgress, options) {
2430
+ const scanResult = await scanFolder(rootDir, onProgress, {
2431
+ collectImages: true,
2432
+ parsePdf: options?.parsePdf
2433
+ });
2434
+ const { docs, images } = Array.isArray(scanResult) ? {
2435
+ docs: scanResult,
2436
+ images: []
2437
+ } : scanResult;
2438
+ const { translations, translationLangs } = await scanTranslationsDir(rootDir);
2439
+ return {
2440
+ docs,
2441
+ images,
2442
+ translations,
2443
+ translationLangs,
2444
+ readmeMd: findReadmeContent(docs),
2445
+ fullTree: [
2446
+ ...docs.map((d) => d.path),
2447
+ ...images.map((i) => i.path),
2448
+ ...translations.map((t) => t.path)
2449
+ ],
2450
+ standaloneImages: findStandaloneImages(images, docs)
2451
+ };
2452
+ }
2453
+ /**
2454
+ * 扫描 translations/{lang}/ 目录。
2455
+ * 每个 lang 子目录对应一种翻译语言,其下的文件按原目录结构保留。
2456
+ * path = translations/{lang}/{相对 lang 目录的路径}。
2457
+ */
2458
+ async function scanTranslationsDir(rootDir) {
2459
+ const translationsDir = join(rootDir, "translations");
2460
+ if (!existsSync(translationsDir)) return {
2461
+ translations: [],
2462
+ translationLangs: []
2463
+ };
2464
+ let langEntries;
2465
+ try {
2466
+ langEntries = await readdir(translationsDir, { withFileTypes: true });
2467
+ } catch {
2468
+ return {
2469
+ translations: [],
2470
+ translationLangs: []
2471
+ };
2472
+ }
2473
+ const langs = langEntries.filter((e) => e.isDirectory()).map((e) => e.name);
2474
+ const translations = [];
2475
+ for (const lang of langs) {
2476
+ const langDir = join(translationsDir, lang);
2477
+ const transFiles = [];
2478
+ await walkDir(langDir, langDir, transFiles);
2479
+ for (const f of transFiles) {
2480
+ if (f.isImage) continue;
2481
+ const ext = f.relPath.toLowerCase().match(/\.([^.]+)$/)?.[1] ?? "";
2482
+ const kind = EXT_KIND[ext];
2483
+ if (!kind) continue;
2484
+ try {
2485
+ const content = await readFileWithKind(f.absPath, kind);
2486
+ if (!content || content.trim().length < 5) continue;
2487
+ translations.push({
2488
+ path: `translations/${lang}/${f.relPath}`,
2489
+ title: inferTitle(f.relPath),
2490
+ content,
2491
+ lang: detectLang(f.relPath),
2492
+ kind
2493
+ });
2494
+ } catch {}
2495
+ }
2496
+ }
2497
+ return {
2498
+ translations,
2499
+ translationLangs: langs
2500
+ };
2501
+ }
2502
+ /**
2503
+ * 从已扫描文档里找 README 全文。
2504
+ * 优先根目录 README.md/README.markdown,其次 index.md,再首个 md,都没有返回 ""。
2505
+ */
2506
+ function findReadmeContent(docs) {
2507
+ const readme = docs.find((d) => {
2508
+ const parts = d.path.split("/");
2509
+ return parts.length === 1 && /^readme\.(md|markdown)$/i.test(parts[0]);
2510
+ });
2511
+ if (readme) return readme.content;
2512
+ const index = docs.find((d) => {
2513
+ const parts = d.path.split("/");
2514
+ return parts.length === 1 && /^index\.(md|markdown)$/i.test(parts[0]);
2515
+ });
2516
+ if (index) return index.content;
2517
+ return docs.find((d) => d.kind === "md")?.content ?? "";
2518
+ }
2519
+ /**
2520
+ * 找出不被任何文档引用的独立图片文件。
2521
+ * 这些是"孤儿"图片,需要 LLM 在 Step 4 关联到最相关的 lesson。
2522
+ *
2523
+ * 判定:source=image_file(独立文件,非 PDF/notebook 提取) + 有 absPath(磁盘文件) +
2524
+ * 不在任何文档的图片引用路径里。
2525
+ */
2526
+ function findStandaloneImages(images, docs) {
2527
+ const referencedPaths = /* @__PURE__ */ new Set();
2528
+ for (const doc of docs) {
2529
+ if (doc.kind === "txt" || doc.kind === "html") continue;
2530
+ const refs = extractImageRefs(doc.content);
2531
+ for (const ref of refs) {
2532
+ const resolved = resolveImageRef(ref.refPath, doc.path);
2533
+ referencedPaths.add(resolved);
2534
+ }
2535
+ }
2536
+ return images.filter((img) => img.source === "image_file" && img.absPath && !referencedPaths.has(img.path));
2537
+ }
1862
2538
  //#endregion
1863
2539
  //#region src/vendor/repo-fetcher.ts
1864
2540
  /** CDN URL 构造 */
@@ -2251,6 +2927,34 @@ async function fetchRepoInventory(owner, repo, branch, fetchFn, onProgress, sign
2251
2927
  };
2252
2928
  }
2253
2929
  /**
2930
+ * 提取正文开头摘录(供 Step 4 结构设计做语义分组)。
2931
+ *
2932
+ * 规则:
2933
+ * - 跳过标题行(#/##/###)、代码围栏内容、空行、纯符号行(分隔线/表格线)
2934
+ * - 行内 markdown 降噪:图片整体丢弃、`[文字](链接)` 只留文字、剥引用前缀 ">"
2935
+ * - 行以空格连接成单行(进 prompt 的 JSON 块不能带换行),空白折叠
2936
+ * - 攒够 maxChars 即停(长文件不读完,零浪费)
2937
+ * 纯函数,可 verify 直测。
2938
+ */
2939
+ function extractBodyPreview(text, maxChars = 300) {
2940
+ const lines = text.split(/\r?\n/);
2941
+ let inCodeFence = false;
2942
+ let buf = "";
2943
+ for (const rawLine of lines) {
2944
+ if (buf.length >= maxChars) break;
2945
+ if (/^(\s*)(```|~~~)/.test(rawLine)) {
2946
+ inCodeFence = !inCodeFence;
2947
+ continue;
2948
+ }
2949
+ if (inCodeFence) continue;
2950
+ if (/^#{1,6}\s/.test(rawLine)) continue;
2951
+ let line = rawLine.replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^>\s?/, "").replace(/\s+/g, " ").trim();
2952
+ if (!line || /^(-{3,}|\*{3,}|_{3,})$/.test(line) || /^\|?[\s:|-]+\|?$/.test(line)) continue;
2953
+ buf += (buf ? " " : "") + line;
2954
+ }
2955
+ return buf.slice(0, maxChars);
2956
+ }
2957
+ /**
2254
2958
  * Step 3: 批量提取文件的标题大纲(H1/H2/H3 + 每段字符数,不含正文)。
2255
2959
  * 拉取完整文件文本(不只前 N 行),因为字符数统计需要全文。
2256
2960
  * 并发度 5,同 fetchMarkdownContents。
@@ -2345,7 +3049,8 @@ function extractOutlineWithCharCounts(text, filePath) {
2345
3049
  return {
2346
3050
  h1: h1 || (filePath.split("/").pop() ?? filePath),
2347
3051
  totalChars,
2348
- headings
3052
+ headings,
3053
+ bodyPreview: extractBodyPreview(text)
2349
3054
  };
2350
3055
  }
2351
3056
  /**
@@ -2390,87 +3095,502 @@ async function fetchSingleFileContent(filePath, owner, repo, branch, fetchFn) {
2390
3095
  }
2391
3096
  }
2392
3097
  /**
2393
- * Build the pending design from Step 1 (inventory) + Step 3 (outlines).
2394
- * @param url - the GitHub URL the learner asked to import.
2395
- * @param owner - repo owner.
2396
- * @param repo - repo name.
2397
- * @param inventory - fetchRepoInventory output (README, file list, tree).
2398
- * @param outlines - fetchFileOutlines output, keyed by path.
2399
- * @returns the pending design the brief renders from.
3098
+ * 通用二进制下载(arXiv PDF ):注入 fetchFn(跟随重定向),带取消与
3099
+ * 大小上限。fetchImageAsDataUrl 的泛化——那个只管 CDN 图片且吞错,这个要把
3100
+ * 失败原因如实抛给用户。
2400
3101
  */
2401
- function buildPendingDesign(url, owner, repo, inventory, outlines) {
2402
- const files = [];
2403
- for (const discovered of inventory.fileList) {
2404
- const outline = outlines.get(discovered.path);
2405
- if (outline === void 0) continue;
2406
- files.push({
2407
- path: discovered.path,
2408
- role: discovered.kind === "ipynb" ? "practice" : "original",
2409
- outline
2410
- });
2411
- }
2412
- const courseTitle = inventory.readmeMd.match(/^#\s+(.+)$/m)?.[1]?.trim() || repo;
3102
+ async function downloadToBuffer(url, fetchFn, opts = {}) {
3103
+ const maxBytes = opts.maxBytes ?? 67108864;
3104
+ const r = await fetchFn(url, {
3105
+ signal: opts.signal,
3106
+ headers: opts.headers
3107
+ });
3108
+ if (!r.ok) throw new Error(`下载失败(HTTP ${r.status}):${url}`);
3109
+ const buf = Buffer.from(await r.arrayBuffer());
3110
+ if (buf.length === 0) throw new Error(`下载内容为空:${url}`);
3111
+ if (buf.length > maxBytes) throw new Error(`文件超过 ${Math.round(maxBytes / 1024 / 1024)}MB 上限,放弃导入`);
3112
+ return buf;
3113
+ }
3114
+ //#endregion
3115
+ //#region src/vendor/import-plan.ts
3116
+ /** 从 GitHub URL 提取 owner/repo;无效返回 null(与 IPC 旧正则一致,.git 后缀剥掉)。 */
3117
+ function parseGithubUrl$1(url) {
3118
+ const m = url.match(/github\.com\/([^/]+)\/([^/]+)/);
3119
+ if (!m) return null;
2413
3120
  return {
2414
- source: "github",
2415
- url,
2416
- owner,
2417
- repo,
2418
- branch: inventory.branch,
2419
- courseTitle,
2420
- readmeExcerpt: inventory.readmeMd.slice(0, 4e3),
2421
- files,
2422
- fullTreeCount: inventory.fullTree.length
3121
+ owner: m[1],
3122
+ repo: m[2].replace(/\.git$/, "")
2423
3123
  };
2424
3124
  }
3125
+ //#endregion
3126
+ //#region src/vendor/url-route.ts
2425
3127
  /**
2426
- * Build the pending design from a local folder scan — the same brief, but the
2427
- * bodies ride along (localContents), so apply is fully offline.
2428
- * @param path - the absolute folder path (becomes sourceRef).
2429
- * @param title - fallback course title (the folder's name).
2430
- * @param docs - scanFolder output (relative paths + content).
2431
- * @returns the pending design.
3128
+ * 智能 URL 路由 —— 一个输入框自动分流三类导入来源(纯函数)。
3129
+ *
3130
+ * github.com/{owner}/{repo} 走既有仓库导入(完整 5 步管线)
3131
+ * arxiv.org / export.arxiv.org /abs/ID /pdf/ID 论文 PDF 导入
3132
+ * 其余任意 http(s) 网页文章正文抽取(readability)
3133
+ *
3134
+ * 用户不需要理解三种来源的区别。纯函数,verify 直测。
2432
3135
  */
2433
- function buildPendingDesignFromFolder(path, title, docs) {
2434
- const files = docs.map((doc) => ({
2435
- path: doc.path,
2436
- role: doc.kind === "ipynb" ? "practice" : "original",
2437
- outline: extractOutlineWithCharCounts(doc.content, doc.path)
2438
- }));
2439
- const readme = docs.find((doc) => /^readme\.md$/i.test(doc.path.split("/").pop() ?? ""))?.content ?? "";
2440
- const courseTitle = readme.match(/^#\s+(.+)$/m)?.[1]?.trim() || title;
2441
- const localContents = new Map(docs.map((doc) => [doc.path, doc.content]));
3136
+ /** arXiv 论文 ID:新式 2401.12345[v2] / 旧式 cs.CL/24010000 / hep-th/9901001 */
3137
+ const ARXIV_ID = /^[a-z.-]+\/\d{7}$|^\d{4}\.\d{4,5}(v\d+)?$/i;
3138
+ function routeImportUrl(raw) {
3139
+ const url = raw.trim();
3140
+ if (!url || /\s/.test(url)) return null;
3141
+ if (parseGithubUrl$1(url)) return {
3142
+ kind: "github",
3143
+ url
3144
+ };
3145
+ if (url.includes("://") && !/^https?:\/\//i.test(url)) return null;
3146
+ let parsed;
3147
+ try {
3148
+ parsed = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`);
3149
+ } catch {
3150
+ return null;
3151
+ }
3152
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
3153
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
3154
+ if (host === "arxiv.org" || host === "export.arxiv.org") {
3155
+ const id = parsed.pathname.match(/^\/(?:abs|pdf)\/([^?#]+?)(?:\.pdf)?$/i)?.[1] ?? "";
3156
+ if (id && ARXIV_ID.test(id)) return {
3157
+ kind: "url",
3158
+ flavor: "arxiv",
3159
+ url: `https://arxiv.org/abs/${id}`,
3160
+ arxivId: id,
3161
+ pdfUrl: `https://export.arxiv.org/pdf/${id}`
3162
+ };
3163
+ }
3164
+ const path = parsed.pathname;
3165
+ if (host === "bilibili.com" || host === "b23.tv") {
3166
+ if (/(BV[a-zA-Z0-9]+|av\d+)/i.test(path) || host === "b23.tv" || path.startsWith("/video/")) return {
3167
+ kind: "video",
3168
+ source: "bilibili",
3169
+ url: parsed.toString()
3170
+ };
3171
+ }
3172
+ if (host === "douyin.com" || host === "v.douyin.com" || host === "iesdouyin.com") return {
3173
+ kind: "video",
3174
+ source: "ytdlp",
3175
+ url: parsed.toString()
3176
+ };
3177
+ if (host === "youtube.com" || host === "youtu.be" || host === "m.youtube.com") {
3178
+ if (host === "youtu.be" || path === "/watch" || path.startsWith("/shorts/") || path.startsWith("/live/")) return {
3179
+ kind: "video",
3180
+ source: "ytdlp",
3181
+ url: parsed.toString()
3182
+ };
3183
+ }
2442
3184
  return {
2443
- source: "folder",
2444
- url: path,
2445
- owner: "",
2446
- repo: title,
2447
- branch: "local",
2448
- courseTitle,
2449
- readmeExcerpt: readme.slice(0, 4e3),
2450
- files,
2451
- fullTreeCount: docs.length,
2452
- localContents
3185
+ kind: "url",
3186
+ flavor: "article",
3187
+ url: parsed.toString()
2453
3188
  };
2454
3189
  }
3190
+ /** 身份键用:归一化 URL(去 hash,去尾斜杠;query 保留——不少站点 query 载内容)。 */
3191
+ function normalizeUrlIdentity(url) {
3192
+ try {
3193
+ const u = new URL(url);
3194
+ u.hash = "";
3195
+ return u.toString().replace(/\/$/, "");
3196
+ } catch {
3197
+ return url;
3198
+ }
3199
+ }
3200
+ //#endregion
3201
+ //#region src/vendor/pdf-text.ts
3202
+ var pdf_text_exports = /* @__PURE__ */ __exportAll({
3203
+ normalizeRadicals: () => normalizeRadicals,
3204
+ parsePdfText: () => parsePdfText
3205
+ });
3206
+ function decodePdfString(s) {
3207
+ return s.replace(/\\([0-7]{1,3}|.)/g, (_m, esc) => {
3208
+ if (/^[0-7]+$/.test(esc)) return String.fromCharCode(parseInt(esc, 8));
3209
+ if (esc === "n") return "\n";
3210
+ if (esc === "r") return "\r";
3211
+ if (esc === "t") return " ";
3212
+ if (esc === "b" || esc === "f") return " ";
3213
+ return esc;
3214
+ });
3215
+ }
3216
+ function decodeHexString(s) {
3217
+ const clean = s.replace(/[^0-9a-fA-F]/g, "");
3218
+ let out = "";
3219
+ const bytes = [];
3220
+ for (let i = 0; i + 1 < clean.length; i += 2) bytes.push(parseInt(clean.slice(i, i + 2), 16));
3221
+ if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) {
3222
+ for (let i = 2; i + 1 < bytes.length; i += 2) out += String.fromCharCode(bytes[i] << 8 | bytes[i + 1]);
3223
+ return out;
3224
+ }
3225
+ for (const b of bytes) if (b >= 32 && b < 127) out += String.fromCharCode(b);
3226
+ return out;
3227
+ }
3228
+ /** Pull text out of one decoded content stream. */
3229
+ function textFromContentStream(content) {
3230
+ if (!/BT[\s\S]*?ET/.test(content)) return "";
3231
+ let out = "";
3232
+ const re = /\(((?:\\.|[^\\()])*)\)\s*(Tj|'|")|<([0-9a-fA-F\s]+)>\s*(Tj|'|")|\[((?:\(.*?\)|<[0-9a-fA-F\s]+>|[^\]])*?)\]\s*TJ/g;
3233
+ let m;
3234
+ while ((m = re.exec(content)) !== null) if (m[1] !== void 0) {
3235
+ out += decodePdfString(m[1]);
3236
+ if (m[2] === "'" || m[2] === "\"") out += "\n";
3237
+ } else if (m[3] !== void 0) {
3238
+ out += decodeHexString(m[3]);
3239
+ if (m[4] === "'" || m[4] === "\"") out += "\n";
3240
+ } else if (m[5] !== void 0) {
3241
+ const partRe = /\(((?:\\.|[^\\()])*)\)|<([0-9a-fA-F\s]+)>/g;
3242
+ let pm;
3243
+ while ((pm = partRe.exec(m[5])) !== null) out += pm[1] !== void 0 ? decodePdfString(pm[1]) : decodeHexString(pm[2] ?? "");
3244
+ }
3245
+ return out;
3246
+ }
2455
3247
  /**
2456
- * Render the design brief the ONLY channel into the tutor's context
2457
- * (dsh models see tool results through output.render alone). Carries the
3248
+ * PDF 文本层抽取(零依赖)。逐个 stream 对象:Flate 解压 BT/ET 块内 Tj/TJ 文本。
3249
+ * 顺序按对象在文件中的出现位置(绝大多数生成器按页序写)
3250
+ * 限制(诚实):加密 PDF、纯扫描图、Type0 复合字体编码的 PDF 返回部分或空文本;
3251
+ * 调用方按"无文本"处理,不让单个 PDF 崩掉导入(同上游契约)。
3252
+ */
3253
+ function parsePdfText(buf) {
3254
+ const latin = new TextDecoder("latin1");
3255
+ const raw = latin.decode(buf);
3256
+ if (/\/Encrypt\b/.test(raw)) return "";
3257
+ const parts = [];
3258
+ const streamRe = /stream\r?\n?/g;
3259
+ let sm;
3260
+ while ((sm = streamRe.exec(raw)) !== null) {
3261
+ const start = sm.index + sm[0].length;
3262
+ const end = raw.indexOf("endstream", start);
3263
+ if (end === -1) break;
3264
+ const head = raw.slice(Math.max(0, sm.index - 300), sm.index);
3265
+ if (!/obj\b[\s\S]*<<[\s\S]*>>\s*$/.test(head) && !/<<[^<>]*>>\s*$/.test(head)) continue;
3266
+ const chunk = buf.subarray(start, end);
3267
+ let content = "";
3268
+ if (/FlateDecode/.test(head)) try {
3269
+ content = latin.decode(inflateZlib(chunk));
3270
+ } catch {
3271
+ streamRe.lastIndex = end + 9;
3272
+ continue;
3273
+ }
3274
+ else if (!/\/Filter/.test(head)) content = latin.decode(chunk);
3275
+ else {
3276
+ streamRe.lastIndex = end + 9;
3277
+ continue;
3278
+ }
3279
+ const text = textFromContentStream(content);
3280
+ if (text.trim()) parts.push(text);
3281
+ streamRe.lastIndex = end + 9;
3282
+ }
3283
+ return normalizeRadicals(parts.join("\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim());
3284
+ }
3285
+ /**
3286
+ * 康熙部首区(U+2F00-U+2FDF)→CJK 统一表意区归一(upstream v0.23.1 port, verbatim)。
3287
+ * 部分中文 PDF 的 ToUnicode 映射落在部首区(d2l-zh 真书采样实测:"⼿⼀⽅"应读
3288
+ * "手一方"),部首区上检索/匹配全部断裂。逐字符 NFKC(部首→汉字是 Unicode 标准
3289
+ * 一对一兼容映射,不动其他任何字符)。
3290
+ */
3291
+ function normalizeRadicals(md) {
3292
+ if (!md) return md;
3293
+ return /[\u2F00-\u2FDF]/.test(md) ? md.replace(/[\u2F00-\u2FDF]/g, (c) => c.normalize("NFKC")) : md;
3294
+ }
3295
+ //#endregion
3296
+ //#region src/vendor/text-chunk.ts
3297
+ /** 段落/句子边界切分:优先段落聚合,超长段落内再按句子切。 */
3298
+ function splitToUnits(text) {
3299
+ const units = [];
3300
+ for (const para of text.split(/\n{2,}/)) {
3301
+ const p = para.replace(/\s+/g, " ").trim();
3302
+ if (!p) continue;
3303
+ if (p.length <= 600) {
3304
+ units.push(p);
3305
+ continue;
3306
+ }
3307
+ const sentences = p.match(/[^。!?!?.]+[。!?!?.]+["'”’))]*|[^。!?!?.]+$/g) ?? [p];
3308
+ for (const s of sentences) {
3309
+ const t = s.trim();
3310
+ if (t) units.push(t);
3311
+ }
3312
+ }
3313
+ return units;
3314
+ }
3315
+ /**
3316
+ * 把无标题长文切成多个虚拟 markdown 文件。
3317
+ * @param stem 虚拟文件名主干(如 "notes" / "arxiv-2401.12345"),产出 {stem}-01.md
3318
+ * @param targetChars 每段目标字符数(默认 4000,与 Step4 的 3000-8000 课时段对齐)
3319
+ */
3320
+ function chunkHeadinglessText(text, stem, targetChars = 4e3) {
3321
+ const cleaned = text.replace(/\r\n/g, "\n").trim();
3322
+ if (!cleaned) return [];
3323
+ const units = splitToUnits(cleaned);
3324
+ const chunks = [];
3325
+ let buf = [];
3326
+ let bufLen = 0;
3327
+ for (const u of units) {
3328
+ if (u.length > targetChars) {
3329
+ if (buf.length > 0) {
3330
+ chunks.push(buf.join("\n\n"));
3331
+ buf = [];
3332
+ bufLen = 0;
3333
+ }
3334
+ for (let i = 0; i < u.length; i += targetChars) chunks.push(u.slice(i, i + targetChars));
3335
+ continue;
3336
+ }
3337
+ if (bufLen + u.length > targetChars && buf.length > 0) {
3338
+ chunks.push(buf.join("\n\n"));
3339
+ buf = [];
3340
+ bufLen = 0;
3341
+ }
3342
+ buf.push(u);
3343
+ bufLen += u.length;
3344
+ }
3345
+ if (buf.length > 0) chunks.push(buf.join("\n\n"));
3346
+ return chunks.map((c, i) => {
3347
+ return {
3348
+ path: `${stem}-${String(i + 1).padStart(2, "0")}.md`,
3349
+ content: `# 第 ${i + 1} 部分\n\n${c}`
3350
+ };
3351
+ });
3352
+ }
3353
+ /**
3354
+ * 单一长文档的统一预处理器:有 H2/H3 结构 → 整体一个文件(Step4 自己按标题拆);
3355
+ * 无结构且超长 → chunkHeadinglessText 预分段。url/arXiv/粘贴三个来源共用。
3356
+ */
3357
+ function prepareSingleDoc(name, markdown, stem) {
3358
+ const md = markdown.trim();
3359
+ if (!md) return [];
3360
+ if ((md.match(/^##\s/m) ?? []).length >= 3 || md.length <= 8e3) return [{
3361
+ path: `${name.replace(/[\\/:*?"<>|#]/g, "-").trim() || "document"}.md`,
3362
+ content: md
3363
+ }];
3364
+ return chunkHeadinglessText(md, (stem ?? name.replace(/\s+/g, "-").slice(0, 40)) || "text");
3365
+ }
3366
+ //#endregion
3367
+ //#region src/vendor/video-meta.ts
3368
+ const BILI_HEADERS = {
3369
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36",
3370
+ Referer: "https://www.bilibili.com/"
3371
+ };
3372
+ /** 从 B站 URL 提取 BV/av 号与分P(b23.tv 短链由 fetchFn 跟随重定向展开)。
3373
+ * page=undefined 表示 URL 未带 ?p= —— 多分P 视频导入整季;带 ?p=N 只导该集。 */
3374
+ function parseBilibiliId(url) {
3375
+ const m = (url.match(/bilibili\.com\/(?:video\/)?(?:BV[a-zA-Z0-9]+|av\d+)/i) ? url : null)?.match(/(BV[a-zA-Z0-9]+|av(\d+))/i);
3376
+ if (!m) return null;
3377
+ const pm = url.match(/[?&]p=(\d+)/);
3378
+ return {
3379
+ bvid: m[1].toLowerCase().startsWith("bv") ? m[1] : void 0,
3380
+ aid: m[2] ? Number(m[2]) : void 0,
3381
+ page: pm ? Math.max(1, Number(pm[1])) : void 0
3382
+ };
3383
+ }
3384
+ /** view API 元数据(免登录,无 wbi)。b23.tv 短链由 fetchFn 重定向展开后重试一次。 */
3385
+ async function fetchBilibiliMeta(url, fetchFn, signal) {
3386
+ let id = parseBilibiliId(url);
3387
+ if (!id && /b23\.tv/i.test(url)) id = parseBilibiliId((await fetchFn(url, {
3388
+ signal,
3389
+ headers: BILI_HEADERS
3390
+ })).url);
3391
+ if (!id) throw new Error("不是可识别的 B站视频链接(BV/av 号缺失)");
3392
+ const resp = await (await fetchFn(`https://api.bilibili.com/x/web-interface/view?${id.bvid ? `bvid=${id.bvid}` : `aid=${id.aid}`}`, {
3393
+ signal,
3394
+ headers: BILI_HEADERS
3395
+ })).json();
3396
+ if (resp.code !== 0 || !resp.data) throw new Error(`B站接口返回错误(${resp.code} ${resp.message ?? ""})`);
3397
+ const d = resp.data;
3398
+ return {
3399
+ title: (d.title ?? "").trim() || "未命名视频",
3400
+ owner: (d.owner?.name ?? "").trim(),
3401
+ desc: (d.desc ?? "").trim(),
3402
+ parts: (d.pages ?? []).map((p) => (p.part ?? "").trim()).filter(Boolean),
3403
+ duration: d.duration ?? 0
3404
+ };
3405
+ }
3406
+ //#endregion
3407
+ //#region src/vendor/token-estimate.ts
3408
+ /**
3409
+ * token 用量估算 —— main 与 renderer 共用的纯启发式(零依赖,不引 tokenizer)。
3410
+ *
3411
+ * 为什么是启发式:本地优先 + 无原生模块约束(sql.js/Electron),而上下文表只需要
3412
+ * "约 x%"的量级感,不需要精确计费。标尺来自主流 tokenizer 的经验值:
3413
+ * - CJK 字符 ≈ 1.1 token/字(o200k/cl100k 对常用汉字 1-2 token,均值取 1.1)
3414
+ * - 其他(拉丁/代码/符号) ≈ 4 字符/token
3415
+ *
3416
+ * 纯函数:确定性、可测(verify-token-estimate.mjs)。
3417
+ */
3418
+ /** CJK 及全角区段(含 CJK 标点/全角 ASCII/假名/谚文边缘),命中即按"一字一 token 档"计。 */
3419
+ const CJK_RE = /[\u2E80-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/g;
3420
+ /** 估算一段文本的 token 数。空串返回 0;只向上取整。
3421
+ * CJK 用整数算术(×11/10)避开 0.1 的浮点误差,100 字恰好 110 而非 111。 */
3422
+ function estimateTokens(text) {
3423
+ if (!text) return 0;
3424
+ const cjk = (text.match(CJK_RE) ?? []).length;
3425
+ const other = text.length - cjk;
3426
+ return Math.ceil(cjk * 11 / 10 + other / 4);
3427
+ }
3428
+ /**
3429
+ * Build the pending design from Step 1 (inventory) + Step 3 (outlines).
3430
+ * @param url - the GitHub URL the learner asked to import.
3431
+ * @param owner - repo owner.
3432
+ * @param repo - repo name.
3433
+ * @param inventory - fetchRepoInventory output (README, file list, tree).
3434
+ * @param outlines - fetchFileOutlines output, keyed by path.
3435
+ * @returns the pending design the brief renders from.
3436
+ */
3437
+ function buildPendingDesign(url, owner, repo, inventory, outlines) {
3438
+ const files = [];
3439
+ for (const discovered of inventory.fileList) {
3440
+ const outline = outlines.get(discovered.path);
3441
+ if (outline === void 0) continue;
3442
+ files.push({
3443
+ path: discovered.path,
3444
+ role: discovered.kind === "ipynb" ? "practice" : "original",
3445
+ outline
3446
+ });
3447
+ }
3448
+ const courseTitle = inventory.readmeMd.match(/^#\s+(.+)$/m)?.[1]?.trim() || repo;
3449
+ return {
3450
+ source: "github",
3451
+ url,
3452
+ owner,
3453
+ repo,
3454
+ branch: inventory.branch,
3455
+ courseTitle,
3456
+ readmeExcerpt: inventory.readmeMd.slice(0, 4e3),
3457
+ files,
3458
+ fullTreeCount: inventory.fullTree.length
3459
+ };
3460
+ }
3461
+ /**
3462
+ * Build the pending design from a local folder scan — the same brief, but the
3463
+ * bodies ride along (localContents), so apply is fully offline.
3464
+ * @param path - the absolute folder path (becomes sourceRef).
3465
+ * @param title - fallback course title (the folder's name).
3466
+ * @param docs - scanFolder output (relative paths + content).
3467
+ * @returns the pending design.
3468
+ */
3469
+ function buildPendingDesignFromFolder(path, title, docs, extra) {
3470
+ const files = docs.map((doc) => ({
3471
+ path: doc.path,
3472
+ role: doc.kind === "ipynb" ? "practice" : "original",
3473
+ outline: extractOutlineWithCharCounts(doc.content, doc.path)
3474
+ }));
3475
+ const readme = docs.find((doc) => /^readme\.md$/i.test(doc.path.split("/").pop() ?? ""))?.content ?? "";
3476
+ const courseTitle = readme.match(/^#\s+(.+)$/m)?.[1]?.trim() || title;
3477
+ const localContents = new Map(docs.map((doc) => [doc.path, doc.content]));
3478
+ return {
3479
+ source: "folder",
3480
+ url: path,
3481
+ owner: "",
3482
+ repo: title,
3483
+ branch: "local",
3484
+ courseTitle,
3485
+ readmeExcerpt: readme.slice(0, 4e3),
3486
+ files,
3487
+ fullTreeCount: docs.length,
3488
+ localContents,
3489
+ ...extra?.translations !== void 0 && extra.translations.length > 0 ? { translations: collectTranslations(extra.translations) } : {},
3490
+ ...extra?.images !== void 0 && extra.images.length > 0 ? { localImages: /* @__PURE__ */ new Map() } : {}
3491
+ };
3492
+ }
3493
+ /** Rewrite ![alt](relRef) refs in one file's content to the inlined data URLs
3494
+ * (refs resolve relative to the markdown file's own directory). */
3495
+ function inlineLocalImages(content, filePath, images) {
3496
+ if (images.size === 0) return content;
3497
+ const dir = filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : "";
3498
+ const resolve = (ref) => {
3499
+ const parts = `${dir}/${ref.replace(/^\.\//, "")}`.split("/");
3500
+ const stack = [];
3501
+ for (const p of parts) if (p === "..") stack.pop();
3502
+ else if (p !== "." && p !== "") stack.push(p);
3503
+ return stack.join("/");
3504
+ };
3505
+ return content.replace(/(!\[[^\]]*\]\()([^)\s]+)(\))/g, (m, open, ref, close) => {
3506
+ if (/^(https?:|data:)/i.test(ref)) return m;
3507
+ const data = images.get(resolve(ref));
3508
+ return data === void 0 ? m : `${open}${data}${close}`;
3509
+ });
3510
+ }
3511
+ /** translations/{lang}/{originalPath} → Map<originalPath, {lang, content}> (upstream translation pairing). */
3512
+ function collectTranslations(translations) {
3513
+ const out = /* @__PURE__ */ new Map();
3514
+ for (const t of translations) {
3515
+ const m = t.path.match(/^translations\/([^/]+)\/(.+)$/);
3516
+ if (m === null) continue;
3517
+ out.set(m[2], {
3518
+ lang: m[1],
3519
+ content: t.content
3520
+ });
3521
+ }
3522
+ return out;
3523
+ }
3524
+ /** Rewrite relative image refs to jsDelivr raw URLs (GitHub imports keep
3525
+ * referencing the CDN — no download, upstream image-refs semantics). */
3526
+ function rewriteGithubImageRefs(content, filePath, owner, repo, branch) {
3527
+ const dir = filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : "";
3528
+ const resolve = (ref) => {
3529
+ const parts = `${dir}/${ref.replace(/^\.\//, "")}`.split("/");
3530
+ const stack = [];
3531
+ for (const p of parts) if (p === "..") stack.pop();
3532
+ else if (p !== "." && p !== "") stack.push(p);
3533
+ return stack.join("/");
3534
+ };
3535
+ return content.replace(/(!\[[^\]]*\]\()([^)\s]+)(\))/g, (m, open, ref, close) => {
3536
+ if (/^(https?:|data:)/i.test(ref)) return m;
3537
+ return `${open}https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${resolve(ref)}${close}`;
3538
+ });
3539
+ }
3540
+ /**
3541
+ * Build the pending design from URL-sourced docs (arXiv PDF text / web article
3542
+ * markdown, already chunked by prepareSingleDoc) — bodies ride along so apply
3543
+ * is fully offline, same as folder imports.
3544
+ * @param url - the normalized source URL (becomes sourceRef).
3545
+ * @param title - the fetched title (article title / arXiv id label).
3546
+ * @param docs - virtual docs ({stem}-01.md parts or one whole file).
3547
+ * @returns the pending design.
3548
+ */
3549
+ function buildPendingDesignFromUrl(url, title, docs) {
3550
+ const files = docs.map((doc) => ({
3551
+ path: doc.path,
3552
+ role: "original",
3553
+ outline: extractOutlineWithCharCounts(doc.content, doc.path)
3554
+ }));
3555
+ const localContents = new Map(docs.map((doc) => [doc.path, doc.content]));
3556
+ return {
3557
+ source: "url",
3558
+ url,
3559
+ owner: "",
3560
+ repo: title,
3561
+ branch: "web",
3562
+ courseTitle: title,
3563
+ readmeExcerpt: docs[0]?.content.slice(0, 4e3) ?? "",
3564
+ files,
3565
+ fullTreeCount: docs.length,
3566
+ localContents
3567
+ };
3568
+ }
3569
+ /**
3570
+ * Render the design brief — the ONLY channel into the tutor's context
3571
+ * (dsh models see tool results through output.render alone). Carries the
2458
3572
  * upstream design rules: study/practice/attached classification, the
2459
3573
  * 3000-8000 chars lesson pacing, sub-1000 merging, attached absorption, and
2460
3574
  * the strict JSON contract study_apply_design expects.
2461
3575
  * @param pending - the pending design to present.
2462
3576
  * @returns the full brief text.
2463
3577
  */
2464
- function renderDesignBrief(pending) {
3578
+ function renderDesignBrief(pending, part = 1) {
3579
+ const parts = planBriefParts(pending.files);
3580
+ const shown = parts[Math.min(Math.max(part, 1), parts.length) - 1];
2465
3581
  const lines = [];
2466
3582
  lines.push(`## Course design brief: ${pending.courseTitle}`);
2467
- lines.push(pending.source === "folder" ? `Folder import (${pending.fullTreeCount} files; ${pending.files.length} course files below).` : `Repo ${pending.owner}/${pending.repo}@${pending.branch} (${pending.fullTreeCount} paths in tree; ${pending.files.length} course files below).`);
3583
+ lines.push(pending.source === "folder" ? `Folder import (${pending.fullTreeCount} files; ${shown.length} of ${pending.files.length} course files below).` : pending.source === "url" ? `Web import from ${pending.url} (${pending.fullTreeCount} document parts; ${shown.length} of ${pending.files.length} course files below).` : `Repo ${pending.owner}/${pending.repo}@${pending.branch} (${pending.fullTreeCount} paths in tree; ${shown.length} of ${pending.files.length} course files below).`);
3584
+ if (parts.length > 1) {
3585
+ lines.push("");
3586
+ lines.push(`**Brief part ${Math.min(Math.max(part, 1), parts.length)} of ${parts.length}** (context-budget split, ~${Math.round(BRIEF_PART_TOKEN_BUDGET / 1e3)}k tokens per part). Design lessons ONLY from the files in this part, apply, then call the same import tool again with \`part\` = ${Math.min(Math.max(part, 1), parts.length) + 1 <= parts.length ? Math.min(Math.max(part, 1), parts.length) + 1 : "done"} — each part imports as its own course. File paths from other parts are valid but invisible to you; do not guess them.`);
3587
+ }
2468
3588
  lines.push("");
2469
3589
  lines.push("### Repository README (first 4000 chars)");
2470
3590
  lines.push(pending.readmeExcerpt.trim() === "" ? "(empty)" : pending.readmeExcerpt);
2471
3591
  lines.push("");
2472
3592
  lines.push("### Files (role hint · h1 · totalChars · H2/H3 outline with per-heading chars)");
2473
- for (const file of pending.files) {
3593
+ for (const file of shown) {
2474
3594
  lines.push(`- ${file.path} (role hint: ${file.role}, total ${file.outline.totalChars} chars, h1: ${file.outline.h1})`);
2475
3595
  const headings = file.outline.headings.slice(0, 40);
2476
3596
  for (const heading of headings) lines.push(` ${"#".repeat(heading.level)} ${heading.title} [${heading.chars}]`);
@@ -2491,13 +3611,41 @@ function renderDesignBrief(pending) {
2491
3611
  lines.push("Other rules:");
2492
3612
  lines.push("- role hints are references, not verdicts — README tables usually mark real roles (Lesson link = study, Notebook/Lab = practice).");
2493
3613
  lines.push("- if the directory layout is already clear (e.g. lessons/N-Topic/), keep its sections; do not over-reorganize.");
2494
- lines.push(`- anchor is the full heading text used to slice the body; omit it for whole-file lessons.${pending.files.length > 80 ? " This repo is large: design at file granularity (omit anchors, one lesson per file) to keep the JSON manageable." : ""}`);
3614
+ lines.push(`- anchor is the full heading text used to slice the body; omit it for whole-file lessons.${shown.length > 80 ? " This part is large: design at file granularity (omit anchors, one lesson per file) to keep the JSON manageable." : ""}`);
2495
3615
  lines.push("");
2496
3616
  lines.push("Now design the course and call study_apply_design with:");
2497
3617
  lines.push("{ \"sections\": [ { \"title\": \"...\", \"lessons\": [ { \"title\": \"...\", \"file\": \"<exact path from this brief>\", \"anchor\": \"<optional full heading text>\", \"world\": \"study\" | \"practice\" } ] } ] }");
2498
3618
  lines.push("Use ONLY file paths that appear in this brief — anything else is dropped. Apply directly, then walk the learner through the course map.");
2499
3619
  return lines.join("\n");
2500
3620
  }
3621
+ /** Context-budget per brief part (upstream import v2 batching semantics, dsh-adapted:
3622
+ * the brief is the plugin's classification input, so the split rides the brief). */
3623
+ const BRIEF_PART_TOKEN_BUDGET = 48e3;
3624
+ /** Greedily pack brief files into token-budgeted parts (upstream Step-2 batching).
3625
+ * A single file larger than the budget gets its own part (never dropped). */
3626
+ function planBriefParts(files, budgetTokens = BRIEF_PART_TOKEN_BUDGET) {
3627
+ const parts = [];
3628
+ let current = [];
3629
+ let used = 0;
3630
+ for (const file of files) {
3631
+ const cost = estimateTokens(briefFileBlock(file));
3632
+ if (current.length > 0 && used + cost > budgetTokens) {
3633
+ parts.push(current);
3634
+ current = [];
3635
+ used = 0;
3636
+ }
3637
+ current.push(file);
3638
+ used += cost;
3639
+ }
3640
+ if (current.length > 0) parts.push(current);
3641
+ return parts.length > 0 ? parts : [[]];
3642
+ }
3643
+ /** The rendered brief block for one file (the unit the token budget packs). */
3644
+ function briefFileBlock(file) {
3645
+ const lines = [`${file.path} (role hint: ${file.role}, total ${file.outline.totalChars} chars, h1: ${file.outline.h1})`];
3646
+ for (const heading of file.outline.headings.slice(0, 40)) lines.push(`${"#".repeat(heading.level)} ${heading.title} [${heading.chars}]`);
3647
+ return lines.join("\n");
3648
+ }
2501
3649
  /**
2502
3650
  * Extract H2/H3 headings with line numbers; headings inside ``` / ~~~ fences
2503
3651
  * are body text (upstream import-pipeline.ts extractHeadings).
@@ -2631,7 +3779,7 @@ function slugAnchor(title) {
2631
3779
  * @param contents - fetched body text per designed file (missing key = loud failure).
2632
3780
  * @returns the parsed course ready for importCourse.
2633
3781
  */
2634
- function buildCourseFromDesign(courseTitle, validated, contents) {
3782
+ function buildCourseFromDesign(courseTitle, validated, contents, translations) {
2635
3783
  const headingsCache = /* @__PURE__ */ new Map();
2636
3784
  const headingsOf = (file) => {
2637
3785
  const cached = headingsCache.get(file);
@@ -2653,12 +3801,23 @@ function buildCourseFromDesign(courseTitle, validated, contents) {
2653
3801
  const titleIndex = lesson.anchor === null ? -1 : findTitleIndex(headings, lesson.anchor);
2654
3802
  const isFirst = !firstLessonSeen.has(lesson.file);
2655
3803
  firstLessonSeen.add(lesson.file);
3804
+ const pair = translations?.get(lesson.file);
3805
+ let translation;
3806
+ if (pair !== void 0) {
3807
+ const tHeadings = extractHeadings(pair.content);
3808
+ const tIndex = lesson.anchor === null ? -1 : findTitleIndex(tHeadings, lesson.anchor);
3809
+ translation = sliceLessonBody(pair.content, tHeadings, tIndex, isFirst);
3810
+ }
2656
3811
  return {
2657
3812
  title: lesson.title,
2658
3813
  anchor: slugAnchor(lesson.title),
2659
3814
  body: sliceLessonBody(content, headings, titleIndex, isFirst),
2660
3815
  sourceFilePath: lesson.file,
2661
- world: lesson.world
3816
+ world: lesson.world,
3817
+ ...translation !== void 0 ? {
3818
+ translation,
3819
+ translationLang: pair.lang
3820
+ } : {}
2662
3821
  };
2663
3822
  });
2664
3823
  return {
@@ -2713,14 +3872,14 @@ function designBriefLines(value) {
2713
3872
  * @returns card lines.
2714
3873
  */
2715
3874
  function mapLines(value) {
2716
- const lines = [`🗺 ${value.title} — ${value.counts.mastered}/${value.counts.total} mastered`];
3875
+ const lines = [`🗺 ${value.title} [courseId ${value.courseId}] — ${value.counts.mastered}/${value.counts.total} mastered`];
2717
3876
  for (const section of value.tree) {
2718
3877
  lines.push(`▍${section.title}`);
2719
3878
  for (const lesson of section.lessons) {
2720
3879
  const mastery = lesson.masteryPct === null ? "" : ` · ${lesson.masteryPct}%${lesson.crown >= 4 ? " 👑" : ""}`;
2721
3880
  const weak = lesson.weakConcepts > 0 ? ` · ⚡${lesson.weakConcepts}` : "";
2722
3881
  const friction = lesson.frictionCount > 0 ? ` · 😣${lesson.frictionCount}` : "";
2723
- lines.push(` ${statusGlyph(lesson.kind, lesson.status)} ${lesson.title}${mastery}${weak}${friction}`);
3882
+ lines.push(` ${statusGlyph(lesson.kind, lesson.status)} ${lesson.title} [lessonId ${lesson.id}]${mastery}${weak}${friction}`);
2724
3883
  }
2725
3884
  }
2726
3885
  return lines;
@@ -2745,7 +3904,7 @@ function dueLines(value) {
2745
3904
  const lines = [`🔁 ${value.total} due`];
2746
3905
  for (const item of value.due) {
2747
3906
  const overdue = item.overdueDays > 0 ? ` · ${item.overdueDays}d overdue` : "";
2748
- lines.push(` ⏰ ${item.lessonTitle} — ${item.courseTitle}${overdue}`);
3907
+ lines.push(` ⏰ ${item.lessonTitle} [lessonId ${item.lessonId}] — ${item.courseTitle}${overdue}`);
2749
3908
  }
2750
3909
  return lines;
2751
3910
  }
@@ -2876,9 +4035,37 @@ function toMapValue(course) {
2876
4035
  };
2877
4036
  }
2878
4037
  /** Canonical value of `study_lesson`. */
4038
+ /** The learner-state block (upstream learner-model buildLearnerSnapshot): one
4039
+ * composed projection of mastery/status/weak concepts/friction/memory that the
4040
+ * tutor consumes whole instead of re-deriving from scattered fields. */
4041
+ function learnerStateLine(ref, state) {
4042
+ const l = ref.lesson;
4043
+ const parts = [];
4044
+ parts.push(`status ${l.status}, mastery ${l.mastery === null ? "untracked" : `${Math.round(l.mastery * 100)}%`}, strategy ${strategyBand(l.mastery)}`);
4045
+ const weak = (l.concepts ?? []).filter((_c, i) => (l.conceptMastery?.[i] ?? .5) < .7);
4046
+ if (weak.length > 0) parts.push(`weak concepts: ${weak.map((c) => c.title).join("、")}`);
4047
+ const frictionCats = [...new Set(l.friction.slice(-5).map((f) => f.category))];
4048
+ if (frictionCats.length > 0) parts.push(`recent friction: ${frictionCats.join("/")}`);
4049
+ if (l.memory !== null) parts.push(`lesson memory: ${l.memory}`);
4050
+ if (state.memoryGlobal !== null) parts.push(`global memory: ${state.memoryGlobal}`);
4051
+ return parts.join(" | ");
4052
+ }
2879
4053
  function toLessonValue(ref, state) {
2880
4054
  const next = nextLesson(ref.course, ref.lesson.id);
2881
4055
  const pending = state.proposals.find((p) => p.lessonId === ref.lesson.id && p.status === "pending");
4056
+ /** Exam design guide (upstream exam-logic): question quota from the section's
4057
+ * KC union, per-question time rule, star thresholds — only on exam nodes. */
4058
+ const examGuide = ref.lesson.kind === "exam" ? (() => {
4059
+ const kcTitles = [...new Set(ref.section.lessons.filter((l) => l.kind !== "exam" && l.concepts !== null).flatMap((l) => l.concepts.map((c) => c.title)))];
4060
+ return {
4061
+ questionCount: planExamQuota(kcTitles).reduce((a, b) => a + b, 0),
4062
+ kcCount: kcTitles.length,
4063
+ timeLimitRule: "per-question seconds = 45 + cjkChars/5 + words/3 + options×8, +25 code block, +25 formula, clamp 60–300 (questionTimeLimitSec)",
4064
+ starsRule: "≥95%→3★, ≥80%→2★, ≥60%→1★, below→0 (best-of kept; report with study_exam_result)",
4065
+ bestStars: ref.lesson.examStars ?? 0,
4066
+ examAttempts: ref.lesson.examAttempts ?? 0
4067
+ };
4068
+ })() : null;
2882
4069
  return {
2883
4070
  lessonId: ref.lesson.id,
2884
4071
  courseId: ref.course.id,
@@ -2905,7 +4092,10 @@ function toLessonValue(ref, state) {
2905
4092
  id: pending.id,
2906
4093
  rationale: pending.rationale
2907
4094
  },
2908
- nextLessonId: next?.id ?? null
4095
+ nextLessonId: next?.id ?? null,
4096
+ learnerState: learnerStateLine(ref, state),
4097
+ ...ref.lesson.summary !== void 0 ? { summary: ref.lesson.summary } : {},
4098
+ ...examGuide !== null ? { examGuide } : {}
2909
4099
  };
2910
4100
  }
2911
4101
  /**
@@ -2948,6 +4138,8 @@ function studyTools(store, deps = {}) {
2948
4138
  * replaces an unconsumed one.
2949
4139
  */
2950
4140
  let pendingDesign = null;
4141
+ /** Which context-budget part of the pending brief the tutor last asked for. */
4142
+ let pendingPart = 1;
2951
4143
  /** Run a mutating state operation and persist. */
2952
4144
  const mutate = (fn) => {
2953
4145
  const result = fn(store.get());
@@ -3088,13 +4280,21 @@ function studyTools(store, deps = {}) {
3088
4280
  fullTreeCount: {
3089
4281
  type: "integer",
3090
4282
  required: true
4283
+ },
4284
+ part: {
4285
+ type: "integer",
4286
+ description: "Brief part this call rendered (context-budget batching)."
4287
+ },
4288
+ partCount: {
4289
+ type: "integer",
4290
+ description: "Total brief parts; >1 means design+apply per part."
3091
4291
  }
3092
4292
  }
3093
4293
  }] } };
3094
4294
  /** Shared render: the brief rides the design_required branch; imported stays the old summary. */
3095
4295
  const designOrImportedRender = (_args, value) => [{
3096
4296
  type: "text",
3097
- 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) : `Imported course “${value.title}” (${value.sections} sections, ${value.lessons} lessons). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}).`
4297
+ 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}).`
3098
4298
  }];
3099
4299
  const designOrImportedPresent = {
3100
4300
  presentationMeta: (_args, value) => value.status === "design_required" ? designBriefLines(value) : importLines(value),
@@ -3103,1167 +4303,1873 @@ function studyTools(store, deps = {}) {
3103
4303
  content: textBlocks(result.meta)
3104
4304
  })
3105
4305
  };
3106
- return [
3107
- importMarkdown,
3108
- defineTool({
3109
- name: "study_import_folder",
3110
- 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.",
3111
- parameters: {
3112
- path: {
3113
- type: "string",
3114
- required: true,
3115
- description: "Absolute path of the folder to scan."
3116
- },
3117
- title: {
3118
- type: "string",
3119
- description: "Optional course title overriding the folder name / README H1."
3120
- }
3121
- },
3122
- output: {
3123
- ...designOrImportedOutput,
3124
- render: designOrImportedRender
4306
+ const importFolder = defineTool({
4307
+ name: "study_import_folder",
4308
+ 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.",
4309
+ parameters: {
4310
+ path: {
4311
+ type: "string",
4312
+ required: true,
4313
+ description: "Absolute path of the folder to scan."
3125
4314
  },
3126
- async execute(args) {
3127
- if (!existsSync(args.path)) throw new Error(`lookatstudy-plugin: folder does not exist: ${args.path}`);
3128
- const existing = store.get().courses.find((c) => c.source === "folder" && c.sourceRef === args.path);
3129
- if (existing !== void 0) return {
3130
- status: "imported",
3131
- ...toImportValue(existing)
3132
- };
3133
- const docs = await scanFolder(args.path);
3134
- if (docs.length === 0) throw new Error(`lookatstudy-plugin: no importable files found in ${args.path}`);
3135
- const title = args.title ?? basename(args.path.replaceAll("\\", "/"));
3136
- pendingDesign = buildPendingDesignFromFolder(args.path, title, docs);
3137
- return {
3138
- status: "design_required",
3139
- repo: pendingDesign.repo,
3140
- branch: pendingDesign.branch,
3141
- courseTitle: pendingDesign.courseTitle,
3142
- fileCount: pendingDesign.files.length,
3143
- fullTreeCount: pendingDesign.fullTreeCount
3144
- };
4315
+ title: {
4316
+ type: "string",
4317
+ description: "Optional course title overriding the folder name / README H1."
3145
4318
  },
3146
- timeoutMs: 6e4,
3147
- presentCall: (args) => ({
3148
- card: "generic",
3149
- title: `Scan folder: ${args.path}`,
3150
- kind: "read",
3151
- rawInput: args.path
3152
- }),
3153
- ...designOrImportedPresent
4319
+ part: {
4320
+ type: "integer",
4321
+ description: "Which context-budget brief part to render (1-based); relevant only when the folder is huge."
4322
+ }
4323
+ },
4324
+ output: {
4325
+ ...designOrImportedOutput,
4326
+ render: designOrImportedRender
4327
+ },
4328
+ async execute(args) {
4329
+ if (!existsSync(args.path)) throw new Error(`lookatstudy-plugin: folder does not exist: ${args.path}`);
4330
+ const part = args.part ?? 1;
4331
+ const existing = part <= 1 ? store.get().courses.find((c) => c.source === "folder" && c.sourceRef === args.path) : void 0;
4332
+ if (existing !== void 0) return {
4333
+ status: "imported",
4334
+ ...toImportValue(existing)
4335
+ };
4336
+ const inventory = await buildLocalInventory(args.path);
4337
+ const docs = inventory.docs;
4338
+ if (docs.length === 0) throw new Error(`lookatstudy-plugin: no importable files found in ${args.path}`);
4339
+ const title = args.title ?? basename(args.path.replaceAll("\\", "/"));
4340
+ pendingDesign = buildPendingDesignFromFolder(args.path, title, docs, {
4341
+ translations: inventory.translations,
4342
+ images: inventory.images
4343
+ });
4344
+ const localImages = /* @__PURE__ */ new Map();
4345
+ for (const img of inventory.images) {
4346
+ if (img.absPath === "" || img.source === "pdf_page") continue;
4347
+ try {
4348
+ const buf = await readFile(img.absPath);
4349
+ if (buf.length > 2e5) continue;
4350
+ localImages.set(img.path, `data:${img.mime};base64,${buf.toString("base64")}`);
4351
+ } catch {}
4352
+ }
4353
+ if (localImages.size > 0) pendingDesign.localImages = localImages;
4354
+ pendingPart = part;
4355
+ return designRequiredValue(pendingDesign, part);
4356
+ },
4357
+ timeoutMs: 6e4,
4358
+ presentCall: (args) => ({
4359
+ card: "generic",
4360
+ title: `Scan folder: ${args.path}`,
4361
+ kind: "read",
4362
+ rawInput: args.path
3154
4363
  }),
3155
- defineTool({
3156
- name: "study_import_github",
3157
- description: "Start importing a GitHub learning repository: fetches the README outline and every course file's heading outline (with char counts) through the jsDelivr CDN, then returns a design brief — the TUTOR designs the course structure (sections/lessons/anchors/worlds) from it and applies the result with study_apply_design. Re-importing an already-imported URL returns the existing course directly. Awesome-lists are rejected.",
3158
- parameters: {
3159
- url: {
3160
- type: "string",
3161
- required: true,
3162
- description: "Repository URL, e.g. https://github.com/microsoft/AI-For-Beginners."
3163
- },
3164
- branch: {
3165
- type: "string",
3166
- description: "Branch to read (main tried, then master); defaults to main."
3167
- }
4364
+ ...designOrImportedPresent
4365
+ });
4366
+ /** Stamp the requested brief part onto the pending design and build the design_required value. */
4367
+ const designRequiredValue = (pd, part) => {
4368
+ const partCount = planBriefParts(pd.files).length;
4369
+ pd.part = part;
4370
+ pd.partCount = partCount;
4371
+ return {
4372
+ status: "design_required",
4373
+ repo: pd.repo,
4374
+ branch: pd.branch,
4375
+ courseTitle: partCount > 1 ? `${pd.courseTitle} (part ${part})` : pd.courseTitle,
4376
+ fileCount: planBriefParts(pd.files)[Math.min(part, partCount) - 1].length,
4377
+ fullTreeCount: pd.fullTreeCount,
4378
+ ...partCount > 1 ? {
4379
+ part,
4380
+ partCount
4381
+ } : {}
4382
+ };
4383
+ };
4384
+ /** Shared GitHub import flow — study_import_github's core, reused verbatim by
4385
+ * study_import_url's github branch (routing is the only difference). */
4386
+ const runGithubImport = async (url, branch, exec, part = 1) => {
4387
+ const { owner, repo } = parseGithubUrl(url);
4388
+ const resolvedBranch = branch ?? "main";
4389
+ const fetchFn = signalFetch(exec.signal, baseFetch);
4390
+ const existing = part <= 1 ? store.get().courses.find((c) => c.source === "github" && c.sourceRef === url) : void 0;
4391
+ if (existing !== void 0) return {
4392
+ status: "imported",
4393
+ ...toImportValue(existing)
4394
+ };
4395
+ const inventory = await fetchRepoInventory(owner, repo, resolvedBranch, fetchFn, void 0, exec.signal);
4396
+ pendingDesign = buildPendingDesign(url, owner, repo, inventory, await fetchFileOutlines(inventory.fileList.map((f) => f.path), owner, repo, inventory.branch, fetchFn, void 0, exec.signal));
4397
+ if (pendingDesign.files.length === 0) throw new Error("lookatstudy-plugin: course files were discovered but no outlines could be fetched (CDN unreachable?)");
4398
+ pendingPart = part;
4399
+ return designRequiredValue(pendingDesign, part);
4400
+ };
4401
+ const importGithub = defineTool({
4402
+ name: "study_import_github",
4403
+ description: "Start importing a GitHub learning repository: fetches the README outline and every course file's heading outline (with char counts) through the jsDelivr CDN, then returns a design brief — the TUTOR designs the course structure (sections/lessons/anchors/worlds) from it and applies the result with study_apply_design. Re-importing an already-imported URL returns the existing course directly. Awesome-lists are rejected.",
4404
+ parameters: {
4405
+ url: {
4406
+ type: "string",
4407
+ required: true,
4408
+ description: "Repository URL, e.g. https://github.com/microsoft/AI-For-Beginners."
3168
4409
  },
3169
- output: {
3170
- ...designOrImportedOutput,
3171
- render: designOrImportedRender
4410
+ branch: {
4411
+ type: "string",
4412
+ description: "Branch to read (main tried, then master); defaults to main."
3172
4413
  },
3173
- async execute(args, exec) {
3174
- const { owner, repo } = parseGithubUrl(args.url);
3175
- const branch = args.branch ?? "main";
3176
- const fetchFn = signalFetch(exec.signal, baseFetch);
3177
- const existing = store.get().courses.find((c) => c.source === "github" && c.sourceRef === args.url);
4414
+ part: {
4415
+ type: "integer",
4416
+ description: "Which context-budget brief part to render (1-based); relevant only for huge repos."
4417
+ }
4418
+ },
4419
+ output: {
4420
+ ...designOrImportedOutput,
4421
+ render: designOrImportedRender
4422
+ },
4423
+ execute: (args, exec) => runGithubImport(args.url, args.branch, exec, args.part ?? 1),
4424
+ timeoutMs: 18e4,
4425
+ presentCall: (args) => ({
4426
+ card: "generic",
4427
+ title: `Import GitHub course: ${args.url}`,
4428
+ kind: "fetch"
4429
+ }),
4430
+ ...designOrImportedPresent
4431
+ });
4432
+ const importUrl = defineTool({
4433
+ name: "study_import_url",
4434
+ description: "Import any learning URL by auto-routing: GitHub repos reuse the repository import; arXiv papers download the PDF and extract its text layer; other http(s) pages get web-article extraction (nav/ads stripped, honest failure on non-article pages). All routes return the same design brief the tutor designs against (apply with study_apply_design). Video links (B站/YouTube/抖音) are classified with title metadata but cannot be transcribed here — ask the learner to paste the transcript/subtitles and use study_import_markdown instead.",
4435
+ parameters: {
4436
+ url: {
4437
+ type: "string",
4438
+ required: true,
4439
+ description: "The URL to import: github.com repo, arxiv.org paper, or any web article page."
4440
+ },
4441
+ part: {
4442
+ type: "integer",
4443
+ description: "Which context-budget brief part to render (1-based); relevant only for enormous documents."
4444
+ }
4445
+ },
4446
+ output: {
4447
+ ...designOrImportedOutput,
4448
+ render: designOrImportedRender
4449
+ },
4450
+ async execute(args, exec) {
4451
+ const route = routeImportUrl(args.url);
4452
+ if (route === null) throw new Error(`lookatstudy-plugin: ${JSON.stringify(args.url)} is not a recognizable import URL (github repo / arxiv paper / web article / video link)`);
4453
+ if (route.kind === "github") return runGithubImport(args.url, void 0, exec, args.part ?? 1);
4454
+ const fetchFn = signalFetch(exec.signal, baseFetch);
4455
+ if (route.kind === "video") {
4456
+ if (route.source === "bilibili") {
4457
+ const meta = await fetchBilibiliMeta(route.url, fetchFn, exec.signal);
4458
+ const parts = meta.parts.length > 0 ? `, ${meta.parts.length} 分P` : "";
4459
+ throw new Error(`lookatstudy-plugin: B站视频《${meta.title}》(UP:${meta.owner}${parts})已识别,但插件内无字幕拉取与音频转写能力。请让学习者把字幕/文稿粘贴进来,用 study_import_markdown 导入。`);
4460
+ }
4461
+ throw new Error(`lookatstudy-plugin: ${route.url} 是视频链接(YouTube/抖音需要 yt-dlp,插件环境不可用)。请让学习者把字幕/文稿粘贴进来,用 study_import_markdown 导入。`);
4462
+ }
4463
+ if (route.flavor === "arxiv") {
4464
+ const text = parsePdfText(await downloadToBuffer(route.pdfUrl, fetchFn, { signal: exec.signal }));
4465
+ 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");
4466
+ const docs = prepareSingleDoc(`arxiv-${route.arxivId}`, `# arXiv:${route.arxivId}\n\n${text}`);
4467
+ if (docs.length === 0) throw new Error("lookatstudy-plugin: arXiv PDF text was empty after chunking");
4468
+ const part = args.part ?? 1;
4469
+ const existing = part <= 1 ? store.get().courses.find((c) => c.source === "url" && c.sourceRef === route.url) : void 0;
3178
4470
  if (existing !== void 0) return {
3179
4471
  status: "imported",
3180
4472
  ...toImportValue(existing)
3181
4473
  };
3182
- const inventory = await fetchRepoInventory(owner, repo, branch, fetchFn, void 0, exec.signal);
3183
- const outlines = await fetchFileOutlines(inventory.fileList.map((f) => f.path), owner, repo, inventory.branch, fetchFn, void 0, exec.signal);
3184
- pendingDesign = buildPendingDesign(args.url, owner, repo, inventory, outlines);
3185
- if (pendingDesign.files.length === 0) throw new Error("lookatstudy-plugin: course files were discovered but no outlines could be fetched (CDN unreachable?)");
3186
- return {
3187
- status: "design_required",
3188
- repo: `${owner}/${repo}`,
3189
- branch: pendingDesign.branch,
3190
- courseTitle: pendingDesign.courseTitle,
3191
- fileCount: pendingDesign.files.length,
3192
- fullTreeCount: pendingDesign.fullTreeCount
3193
- };
3194
- },
3195
- timeoutMs: 18e4,
3196
- presentCall: (args) => ({
3197
- card: "generic",
3198
- title: `Import GitHub course: ${args.url}`,
3199
- kind: "fetch"
3200
- }),
3201
- ...designOrImportedPresent
4474
+ pendingDesign = buildPendingDesignFromUrl(route.url, `arXiv:${route.arxivId}`, docs);
4475
+ pendingPart = part;
4476
+ return designRequiredValue(pendingDesign, part);
4477
+ }
4478
+ const part = args.part ?? 1;
4479
+ const identity = normalizeUrlIdentity(route.url);
4480
+ const existing = part <= 1 ? store.get().courses.find((c) => c.source === "url" && c.sourceRef === identity) : void 0;
4481
+ if (existing !== void 0) return {
4482
+ status: "imported",
4483
+ ...toImportValue(existing)
4484
+ };
4485
+ const resp = await fetchFn(route.url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; LookatStudyPlugin/0.9)" } });
4486
+ if (!resp.ok) throw new Error(`lookatstudy-plugin: page fetch failed (HTTP ${resp.status}): ${route.url}`);
4487
+ const html = await resp.text();
4488
+ const article = extractArticle(html, route.url);
4489
+ 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`);
4490
+ const docs = prepareSingleDoc(article.title.slice(0, 60), article.markdown);
4491
+ if (docs.length === 0) throw new Error("lookatstudy-plugin: article body was empty after chunking");
4492
+ pendingDesign = buildPendingDesignFromUrl(identity, article.title, docs);
4493
+ pendingPart = part;
4494
+ return designRequiredValue(pendingDesign, part);
4495
+ },
4496
+ timeoutMs: 12e4,
4497
+ presentCall: (args) => ({
4498
+ card: "generic",
4499
+ title: `Import URL: ${args.url}`,
4500
+ kind: "fetch"
3202
4501
  }),
3203
- defineTool({
3204
- name: "study_apply_design",
3205
- description: "Apply the tutor-designed course structure to the pending import (the one study_import_github or study_import_folder returned design_required for). Every lesson's file must come from the design brief — unknown paths are dropped (anti-hallucination); lesson bodies are sliced by their anchor heading and the course is imported. On a validation or fetch error the tutor fixes the design and simply calls again.",
3206
- parameters: { sections: {
3207
- type: "array",
3208
- required: true,
3209
- description: "Designed sections in learning order.",
3210
- items: {
3211
- type: "object",
3212
- additionalProperties: false,
3213
- properties: {
3214
- title: {
3215
- type: "string",
3216
- required: true,
3217
- description: "Section title (in the learner's language)."
3218
- },
3219
- lessons: {
3220
- type: "array",
3221
- required: true,
3222
- items: {
3223
- type: "object",
3224
- additionalProperties: false,
3225
- properties: {
3226
- title: {
3227
- type: "string",
3228
- required: true,
3229
- description: "Lesson title."
3230
- },
3231
- file: {
3232
- type: "string",
3233
- required: true,
3234
- description: "Exact file path from the design brief."
3235
- },
3236
- anchor: {
3237
- type: "string",
3238
- description: "Full H2/H3 heading text the lesson body starts at; omit for whole-file lessons."
3239
- },
3240
- world: {
3241
- type: "string",
3242
- description: "\"study\" (explanation) or \"practice\" (exercise/lab/notebook); anything else is treated as study."
3243
- }
4502
+ ...designOrImportedPresent
4503
+ });
4504
+ const applyDesign = defineTool({
4505
+ name: "study_apply_design",
4506
+ description: "Apply the tutor-designed course structure to the pending import (the one study_import_github or study_import_folder returned design_required for). Every lesson's file must come from the design brief — unknown paths are dropped (anti-hallucination); lesson bodies are sliced by their anchor heading and the course is imported. On a validation or fetch error the tutor fixes the design and simply calls again.",
4507
+ parameters: { sections: {
4508
+ type: "array",
4509
+ required: true,
4510
+ description: "Designed sections in learning order.",
4511
+ items: {
4512
+ type: "object",
4513
+ additionalProperties: false,
4514
+ properties: {
4515
+ title: {
4516
+ type: "string",
4517
+ required: true,
4518
+ description: "Section title (in the learner's language)."
4519
+ },
4520
+ lessons: {
4521
+ type: "array",
4522
+ required: true,
4523
+ items: {
4524
+ type: "object",
4525
+ additionalProperties: false,
4526
+ properties: {
4527
+ title: {
4528
+ type: "string",
4529
+ required: true,
4530
+ description: "Lesson title."
4531
+ },
4532
+ file: {
4533
+ type: "string",
4534
+ required: true,
4535
+ description: "Exact file path from the design brief."
4536
+ },
4537
+ anchor: {
4538
+ type: "string",
4539
+ description: "Full H2/H3 heading text the lesson body starts at; omit for whole-file lessons."
4540
+ },
4541
+ world: {
4542
+ type: "string",
4543
+ description: "\"study\" (explanation) or \"practice\" (exercise/lab/notebook); anything else is treated as study."
3244
4544
  }
3245
4545
  }
3246
4546
  }
3247
4547
  }
3248
4548
  }
3249
- } },
3250
- output: {
3251
- schema: {
3252
- type: "object",
3253
- additionalProperties: false,
3254
- properties: {
3255
- courseId: {
3256
- type: "string",
3257
- required: true
3258
- },
3259
- title: {
3260
- type: "string",
3261
- required: true
3262
- },
3263
- sections: {
3264
- type: "integer",
3265
- required: true
3266
- },
3267
- lessons: {
3268
- type: "integer",
3269
- required: true
3270
- },
3271
- firstLessonId: {
3272
- type: "string",
3273
- required: true
3274
- },
3275
- firstLessonTitle: {
3276
- type: "string",
3277
- required: true
3278
- },
3279
- droppedLessons: {
3280
- type: "integer",
3281
- required: true
3282
- }
4549
+ }
4550
+ } },
4551
+ output: {
4552
+ schema: {
4553
+ type: "object",
4554
+ additionalProperties: false,
4555
+ properties: {
4556
+ courseId: {
4557
+ type: "string",
4558
+ required: true
4559
+ },
4560
+ title: {
4561
+ type: "string",
4562
+ required: true
4563
+ },
4564
+ sections: {
4565
+ type: "integer",
4566
+ required: true
4567
+ },
4568
+ lessons: {
4569
+ type: "integer",
4570
+ required: true
4571
+ },
4572
+ firstLessonId: {
4573
+ type: "string",
4574
+ required: true
4575
+ },
4576
+ firstLessonTitle: {
4577
+ type: "string",
4578
+ required: true
4579
+ },
4580
+ droppedLessons: {
4581
+ type: "integer",
4582
+ required: true
3283
4583
  }
3284
- },
3285
- render: (_args, value) => [{
3286
- type: "text",
3287
- text: `Imported designed course “${value.title}” (${value.sections} sections, ${value.lessons} lessons${value.droppedLessons > 0 ? `, ${value.droppedLessons} hallucinated lesson(s) dropped` : ""}). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}). Present the course map to the learner.`
3288
- }]
4584
+ }
3289
4585
  },
3290
- async execute(args, exec) {
3291
- const pd = pendingDesign;
3292
- if (pd === null) throw new Error("lookatstudy-plugin: no pending course design call study_import_github or study_import_folder first (a dsh restart also clears it)");
3293
- const validated = validateDesign(args, new Set(pd.files.map((f) => f.path)));
3294
- const uniqueFiles = [...new Set(validated.sections.flatMap((s) => s.lessons.map((l) => l.file)))];
3295
- const contents = /* @__PURE__ */ new Map();
3296
- if (pd.localContents !== void 0) for (const file of uniqueFiles) {
3297
- const text = pd.localContents.get(file);
3298
- if (text === void 0) throw new Error(`lookatstudy-plugin: designed file ${JSON.stringify(file)} is not in the scanned folder — use only paths from the design brief`);
3299
- contents.set(file, text);
4586
+ render: (_args, value) => [{
4587
+ type: "text",
4588
+ text: `Imported designed course “${value.title}” (${value.sections} sections, ${value.lessons} lessons${value.droppedLessons > 0 ? `, ${value.droppedLessons} hallucinated lesson(s) dropped` : ""}). First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}). Present the course map to the learner.`
4589
+ }]
4590
+ },
4591
+ async execute(args, exec) {
4592
+ const pd = pendingDesign;
4593
+ if (pd === null) throw new Error("lookatstudy-plugin: no pending course design — call study_import_github or study_import_folder first (a dsh restart also clears it)");
4594
+ const validated = validateDesign(args, new Set(pd.files.map((f) => f.path)));
4595
+ const uniqueFiles = [...new Set(validated.sections.flatMap((s) => s.lessons.map((l) => l.file)))];
4596
+ const contents = /* @__PURE__ */ new Map();
4597
+ if (pd.localContents !== void 0) for (const file of uniqueFiles) {
4598
+ const text = pd.localContents.get(file);
4599
+ if (text === void 0) throw new Error(`lookatstudy-plugin: designed file ${JSON.stringify(file)} is not in the scanned folder — use only paths from the design brief`);
4600
+ contents.set(file, pd.localImages === void 0 ? text : inlineLocalImages(text, file, pd.localImages));
4601
+ }
4602
+ else {
4603
+ const failed = [];
4604
+ const fetchFn = signalFetch(exec.signal, baseFetch);
4605
+ for (let i = 0; i < uniqueFiles.length; i += 5) {
4606
+ if (exec.signal.aborted) throw new Error("lookatstudy-plugin: import aborted");
4607
+ const batch = uniqueFiles.slice(i, i + 5);
4608
+ const texts = await Promise.all(batch.map((f) => fetchSingleFileContent(f, pd.owner, pd.repo, pd.branch, fetchFn)));
4609
+ for (let j = 0; j < batch.length; j++) if (texts[j] === null) failed.push(batch[j]);
4610
+ else contents.set(batch[j], rewriteGithubImageRefs(texts[j], batch[j], pd.owner, pd.repo, pd.branch));
4611
+ }
4612
+ if (contents.size === 0) throw new Error(`lookatstudy-plugin: every designed file failed to fetch (${failed.length}) — the CDN path is unreachable; retry or re-import`);
4613
+ if (failed.length > 0) throw new Error(`lookatstudy-plugin: ${failed.length} designed file(s) failed to fetch: ${failed.join(", ")} — drop or fix them and call study_apply_design again`);
4614
+ }
4615
+ const parsed = buildCourseFromDesign(pd.part !== void 0 && pd.partCount !== void 0 && pd.partCount > 1 ? `${pd.courseTitle} (part ${pd.part})` : pd.courseTitle, validated, contents, pd.translations);
4616
+ requireParsedLessons(parsed);
4617
+ const value = mutate((state) => toImportValue(importCourse(state, parsed, pd.source, pd.url)));
4618
+ pendingDesign = null;
4619
+ pendingPart = 1;
4620
+ return {
4621
+ ...value,
4622
+ droppedLessons: validated.droppedLessons
4623
+ };
4624
+ },
4625
+ timeoutMs: 18e4,
4626
+ presentCall: () => ({
4627
+ card: "generic",
4628
+ title: "Apply course design",
4629
+ kind: "edit"
4630
+ }),
4631
+ presentationMeta: (_args, value) => [...importLines(value), ...value.droppedLessons > 0 ? [`${value.droppedLessons} dropped (files outside the brief)`] : []],
4632
+ presentResult: (_args, result) => ({
4633
+ card: "generic",
4634
+ content: textBlocks(result.meta)
4635
+ })
4636
+ });
4637
+ const listCourses = defineTool({
4638
+ name: "study_courses",
4639
+ description: "List imported courses with progress, average mastery, due reviews, and the current lesson id — plus the learner's XP/level/streak block (upstream xp-service + streak). With `query`, runs a full-text multi-keyword AND search over every lesson title AND body (upstream course-tree-filter extended) and returns the hits alongside the course list.",
4640
+ parameters: { query: {
4641
+ type: "string",
4642
+ description: "Optional full-text search: space-separated keywords, all must hit title or body."
4643
+ } },
4644
+ output: {
4645
+ schema: {
4646
+ type: "object",
4647
+ additionalProperties: false,
4648
+ properties: {
4649
+ total: {
4650
+ type: "integer",
4651
+ required: true
4652
+ },
4653
+ courses: {
4654
+ type: "array",
4655
+ required: true,
4656
+ items: {
4657
+ type: "object",
4658
+ additionalProperties: false,
4659
+ properties: {
4660
+ courseId: {
4661
+ type: "string",
4662
+ required: true
4663
+ },
4664
+ title: {
4665
+ type: "string",
4666
+ required: true
4667
+ },
4668
+ source: {
4669
+ type: "string",
4670
+ required: true,
4671
+ enum: [
4672
+ "markdown",
4673
+ "folder",
4674
+ "github",
4675
+ "url"
4676
+ ]
4677
+ },
4678
+ total: {
4679
+ type: "integer",
4680
+ required: true
4681
+ },
4682
+ mastered: {
4683
+ type: "integer",
4684
+ required: true
4685
+ },
4686
+ avgMasteryPct: {
4687
+ ...nullableInteger,
4688
+ required: true
4689
+ },
4690
+ dueCount: {
4691
+ type: "integer",
4692
+ required: true
4693
+ },
4694
+ currentLessonId: {
4695
+ ...nullableString,
4696
+ required: true
4697
+ }
4698
+ }
4699
+ }
4700
+ },
4701
+ progress: {
4702
+ type: "object",
4703
+ required: true,
4704
+ additionalProperties: false,
4705
+ description: "XP + streak block (upstream xp-service/streak semantics).",
4706
+ properties: {
4707
+ totalXp: {
4708
+ type: "integer",
4709
+ required: true
4710
+ },
4711
+ level: {
4712
+ type: "integer",
4713
+ required: true
4714
+ },
4715
+ levelPct: {
4716
+ type: "integer",
4717
+ required: true
4718
+ },
4719
+ todayXp: {
4720
+ type: "integer",
4721
+ required: true
4722
+ },
4723
+ dailyGoal: {
4724
+ type: "integer",
4725
+ required: true
4726
+ },
4727
+ streak: {
4728
+ type: "integer",
4729
+ required: true
4730
+ },
4731
+ longestStreak: {
4732
+ type: "integer",
4733
+ required: true
4734
+ },
4735
+ freezeCount: {
4736
+ type: "integer",
4737
+ required: true
4738
+ }
4739
+ }
4740
+ },
4741
+ matches: {
4742
+ type: "array",
4743
+ description: "Full-text hits (present only when query was given).",
4744
+ items: {
4745
+ type: "object",
4746
+ additionalProperties: false,
4747
+ properties: {
4748
+ courseId: {
4749
+ type: "string",
4750
+ required: true
4751
+ },
4752
+ courseTitle: {
4753
+ type: "string",
4754
+ required: true
4755
+ },
4756
+ lessonId: {
4757
+ type: "string",
4758
+ required: true
4759
+ },
4760
+ lessonTitle: {
4761
+ type: "string",
4762
+ required: true
4763
+ },
4764
+ snippet: {
4765
+ type: "string",
4766
+ required: true
4767
+ }
4768
+ }
4769
+ }
4770
+ }
3300
4771
  }
3301
- else {
3302
- const failed = [];
3303
- const fetchFn = signalFetch(exec.signal, baseFetch);
3304
- for (let i = 0; i < uniqueFiles.length; i += 5) {
3305
- if (exec.signal.aborted) throw new Error("lookatstudy-plugin: import aborted");
3306
- const batch = uniqueFiles.slice(i, i + 5);
3307
- const texts = await Promise.all(batch.map((f) => fetchSingleFileContent(f, pd.owner, pd.repo, pd.branch, fetchFn)));
3308
- for (let j = 0; j < batch.length; j++) if (texts[j] === null) failed.push(batch[j]);
3309
- else contents.set(batch[j], texts[j]);
4772
+ },
4773
+ render: (_args, value) => [{
4774
+ type: "text",
4775
+ text: value.courses.length === 0 ? "No courses imported yet. Import one with study_import_markdown, study_import_folder, study_import_github, or study_import_url." : value.courses.map((c) => `[courseId ${c.courseId}] “${c.title}” (${c.source}) — ${c.mastered}/${c.total} lessons mastered${c.avgMasteryPct === null ? "" : `, avg mastery ${c.avgMasteryPct}%`}${c.dueCount === 0 ? "" : `, ${c.dueCount} reviews due`}${c.currentLessonId === null ? "" : `, current lesson ${c.currentLessonId}`}`).join("\n") + `\nXP ${value.progress.totalXp} (Lv${value.progress.level} ${value.progress.levelPct}%), today ${value.progress.todayXp}/${value.progress.dailyGoal}, streak ${value.progress.streak}d (best ${value.progress.longestStreak}d, ${value.progress.freezeCount} freeze left)` + (Array.isArray(value.matches) && value.matches.length > 0 ? `\nsearch hits (use study_lesson with the lessonId):\n${value.matches.map((m) => `- ${m.lessonTitle} [lessonId ${m.lessonId}] (${m.courseTitle}): ${m.snippet}`).join("\n")}` : "")
4776
+ }]
4777
+ },
4778
+ async execute(args) {
4779
+ const state = store.get();
4780
+ const summaries = courseSummaries(state, /* @__PURE__ */ new Date());
4781
+ const xp = levelFromTotalXp(state.xp.total);
4782
+ return {
4783
+ total: summaries.length,
4784
+ courses: summaries.map((s) => ({
4785
+ courseId: s.courseId,
4786
+ title: s.title,
4787
+ source: s.source,
4788
+ total: s.total,
4789
+ mastered: s.mastered,
4790
+ avgMasteryPct: s.avgMasteryPct,
4791
+ dueCount: s.dueCount,
4792
+ currentLessonId: s.currentLessonId
4793
+ })),
4794
+ progress: {
4795
+ totalXp: state.xp.total,
4796
+ level: xp.level,
4797
+ levelPct: xp.pct,
4798
+ todayXp: state.xp.todayXp,
4799
+ dailyGoal: 30,
4800
+ streak: state.streak.currentStreak,
4801
+ longestStreak: state.streak.longestStreak,
4802
+ freezeCount: state.streak.freezeCount
4803
+ },
4804
+ ...args.query !== void 0 ? { matches: searchLessons(state, args.query) } : {}
4805
+ };
4806
+ },
4807
+ isConcurrencySafe: () => true,
4808
+ presentCall: (args) => ({
4809
+ card: "generic",
4810
+ title: args.query === void 0 ? "List courses" : `Search lessons: ${args.query}`,
4811
+ kind: "read"
4812
+ })
4813
+ });
4814
+ const courseMap = defineTool({
4815
+ name: "study_map",
4816
+ description: "Show one course's skill tree: sections, lessons with locked/available/in_progress/mastered status, mastery, weak-concept count (⚡), and friction count — the weak spots to target.",
4817
+ parameters: { courseId: {
4818
+ type: "string",
4819
+ required: true,
4820
+ description: "Course id from an import result or study_courses."
4821
+ } },
4822
+ output: {
4823
+ schema: {
4824
+ type: "object",
4825
+ additionalProperties: false,
4826
+ properties: {
4827
+ courseId: {
4828
+ type: "string",
4829
+ required: true
4830
+ },
4831
+ title: {
4832
+ type: "string",
4833
+ required: true
4834
+ },
4835
+ counts: {
4836
+ type: "object",
4837
+ required: true,
4838
+ additionalProperties: false,
4839
+ properties: {
4840
+ total: {
4841
+ type: "integer",
4842
+ required: true
4843
+ },
4844
+ mastered: {
4845
+ type: "integer",
4846
+ required: true
4847
+ },
4848
+ available: {
4849
+ type: "integer",
4850
+ required: true
4851
+ }
4852
+ }
4853
+ },
4854
+ tree: {
4855
+ type: "array",
4856
+ required: true,
4857
+ items: {
4858
+ type: "object",
4859
+ additionalProperties: false,
4860
+ properties: {
4861
+ title: {
4862
+ type: "string",
4863
+ required: true
4864
+ },
4865
+ lessons: {
4866
+ type: "array",
4867
+ required: true,
4868
+ items: {
4869
+ type: "object",
4870
+ additionalProperties: false,
4871
+ properties: {
4872
+ id: {
4873
+ type: "string",
4874
+ required: true
4875
+ },
4876
+ title: {
4877
+ type: "string",
4878
+ required: true
4879
+ },
4880
+ kind: {
4881
+ type: "string",
4882
+ required: true,
4883
+ enum: [...LESSON_KINDS]
4884
+ },
4885
+ status: {
4886
+ type: "string",
4887
+ required: true,
4888
+ enum: [...LESSON_STATUSES]
4889
+ },
4890
+ masteryPct: {
4891
+ ...nullableInteger,
4892
+ required: true
4893
+ },
4894
+ crown: {
4895
+ type: "integer",
4896
+ required: true
4897
+ },
4898
+ weakConcepts: {
4899
+ type: "integer",
4900
+ required: true
4901
+ },
4902
+ frictionCount: {
4903
+ type: "integer",
4904
+ required: true
4905
+ }
4906
+ }
4907
+ }
4908
+ }
4909
+ }
4910
+ }
3310
4911
  }
3311
- if (contents.size === 0) throw new Error(`lookatstudy-plugin: every designed file failed to fetch (${failed.length}) — the CDN path is unreachable; retry or re-import`);
3312
- if (failed.length > 0) throw new Error(`lookatstudy-plugin: ${failed.length} designed file(s) failed to fetch: ${failed.join(", ")} — drop or fix them and call study_apply_design again`);
3313
4912
  }
3314
- const parsed = buildCourseFromDesign(pd.courseTitle, validated, contents);
3315
- requireParsedLessons(parsed);
3316
- const value = mutate((state) => toImportValue(importCourse(state, parsed, pd.source, pd.url)));
3317
- pendingDesign = null;
3318
- return {
3319
- ...value,
3320
- droppedLessons: validated.droppedLessons
3321
- };
3322
4913
  },
3323
- timeoutMs: 18e4,
3324
- presentCall: () => ({
3325
- card: "generic",
3326
- title: "Apply course design",
3327
- kind: "edit"
3328
- }),
3329
- presentationMeta: (_args, value) => [...importLines(value), ...value.droppedLessons > 0 ? [`${value.droppedLessons} dropped (files outside the brief)`] : []],
3330
- presentResult: (_args, result) => ({
3331
- card: "generic",
3332
- content: textBlocks(result.meta)
3333
- })
4914
+ render: (_args, value) => textBlocks(mapLines(value))
4915
+ },
4916
+ async execute(args) {
4917
+ return toMapValue(findCourse(store.get(), args.courseId));
4918
+ },
4919
+ isConcurrencySafe: () => true,
4920
+ presentCall: (args) => ({
4921
+ card: "generic",
4922
+ title: `Course map: ${args.courseId}`,
4923
+ kind: "read"
3334
4924
  }),
3335
- defineTool({
3336
- name: "study_courses",
3337
- description: "List imported courses with progress, average mastery, due reviews, and the current lesson id.",
3338
- parameters: {},
3339
- output: {
3340
- schema: {
3341
- type: "object",
3342
- additionalProperties: false,
3343
- properties: {
3344
- total: {
3345
- type: "integer",
3346
- required: true
3347
- },
3348
- courses: {
4925
+ presentationMeta: (_args, value) => mapLines(value),
4926
+ presentResult: (_args, result) => ({
4927
+ card: "generic",
4928
+ content: textBlocks(result.meta)
4929
+ })
4930
+ });
4931
+ const lessonContent = defineTool({
4932
+ name: "study_lesson",
4933
+ description: "Open one lesson and make it the focus: returns its markdown content (the source of truth to teach from), teaching strategy band, knowledge concepts with mastery/weak flags, four consolidation starters, memory slots, and any pending mastery proposal.",
4934
+ parameters: { lessonId: {
4935
+ type: "string",
4936
+ required: true,
4937
+ description: "Lesson id from a map, import, or courses call."
4938
+ } },
4939
+ output: {
4940
+ schema: {
4941
+ type: "object",
4942
+ additionalProperties: false,
4943
+ properties: {
4944
+ lessonId: {
4945
+ type: "string",
4946
+ required: true
4947
+ },
4948
+ courseId: {
4949
+ type: "string",
4950
+ required: true
4951
+ },
4952
+ courseTitle: {
4953
+ type: "string",
4954
+ required: true
4955
+ },
4956
+ sectionTitle: {
4957
+ type: "string",
4958
+ required: true
4959
+ },
4960
+ title: {
4961
+ type: "string",
4962
+ required: true
4963
+ },
4964
+ kind: {
4965
+ type: "string",
4966
+ required: true,
4967
+ enum: [...LESSON_KINDS]
4968
+ },
4969
+ status: {
4970
+ type: "string",
4971
+ required: true,
4972
+ enum: [...LESSON_STATUSES]
4973
+ },
4974
+ body: {
4975
+ type: "string",
4976
+ required: true
4977
+ },
4978
+ masteryPct: {
4979
+ ...nullableInteger,
4980
+ required: true
4981
+ },
4982
+ crown: {
4983
+ type: "integer",
4984
+ required: true
4985
+ },
4986
+ attempts: {
4987
+ type: "integer",
4988
+ required: true
4989
+ },
4990
+ correctCount: {
4991
+ type: "integer",
4992
+ required: true
4993
+ },
4994
+ strategy: {
4995
+ type: "string",
4996
+ required: true
4997
+ },
4998
+ concepts: {
4999
+ oneOf: [{ type: "null" }, {
3349
5000
  type: "array",
3350
- required: true,
3351
5001
  items: {
3352
5002
  type: "object",
3353
5003
  additionalProperties: false,
3354
5004
  properties: {
3355
- courseId: {
3356
- type: "string",
3357
- required: true
3358
- },
3359
5005
  title: {
3360
5006
  type: "string",
3361
5007
  required: true
3362
5008
  },
3363
- source: {
3364
- type: "string",
3365
- required: true,
3366
- enum: [
3367
- "markdown",
3368
- "folder",
3369
- "github"
3370
- ]
3371
- },
3372
- total: {
3373
- type: "integer",
3374
- required: true
3375
- },
3376
- mastered: {
5009
+ masteryPct: {
3377
5010
  type: "integer",
3378
5011
  required: true
3379
5012
  },
3380
- avgMasteryPct: {
3381
- ...nullableInteger,
5013
+ weak: {
5014
+ type: "boolean",
3382
5015
  required: true
3383
5016
  },
3384
- dueCount: {
5017
+ /** 1 once this concept has been quizzed at least once, else 0 (ConceptView.tested). */
5018
+ tested: {
3385
5019
  type: "integer",
3386
5020
  required: true
3387
- },
3388
- currentLessonId: {
3389
- ...nullableString,
3390
- required: true
3391
5021
  }
3392
5022
  }
3393
5023
  }
3394
- }
3395
- }
3396
- },
3397
- render: (_args, value) => [{
3398
- type: "text",
3399
- text: value.courses.length === 0 ? "No courses imported yet. Import one with study_import_markdown, study_import_folder, or study_import_github." : value.courses.map((c) => `“${c.title}” (${c.source}) — ${c.mastered}/${c.total} lessons mastered${c.avgMasteryPct === null ? "" : `, avg mastery ${c.avgMasteryPct}%`}${c.dueCount === 0 ? "" : `, ${c.dueCount} reviews due`}${c.currentLessonId === null ? "" : `, current lesson ${c.currentLessonId}`}`).join("\n")
3400
- }]
3401
- },
3402
- async execute() {
3403
- const summaries = courseSummaries(store.get(), /* @__PURE__ */ new Date());
3404
- return {
3405
- total: summaries.length,
3406
- courses: summaries.map((s) => ({
3407
- courseId: s.courseId,
3408
- title: s.title,
3409
- source: s.source,
3410
- total: s.total,
3411
- mastered: s.mastered,
3412
- avgMasteryPct: s.avgMasteryPct,
3413
- dueCount: s.dueCount,
3414
- currentLessonId: s.currentLessonId
3415
- }))
3416
- };
3417
- },
3418
- isConcurrencySafe: () => true,
3419
- presentCall: () => ({
3420
- card: "generic",
3421
- title: "List courses",
3422
- kind: "read"
3423
- })
3424
- }),
3425
- defineTool({
3426
- name: "study_map",
3427
- description: "Show one course's skill tree: sections, lessons with locked/available/in_progress/mastered status, mastery, weak-concept count (⚡), and friction count — the weak spots to target.",
3428
- parameters: { courseId: {
3429
- type: "string",
3430
- required: true,
3431
- description: "Course id from an import result or study_courses."
3432
- } },
3433
- output: {
3434
- schema: {
3435
- type: "object",
3436
- additionalProperties: false,
3437
- properties: {
3438
- courseId: {
3439
- type: "string",
3440
- required: true
3441
- },
3442
- title: {
3443
- type: "string",
3444
- required: true
3445
- },
3446
- counts: {
5024
+ }],
5025
+ required: true
5026
+ },
5027
+ starters: {
5028
+ type: "array",
5029
+ required: true,
5030
+ items: {
3447
5031
  type: "object",
3448
- required: true,
3449
5032
  additionalProperties: false,
3450
5033
  properties: {
3451
- total: {
3452
- type: "integer",
5034
+ label: {
5035
+ type: "string",
3453
5036
  required: true
3454
5037
  },
3455
- mastered: {
3456
- type: "integer",
5038
+ message: {
5039
+ type: "string",
3457
5040
  required: true
3458
5041
  },
3459
- available: {
3460
- type: "integer",
3461
- required: true
5042
+ effect: {
5043
+ type: "string",
5044
+ required: true,
5045
+ enum: [
5046
+ "mastery",
5047
+ "friction",
5048
+ "none"
5049
+ ]
3462
5050
  }
3463
5051
  }
3464
- },
3465
- tree: {
3466
- type: "array",
3467
- required: true,
3468
- items: {
3469
- type: "object",
3470
- additionalProperties: false,
3471
- properties: {
3472
- title: {
3473
- type: "string",
3474
- required: true
3475
- },
3476
- lessons: {
3477
- type: "array",
3478
- required: true,
3479
- items: {
3480
- type: "object",
3481
- additionalProperties: false,
3482
- properties: {
3483
- id: {
3484
- type: "string",
3485
- required: true
3486
- },
3487
- title: {
3488
- type: "string",
3489
- required: true
3490
- },
3491
- kind: {
3492
- type: "string",
3493
- required: true,
3494
- enum: [...LESSON_KINDS]
3495
- },
3496
- status: {
3497
- type: "string",
3498
- required: true,
3499
- enum: [...LESSON_STATUSES]
3500
- },
3501
- masteryPct: {
3502
- ...nullableInteger,
3503
- required: true
3504
- },
3505
- crown: {
3506
- type: "integer",
3507
- required: true
3508
- },
3509
- weakConcepts: {
3510
- type: "integer",
3511
- required: true
3512
- },
3513
- frictionCount: {
3514
- type: "integer",
3515
- required: true
3516
- }
3517
- }
3518
- }
3519
- }
5052
+ }
5053
+ },
5054
+ memory: {
5055
+ type: "object",
5056
+ required: true,
5057
+ additionalProperties: false,
5058
+ properties: {
5059
+ lesson: {
5060
+ ...nullableString,
5061
+ required: true
5062
+ },
5063
+ global: {
5064
+ ...nullableString,
5065
+ required: true
5066
+ },
5067
+ pattern: {
5068
+ ...nullableString,
5069
+ required: true
5070
+ }
5071
+ }
5072
+ },
5073
+ noteCount: {
5074
+ type: "integer",
5075
+ required: true
5076
+ },
5077
+ pendingProposal: {
5078
+ oneOf: [{ type: "null" }, {
5079
+ type: "object",
5080
+ additionalProperties: false,
5081
+ properties: {
5082
+ id: {
5083
+ type: "string",
5084
+ required: true
5085
+ },
5086
+ rationale: {
5087
+ type: "string",
5088
+ required: true
3520
5089
  }
3521
5090
  }
5091
+ }],
5092
+ required: true
5093
+ },
5094
+ examGuide: {
5095
+ type: "object",
5096
+ description: "Exam nodes only: question quota, time-limit rule, star thresholds.",
5097
+ additionalProperties: false,
5098
+ properties: {
5099
+ questionCount: {
5100
+ type: "integer",
5101
+ required: true
5102
+ },
5103
+ kcCount: {
5104
+ type: "integer",
5105
+ required: true
5106
+ },
5107
+ timeLimitRule: {
5108
+ type: "string",
5109
+ required: true
5110
+ },
5111
+ starsRule: {
5112
+ type: "string",
5113
+ required: true
5114
+ },
5115
+ bestStars: {
5116
+ type: "integer",
5117
+ required: true
5118
+ },
5119
+ examAttempts: {
5120
+ type: "integer",
5121
+ required: true
5122
+ }
3522
5123
  }
5124
+ },
5125
+ nextLessonId: {
5126
+ ...nullableString,
5127
+ required: true
5128
+ },
5129
+ learnerState: {
5130
+ type: "string",
5131
+ required: true,
5132
+ description: "Composed learner snapshot (status/mastery/weak/friction/memory)."
5133
+ },
5134
+ summary: {
5135
+ type: "string",
5136
+ description: "1–2 sentence lesson summary (defined with the concepts)."
3523
5137
  }
3524
- },
3525
- render: (_args, value) => textBlocks(mapLines(value))
3526
- },
3527
- async execute(args) {
3528
- return toMapValue(findCourse(store.get(), args.courseId));
5138
+ }
3529
5139
  },
3530
- isConcurrencySafe: () => true,
3531
- presentCall: (args) => ({
3532
- card: "generic",
3533
- title: `Course map: ${args.courseId}`,
3534
- kind: "read"
3535
- }),
3536
- presentationMeta: (_args, value) => mapLines(value),
3537
- presentResult: (_args, result) => ({
3538
- card: "generic",
3539
- content: textBlocks(result.meta)
3540
- })
3541
- }),
3542
- defineTool({
3543
- name: "study_lesson",
3544
- description: "Open one lesson and make it the focus: returns its markdown content (the source of truth to teach from), teaching strategy band, knowledge concepts with mastery/weak flags, four consolidation starters, memory slots, and any pending mastery proposal.",
3545
- parameters: { lessonId: {
5140
+ render: (_args, value) => [{
5141
+ type: "text",
5142
+ 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})`}`
5143
+ }]
5144
+ },
5145
+ async execute(args) {
5146
+ return mutate((state) => {
5147
+ const { ref } = attemptLesson(state, args.lessonId, /* @__PURE__ */ new Date());
5148
+ state.focus = { lessonId: ref.lesson.id };
5149
+ return toLessonValue(ref, state);
5150
+ });
5151
+ },
5152
+ presentCall: (args) => ({
5153
+ card: "generic",
5154
+ title: `Open lesson: ${args.lessonId}`,
5155
+ kind: "read"
5156
+ })
5157
+ });
5158
+ const recordAnswerTool = defineTool({
5159
+ name: "study_record_answer",
5160
+ description: "Record one graded answer and update mastery — call after EVERY learner answer to a scored question. Name the `concept` the question tested (from study_lesson / study_define_concepts) so per-concept mastery stays accurate; lesson mastery is the WEAKEST concept. Mastery ≥50% unlocks the next lesson early; ≥90% graduates automatically and schedules the first review. Also pass the question text and the learner's answer to keep a practice log.",
5161
+ parameters: {
5162
+ lessonId: {
3546
5163
  type: "string",
3547
5164
  required: true,
3548
- description: "Lesson id from a map, import, or courses call."
3549
- } },
3550
- output: {
3551
- schema: {
3552
- type: "object",
3553
- additionalProperties: false,
3554
- properties: {
3555
- lessonId: {
3556
- type: "string",
3557
- required: true
3558
- },
3559
- courseId: {
3560
- type: "string",
3561
- required: true
3562
- },
3563
- courseTitle: {
3564
- type: "string",
3565
- required: true
3566
- },
3567
- sectionTitle: {
3568
- type: "string",
3569
- required: true
3570
- },
3571
- title: {
3572
- type: "string",
3573
- required: true
3574
- },
3575
- kind: {
3576
- type: "string",
3577
- required: true,
3578
- enum: [...LESSON_KINDS]
3579
- },
3580
- status: {
3581
- type: "string",
3582
- required: true,
3583
- enum: [...LESSON_STATUSES]
3584
- },
3585
- body: {
3586
- type: "string",
3587
- required: true
3588
- },
3589
- masteryPct: {
3590
- ...nullableInteger,
3591
- required: true
3592
- },
3593
- crown: {
3594
- type: "integer",
3595
- required: true
3596
- },
3597
- attempts: {
3598
- type: "integer",
3599
- required: true
3600
- },
3601
- correctCount: {
3602
- type: "integer",
3603
- required: true
3604
- },
3605
- strategy: {
3606
- type: "string",
3607
- required: true
3608
- },
3609
- concepts: {
3610
- oneOf: [{ type: "null" }, {
3611
- type: "array",
3612
- items: {
3613
- type: "object",
3614
- additionalProperties: false,
3615
- properties: {
3616
- title: {
3617
- type: "string",
3618
- required: true
3619
- },
3620
- masteryPct: {
3621
- type: "integer",
3622
- required: true
3623
- },
3624
- weak: {
3625
- type: "boolean",
3626
- required: true
3627
- },
3628
- /** 1 once this concept has been quizzed at least once, else 0 (ConceptView.tested). */
3629
- tested: {
3630
- type: "integer",
3631
- required: true
3632
- }
3633
- }
3634
- }
3635
- }],
3636
- required: true
3637
- },
3638
- starters: {
3639
- type: "array",
3640
- required: true,
3641
- items: {
3642
- type: "object",
3643
- additionalProperties: false,
3644
- properties: {
3645
- label: {
3646
- type: "string",
3647
- required: true
3648
- },
3649
- message: {
3650
- type: "string",
3651
- required: true
3652
- },
3653
- effect: {
3654
- type: "string",
3655
- required: true,
3656
- enum: [
3657
- "mastery",
3658
- "friction",
3659
- "none"
3660
- ]
3661
- }
3662
- }
3663
- }
3664
- },
3665
- memory: {
5165
+ description: "Lesson the question tested."
5166
+ },
5167
+ correct: {
5168
+ type: "boolean",
5169
+ required: true,
5170
+ description: "Whether the learner answered correctly."
5171
+ },
5172
+ concept: {
5173
+ type: "string",
5174
+ description: "Concept title the question tested (required once concepts are defined)."
5175
+ },
5176
+ rationale: {
5177
+ type: "string",
5178
+ description: "One line: why you graded it this way."
5179
+ },
5180
+ question: {
5181
+ type: "string",
5182
+ description: "The question text, for the practice log."
5183
+ },
5184
+ givenAnswer: {
5185
+ type: "string",
5186
+ description: "The learner's answer, for the practice log."
5187
+ }
5188
+ },
5189
+ output: {
5190
+ schema: {
5191
+ type: "object",
5192
+ additionalProperties: false,
5193
+ properties: {
5194
+ lessonId: {
5195
+ type: "string",
5196
+ required: true
5197
+ },
5198
+ lessonTitle: {
5199
+ type: "string",
5200
+ required: true
5201
+ },
5202
+ correct: {
5203
+ type: "boolean",
5204
+ required: true
5205
+ },
5206
+ concept: {
5207
+ oneOf: [{ type: "null" }, {
3666
5208
  type: "object",
3667
- required: true,
3668
5209
  additionalProperties: false,
3669
5210
  properties: {
3670
- lesson: {
3671
- ...nullableString,
5211
+ title: {
5212
+ type: "string",
3672
5213
  required: true
3673
5214
  },
3674
- global: {
3675
- ...nullableString,
5215
+ masteryPct: {
5216
+ type: "integer",
3676
5217
  required: true
3677
5218
  },
3678
- pattern: {
3679
- ...nullableString,
5219
+ weak: {
5220
+ type: "boolean",
3680
5221
  required: true
3681
5222
  }
3682
5223
  }
3683
- },
3684
- noteCount: {
3685
- type: "integer",
3686
- required: true
3687
- },
3688
- pendingProposal: {
3689
- oneOf: [{ type: "null" }, {
3690
- type: "object",
3691
- additionalProperties: false,
3692
- properties: {
3693
- id: {
3694
- type: "string",
3695
- required: true
3696
- },
3697
- rationale: {
3698
- type: "string",
3699
- required: true
3700
- }
3701
- }
3702
- }],
3703
- required: true
3704
- },
3705
- nextLessonId: {
3706
- ...nullableString,
3707
- required: true
3708
- }
5224
+ }],
5225
+ required: true
5226
+ },
5227
+ prevMasteryPct: {
5228
+ type: "integer",
5229
+ required: true
5230
+ },
5231
+ newMasteryPct: {
5232
+ type: "integer",
5233
+ required: true
5234
+ },
5235
+ crown: {
5236
+ type: "integer",
5237
+ required: true
5238
+ },
5239
+ mastered: {
5240
+ type: "boolean",
5241
+ required: true
5242
+ },
5243
+ attempts: {
5244
+ type: "integer",
5245
+ required: true
5246
+ },
5247
+ correctCount: {
5248
+ type: "integer",
5249
+ required: true
5250
+ },
5251
+ graduated: {
5252
+ type: "boolean",
5253
+ required: true
5254
+ },
5255
+ unlockedLessonIds: {
5256
+ type: "array",
5257
+ required: true,
5258
+ items: { type: "string" }
5259
+ },
5260
+ reviewDueAt: {
5261
+ ...nullableString,
5262
+ required: true
3709
5263
  }
3710
- },
3711
- render: (_args, value) => [{
3712
- type: "text",
3713
- 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`) + `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})`}`
3714
- }]
3715
- },
3716
- async execute(args) {
3717
- return mutate((state) => {
3718
- const { ref } = attemptLesson(state, args.lessonId, /* @__PURE__ */ new Date());
3719
- state.focus = { lessonId: ref.lesson.id };
3720
- return toLessonValue(ref, state);
3721
- });
5264
+ }
3722
5265
  },
3723
- presentCall: (args) => ({
3724
- card: "generic",
3725
- title: `Open lesson: ${args.lessonId}`,
3726
- kind: "read"
3727
- })
3728
- }),
3729
- defineTool({
3730
- name: "study_record_answer",
3731
- description: "Record one graded answer and update mastery — call after EVERY learner answer to a scored question. Name the `concept` the question tested (from study_lesson / study_define_concepts) so per-concept mastery stays accurate; lesson mastery is the WEAKEST concept. Mastery ≥50% unlocks the next lesson early; ≥90% graduates automatically and schedules the first review. Also pass the question text and the learner's answer to keep a practice log.",
3732
- parameters: {
3733
- lessonId: {
3734
- type: "string",
3735
- required: true,
3736
- description: "Lesson the question tested."
3737
- },
3738
- correct: {
3739
- type: "boolean",
3740
- required: true,
3741
- description: "Whether the learner answered correctly."
3742
- },
3743
- concept: {
3744
- type: "string",
3745
- description: "Concept title the question tested (required once concepts are defined)."
3746
- },
3747
- rationale: {
3748
- type: "string",
3749
- description: "One line: why you graded it this way."
3750
- },
3751
- question: {
3752
- type: "string",
3753
- description: "The question text, for the practice log."
3754
- },
3755
- givenAnswer: {
3756
- type: "string",
3757
- description: "The learner's answer, for the practice log."
5266
+ render: (_args, value) => [{
5267
+ type: "text",
5268
+ text: answerLine(value) + (value.concept === null ? "" : `\nconcept: ${value.concept.title} ${value.concept.masteryPct}%${value.concept.weak ? " ⚡weak" : ""}`) + (value.graduated ? "\n🎓 mastery ≥90% — lesson graduated, first review scheduled." : "") + (value.unlockedLessonIds.length === 0 ? "" : `\n🔓 unlocked: ${value.unlockedLessonIds.join(", ")}`)
5269
+ }]
5270
+ },
5271
+ async execute(args) {
5272
+ return mutate((state) => {
5273
+ const r = recordAnswer(state, args.lessonId, args.correct, args.concept, /* @__PURE__ */ new Date());
5274
+ if (args.question !== void 0) addNote(state, args.lessonId, "practice", args.question.slice(0, 80), `${args.question}\n\nlearner answered: ${args.givenAnswer ?? "(not recorded)"} ${args.correct ? "✓ correct" : "✗ incorrect"}${args.rationale === void 0 ? "" : `\nrationale: ${args.rationale}`}`, "ai", null, /* @__PURE__ */ new Date());
5275
+ return {
5276
+ lessonId: r.ref.lesson.id,
5277
+ lessonTitle: r.ref.lesson.title,
5278
+ correct: args.correct,
5279
+ concept: r.concept === null ? null : {
5280
+ title: r.concept.title,
5281
+ masteryPct: Math.round(r.concept.mastery * 100),
5282
+ weak: r.concept.mastery < .7
5283
+ },
5284
+ prevMasteryPct: Math.round(r.prevMastery * 100),
5285
+ newMasteryPct: Math.round(r.newMastery * 100),
5286
+ crown: r.crown,
5287
+ mastered: r.mastered,
5288
+ attempts: r.ref.lesson.attempts,
5289
+ correctCount: r.ref.lesson.correctCount,
5290
+ graduated: r.progression.graduated,
5291
+ unlockedLessonIds: r.progression.unlocked.map((u) => u.id),
5292
+ reviewDueAt: r.progression.nextDue
5293
+ };
5294
+ });
5295
+ },
5296
+ presentCall: (args) => ({
5297
+ card: "generic",
5298
+ title: `Record answer (${args.correct ? "correct" : "incorrect"}): ${args.lessonId}`
5299
+ }),
5300
+ presentationMeta: (_args, value) => [answerLine(value)],
5301
+ presentResult: (_args, result) => ({
5302
+ card: "generic",
5303
+ content: textBlocks(result.meta)
5304
+ })
5305
+ });
5306
+ const examResultTool = defineTool({
5307
+ name: "study_exam_result",
5308
+ description: "Record one graded section-exam attempt: stars from accuracy (≥95%→3★, ≥80%→2★, ≥60%→1★, below→0; best-of retained across attempts) plus the post-quiz action set the learner should be offered next (explain-wrong / retry / go-deeper / mark-mastered→study_propose_mastery / next-topic). Call once per exam attempt after grading all questions.",
5309
+ parameters: {
5310
+ lessonId: {
5311
+ type: "string",
5312
+ required: true,
5313
+ description: "The exam lesson node id."
5314
+ },
5315
+ correct: {
5316
+ type: "integer",
5317
+ required: true,
5318
+ description: "Questions answered correctly."
5319
+ },
5320
+ total: {
5321
+ type: "integer",
5322
+ required: true,
5323
+ description: "Questions asked in this attempt (must be > 0)."
5324
+ }
5325
+ },
5326
+ output: {
5327
+ schema: {
5328
+ type: "object",
5329
+ additionalProperties: false,
5330
+ properties: {
5331
+ lessonId: {
5332
+ type: "string",
5333
+ required: true
5334
+ },
5335
+ lessonTitle: {
5336
+ type: "string",
5337
+ required: true
5338
+ },
5339
+ stars: {
5340
+ type: "integer",
5341
+ required: true
5342
+ },
5343
+ bestStars: {
5344
+ type: "integer",
5345
+ required: true
5346
+ },
5347
+ attempts: {
5348
+ type: "integer",
5349
+ required: true
5350
+ },
5351
+ masteryPct: {
5352
+ type: "integer",
5353
+ required: true
5354
+ },
5355
+ actions: {
5356
+ type: "array",
5357
+ required: true,
5358
+ items: {
5359
+ type: "object",
5360
+ additionalProperties: false,
5361
+ properties: {
5362
+ id: {
5363
+ type: "string",
5364
+ enum: [
5365
+ "explain-wrong",
5366
+ "retry",
5367
+ "go-deeper",
5368
+ "mark-mastered",
5369
+ "next-topic"
5370
+ ],
5371
+ required: true
5372
+ },
5373
+ label: {
5374
+ type: "string",
5375
+ required: true
5376
+ },
5377
+ advancesMastery: { type: "boolean" }
5378
+ }
5379
+ }
5380
+ },
5381
+ nextLessonId: {
5382
+ ...nullableString,
5383
+ required: true
5384
+ }
3758
5385
  }
3759
5386
  },
3760
- output: {
3761
- schema: {
3762
- type: "object",
3763
- additionalProperties: false,
3764
- properties: {
3765
- lessonId: {
3766
- type: "string",
3767
- required: true
3768
- },
3769
- lessonTitle: {
3770
- type: "string",
3771
- required: true
3772
- },
3773
- correct: {
3774
- type: "boolean",
3775
- required: true
3776
- },
3777
- concept: {
3778
- oneOf: [{ type: "null" }, {
3779
- type: "object",
3780
- additionalProperties: false,
3781
- properties: {
3782
- title: {
3783
- type: "string",
3784
- required: true
3785
- },
3786
- masteryPct: {
3787
- type: "integer",
3788
- required: true
3789
- },
3790
- weak: {
3791
- type: "boolean",
3792
- required: true
3793
- }
5387
+ render: (_args, value) => [{
5388
+ type: "text",
5389
+ 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}.`)
5390
+ }]
5391
+ },
5392
+ execute(args) {
5393
+ return mutate((state) => {
5394
+ const r = recordExamResult(state, args.lessonId, args.correct, args.total);
5395
+ const actions = getPostQuizActions({
5396
+ correct: args.correct,
5397
+ total: args.total
5398
+ }, r.ref.lesson.mastery);
5399
+ const flat = r.ref.course.sections.flatMap((s) => s.lessons);
5400
+ const idx = flat.findIndex((l) => l.id === args.lessonId);
5401
+ const next = flat.slice(idx + 1).find((l) => l.kind !== "exam" && l.status !== "locked") ?? null;
5402
+ const labels = {
5403
+ "explain-wrong": "讲解错题",
5404
+ retry: "再来一组",
5405
+ "go-deeper": "深入这个主题",
5406
+ "mark-mastered": "标记掌握 (study_propose_mastery)",
5407
+ "next-topic": next === null ? "下一课" : `下一课 (${next.title})`
5408
+ };
5409
+ return {
5410
+ lessonId: r.ref.lesson.id,
5411
+ lessonTitle: r.ref.lesson.title,
5412
+ stars: r.stars,
5413
+ bestStars: r.bestStars,
5414
+ attempts: r.attempts,
5415
+ masteryPct: Math.round((r.ref.lesson.mastery ?? 0) * 100),
5416
+ actions: actions.map((a) => ({
5417
+ id: a.id,
5418
+ label: labels[a.id] ?? a.id,
5419
+ ...a.advancesMastery ? { advancesMastery: true } : {}
5420
+ })),
5421
+ nextLessonId: next === null ? null : next.id
5422
+ };
5423
+ });
5424
+ },
5425
+ presentCall: (args) => ({
5426
+ card: "generic",
5427
+ title: `Exam result: ${args.correct}/${args.total}`
5428
+ }),
5429
+ presentationMeta: (_args, value) => [`${value.stars}★`],
5430
+ presentResult: (_args, result) => ({
5431
+ card: "generic",
5432
+ content: textBlocks(result.meta)
5433
+ })
5434
+ });
5435
+ const completeLessonTool = defineTool({
5436
+ name: "study_complete_lesson",
5437
+ description: "Mark a lesson mastered manually (graduation at 90% mastery is the automatic path — this is the override). Unlocks the next lesson and schedules the first spaced review for tomorrow. Call only when the learner has genuinely worked through the lesson.",
5438
+ parameters: { lessonId: {
5439
+ type: "string",
5440
+ required: true,
5441
+ description: "Lesson to complete."
5442
+ } },
5443
+ output: {
5444
+ schema: {
5445
+ type: "object",
5446
+ additionalProperties: false,
5447
+ properties: {
5448
+ lessonId: {
5449
+ type: "string",
5450
+ required: true
5451
+ },
5452
+ lessonTitle: {
5453
+ type: "string",
5454
+ required: true
5455
+ },
5456
+ unlockedLessonIds: {
5457
+ type: "array",
5458
+ required: true,
5459
+ items: { type: "string" }
5460
+ },
5461
+ unlockedLessonTitles: {
5462
+ type: "array",
5463
+ required: true,
5464
+ items: { type: "string" }
5465
+ },
5466
+ reviewDueAt: {
5467
+ type: "string",
5468
+ required: true
5469
+ },
5470
+ courseComplete: {
5471
+ type: "boolean",
5472
+ required: true
5473
+ }
5474
+ }
5475
+ },
5476
+ render: (_args, value) => [{
5477
+ type: "text",
5478
+ text: completeLines(value).join("\n")
5479
+ }]
5480
+ },
5481
+ async execute(args) {
5482
+ return mutate((state) => {
5483
+ const r = completeLesson(state, args.lessonId, /* @__PURE__ */ new Date());
5484
+ return {
5485
+ lessonId: r.ref.lesson.id,
5486
+ lessonTitle: r.ref.lesson.title,
5487
+ unlockedLessonIds: r.unlocked.map((u) => u.id),
5488
+ unlockedLessonTitles: r.unlocked.map((u) => u.title),
5489
+ reviewDueAt: r.dueAt,
5490
+ courseComplete: r.courseComplete
5491
+ };
5492
+ });
5493
+ },
5494
+ presentCall: (args) => ({
5495
+ card: "generic",
5496
+ title: `Complete lesson: ${args.lessonId}`
5497
+ }),
5498
+ presentationMeta: (_args, value) => completeLines(value),
5499
+ presentResult: (_args, result) => ({
5500
+ card: "generic",
5501
+ content: textBlocks(result.meta)
5502
+ })
5503
+ });
5504
+ const dueReviewsTool = defineTool({
5505
+ name: "study_due_reviews",
5506
+ description: "List mastered lessons whose spaced-repetition review is due (optionally within one course), oldest first. Start every session here.",
5507
+ parameters: { courseId: {
5508
+ type: "string",
5509
+ description: "Restrict to one course; omit to scan all courses."
5510
+ } },
5511
+ output: {
5512
+ schema: {
5513
+ type: "object",
5514
+ additionalProperties: false,
5515
+ properties: {
5516
+ total: {
5517
+ type: "integer",
5518
+ required: true
5519
+ },
5520
+ due: {
5521
+ type: "array",
5522
+ required: true,
5523
+ items: {
5524
+ type: "object",
5525
+ additionalProperties: false,
5526
+ properties: {
5527
+ lessonId: {
5528
+ type: "string",
5529
+ required: true
5530
+ },
5531
+ courseTitle: {
5532
+ type: "string",
5533
+ required: true
5534
+ },
5535
+ lessonTitle: {
5536
+ type: "string",
5537
+ required: true
5538
+ },
5539
+ dueAt: {
5540
+ type: "string",
5541
+ required: true
5542
+ },
5543
+ overdueDays: {
5544
+ type: "integer",
5545
+ required: true
3794
5546
  }
3795
- }],
3796
- required: true
3797
- },
3798
- prevMasteryPct: {
3799
- type: "integer",
3800
- required: true
3801
- },
3802
- newMasteryPct: {
3803
- type: "integer",
3804
- required: true
3805
- },
3806
- crown: {
3807
- type: "integer",
3808
- required: true
3809
- },
3810
- mastered: {
3811
- type: "boolean",
3812
- required: true
3813
- },
3814
- attempts: {
3815
- type: "integer",
3816
- required: true
3817
- },
3818
- correctCount: {
3819
- type: "integer",
3820
- required: true
3821
- },
3822
- graduated: {
3823
- type: "boolean",
3824
- required: true
3825
- },
3826
- unlockedLessonIds: {
3827
- type: "array",
3828
- required: true,
3829
- items: { type: "string" }
3830
- },
3831
- reviewDueAt: {
3832
- ...nullableString,
3833
- required: true
5547
+ }
3834
5548
  }
3835
5549
  }
3836
- },
3837
- render: (_args, value) => [{
3838
- type: "text",
3839
- text: answerLine(value) + (value.concept === null ? "" : `\nconcept: ${value.concept.title} ${value.concept.masteryPct}%${value.concept.weak ? " ⚡weak" : ""}`) + (value.graduated ? "\n🎓 mastery ≥90% — lesson graduated, first review scheduled." : "") + (value.unlockedLessonIds.length === 0 ? "" : `\n🔓 unlocked: ${value.unlockedLessonIds.join(", ")}`)
3840
- }]
5550
+ }
3841
5551
  },
3842
- async execute(args) {
3843
- return mutate((state) => {
3844
- const r = recordAnswer(state, args.lessonId, args.correct, args.concept, /* @__PURE__ */ new Date());
3845
- if (args.question !== void 0) addNote(state, args.lessonId, "practice", args.question.slice(0, 80), `${args.question}\n\nlearner answered: ${args.givenAnswer ?? "(not recorded)"} — ${args.correct ? "✓ correct" : "✗ incorrect"}${args.rationale === void 0 ? "" : `\nrationale: ${args.rationale}`}`, "ai", null, /* @__PURE__ */ new Date());
3846
- return {
3847
- lessonId: r.ref.lesson.id,
3848
- lessonTitle: r.ref.lesson.title,
3849
- correct: args.correct,
3850
- concept: r.concept === null ? null : {
3851
- title: r.concept.title,
3852
- masteryPct: Math.round(r.concept.mastery * 100),
3853
- weak: r.concept.mastery < .7
3854
- },
3855
- prevMasteryPct: Math.round(r.prevMastery * 100),
3856
- newMasteryPct: Math.round(r.newMastery * 100),
3857
- crown: r.crown,
3858
- mastered: r.mastered,
3859
- attempts: r.ref.lesson.attempts,
3860
- correctCount: r.ref.lesson.correctCount,
3861
- graduated: r.progression.graduated,
3862
- unlockedLessonIds: r.progression.unlocked.map((u) => u.id),
3863
- reviewDueAt: r.progression.nextDue
3864
- };
3865
- });
5552
+ render: (_args, value) => textBlocks(dueLines(value))
5553
+ },
5554
+ async execute(args) {
5555
+ const due = dueReviews(store.get(), args.courseId, /* @__PURE__ */ new Date());
5556
+ return {
5557
+ total: due.length,
5558
+ due: due.map((d) => ({
5559
+ lessonId: d.lessonId,
5560
+ courseTitle: d.courseTitle,
5561
+ lessonTitle: d.lessonTitle,
5562
+ dueAt: d.dueAt,
5563
+ overdueDays: d.overdueDays
5564
+ }))
5565
+ };
5566
+ },
5567
+ isConcurrencySafe: () => true,
5568
+ presentCall: () => ({
5569
+ card: "generic",
5570
+ title: "List due reviews",
5571
+ kind: "search"
5572
+ }),
5573
+ presentationMeta: (_args, value) => dueLines(value),
5574
+ presentResult: (_args, result) => ({
5575
+ card: "generic",
5576
+ content: textBlocks(result.meta)
5577
+ })
5578
+ });
5579
+ const recordReviewTool = defineTool({
5580
+ name: "study_record_review",
5581
+ description: "Record an SM-2 review grade for a mastered lesson and advance its schedule. Grade how well the learner recalled the material: 5 perfect, 4 hesitant, 3 recalled with effort, 2 incorrect but recognized, 1 incorrect, 0 complete blackout. Target weak concepts (⚡) first.",
5582
+ parameters: {
5583
+ lessonId: {
5584
+ type: "string",
5585
+ required: true,
5586
+ description: "Lesson being reviewed."
3866
5587
  },
3867
- presentCall: (args) => ({
3868
- card: "generic",
3869
- title: `Record answer (${args.correct ? "correct" : "incorrect"}): ${args.lessonId}`
3870
- }),
3871
- presentationMeta: (_args, value) => [answerLine(value)],
3872
- presentResult: (_args, result) => ({
3873
- card: "generic",
3874
- content: textBlocks(result.meta)
3875
- })
5588
+ quality: {
5589
+ type: "integer",
5590
+ required: true,
5591
+ enum: [...QUALITIES],
5592
+ description: "SM-2 recall quality, 0 (blackout) to 5 (perfect)."
5593
+ }
5594
+ },
5595
+ output: {
5596
+ schema: {
5597
+ type: "object",
5598
+ additionalProperties: false,
5599
+ properties: {
5600
+ lessonId: {
5601
+ type: "string",
5602
+ required: true
5603
+ },
5604
+ lessonTitle: {
5605
+ type: "string",
5606
+ required: true
5607
+ },
5608
+ quality: {
5609
+ type: "integer",
5610
+ required: true
5611
+ },
5612
+ intervalDays: {
5613
+ type: "integer",
5614
+ required: true
5615
+ },
5616
+ repetitions: {
5617
+ type: "integer",
5618
+ required: true
5619
+ },
5620
+ easeFactor: {
5621
+ type: "number",
5622
+ required: true
5623
+ },
5624
+ dueAt: {
5625
+ type: "string",
5626
+ required: true
5627
+ }
5628
+ }
5629
+ },
5630
+ render: (_args, value) => [{
5631
+ type: "text",
5632
+ text: reviewLine(value)
5633
+ }]
5634
+ },
5635
+ async execute(args) {
5636
+ return mutate((state) => {
5637
+ const r = recordReview(state, args.lessonId, args.quality, /* @__PURE__ */ new Date());
5638
+ return {
5639
+ lessonId: r.ref.lesson.id,
5640
+ lessonTitle: r.ref.lesson.title,
5641
+ quality: args.quality,
5642
+ intervalDays: r.intervalDays,
5643
+ repetitions: r.repetitions,
5644
+ easeFactor: r.easeFactor,
5645
+ dueAt: r.dueAt
5646
+ };
5647
+ });
5648
+ },
5649
+ presentCall: (args) => ({
5650
+ card: "generic",
5651
+ title: `Record review (quality ${args.quality}): ${args.lessonId}`
3876
5652
  }),
3877
- defineTool({
3878
- name: "study_complete_lesson",
3879
- description: "Mark a lesson mastered manually (graduation at 90% mastery is the automatic path — this is the override). Unlocks the next lesson and schedules the first spaced review for tomorrow. Call only when the learner has genuinely worked through the lesson.",
3880
- parameters: { lessonId: {
5653
+ presentationMeta: (_args, value) => [reviewLine(value)],
5654
+ presentResult: (_args, result) => ({
5655
+ card: "generic",
5656
+ content: textBlocks(result.meta)
5657
+ })
5658
+ });
5659
+ const deleteCourseTool = defineTool({
5660
+ name: "study_delete_course",
5661
+ description: "Delete one course and all its progress. Ask the learner before calling.",
5662
+ parameters: { courseId: {
5663
+ type: "string",
5664
+ required: true,
5665
+ description: "Course to delete."
5666
+ } },
5667
+ output: {
5668
+ schema: {
5669
+ type: "object",
5670
+ additionalProperties: false,
5671
+ properties: {
5672
+ deletedCourseId: {
5673
+ type: "string",
5674
+ required: true
5675
+ },
5676
+ remaining: {
5677
+ type: "integer",
5678
+ required: true
5679
+ }
5680
+ }
5681
+ },
5682
+ render: (_args, value) => [{
5683
+ type: "text",
5684
+ text: `Deleted course ${value.deletedCourseId}. ${value.remaining} courses remain.`
5685
+ }]
5686
+ },
5687
+ async execute(args) {
5688
+ return mutate((state) => {
5689
+ findCourse(state, args.courseId);
5690
+ deleteCourse(state, args.courseId);
5691
+ return {
5692
+ deletedCourseId: args.courseId,
5693
+ remaining: state.courses.length
5694
+ };
5695
+ });
5696
+ },
5697
+ presentCall: (args) => ({
5698
+ card: "generic",
5699
+ title: `Delete course: ${args.courseId}`,
5700
+ kind: "delete",
5701
+ rawInput: args.courseId
5702
+ })
5703
+ });
5704
+ const defineConceptsTool = defineTool({
5705
+ name: "study_define_concepts",
5706
+ 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.",
5707
+ parameters: {
5708
+ lessonId: {
3881
5709
  type: "string",
3882
5710
  required: true,
3883
- description: "Lesson to complete."
3884
- } },
3885
- output: {
3886
- schema: {
5711
+ description: "Lesson to describe."
5712
+ },
5713
+ concepts: {
5714
+ type: "array",
5715
+ required: true,
5716
+ description: "2–7 concepts.",
5717
+ items: {
3887
5718
  type: "object",
3888
5719
  additionalProperties: false,
3889
5720
  properties: {
3890
- lessonId: {
3891
- type: "string",
3892
- required: true
3893
- },
3894
- lessonTitle: {
5721
+ title: {
3895
5722
  type: "string",
3896
- required: true
3897
- },
3898
- unlockedLessonIds: {
3899
- type: "array",
3900
- required: true,
3901
- items: { type: "string" }
3902
- },
3903
- unlockedLessonTitles: {
3904
- type: "array",
3905
5723
  required: true,
3906
- items: { type: "string" }
5724
+ description: "Short concept title."
3907
5725
  },
3908
- reviewDueAt: {
5726
+ description: {
3909
5727
  type: "string",
3910
- required: true
3911
- },
3912
- courseComplete: {
3913
- type: "boolean",
3914
- required: true
5728
+ required: true,
5729
+ description: "One line: what understanding this concept means."
3915
5730
  }
3916
5731
  }
3917
- },
3918
- render: (_args, value) => [{
3919
- type: "text",
3920
- text: completeLines(value).join("\n")
3921
- }]
3922
- },
3923
- async execute(args) {
3924
- return mutate((state) => {
3925
- const r = completeLesson(state, args.lessonId, /* @__PURE__ */ new Date());
3926
- return {
3927
- lessonId: r.ref.lesson.id,
3928
- lessonTitle: r.ref.lesson.title,
3929
- unlockedLessonIds: r.unlocked.map((u) => u.id),
3930
- unlockedLessonTitles: r.unlocked.map((u) => u.title),
3931
- reviewDueAt: r.dueAt,
3932
- courseComplete: r.courseComplete
3933
- };
3934
- });
5732
+ }
3935
5733
  },
3936
- presentCall: (args) => ({
3937
- card: "generic",
3938
- title: `Complete lesson: ${args.lessonId}`
3939
- }),
3940
- presentationMeta: (_args, value) => completeLines(value),
3941
- presentResult: (_args, result) => ({
3942
- card: "generic",
3943
- content: textBlocks(result.meta)
3944
- })
3945
- }),
3946
- defineTool({
3947
- name: "study_due_reviews",
3948
- description: "List mastered lessons whose spaced-repetition review is due (optionally within one course), oldest first. Start every session here.",
3949
- parameters: { courseId: {
5734
+ summary: {
3950
5735
  type: "string",
3951
- description: "Restrict to one course; omit to scan all courses."
3952
- } },
3953
- output: {
3954
- schema: {
3955
- type: "object",
3956
- additionalProperties: false,
3957
- properties: {
3958
- total: {
3959
- type: "integer",
3960
- required: true
3961
- },
3962
- due: {
3963
- type: "array",
3964
- required: true,
3965
- items: {
3966
- type: "object",
3967
- additionalProperties: false,
3968
- properties: {
3969
- lessonId: {
3970
- type: "string",
3971
- required: true
3972
- },
3973
- courseTitle: {
3974
- type: "string",
3975
- required: true
3976
- },
3977
- lessonTitle: {
3978
- type: "string",
3979
- required: true
3980
- },
3981
- dueAt: {
3982
- type: "string",
3983
- required: true
3984
- },
3985
- overdueDays: {
3986
- type: "integer",
3987
- required: true
3988
- }
5736
+ description: "Optional 1–2 sentence lesson summary (upstream lesson-summary-kc: generated once alongside the concepts)."
5737
+ }
5738
+ },
5739
+ output: {
5740
+ schema: {
5741
+ type: "object",
5742
+ additionalProperties: false,
5743
+ properties: {
5744
+ lessonId: {
5745
+ type: "string",
5746
+ required: true
5747
+ },
5748
+ concepts: {
5749
+ type: "array",
5750
+ required: true,
5751
+ items: {
5752
+ type: "object",
5753
+ additionalProperties: false,
5754
+ properties: {
5755
+ title: {
5756
+ type: "string",
5757
+ required: true
5758
+ },
5759
+ masteryPct: {
5760
+ type: "integer",
5761
+ required: true
3989
5762
  }
3990
5763
  }
3991
5764
  }
3992
5765
  }
3993
- },
3994
- render: (_args, value) => textBlocks(dueLines(value))
5766
+ }
3995
5767
  },
3996
- async execute(args) {
3997
- const due = dueReviews(store.get(), args.courseId, /* @__PURE__ */ new Date());
5768
+ render: (_args, value) => [{
5769
+ type: "text",
5770
+ text: `Concepts defined: ${value.concepts.map((c) => `${c.title} (${c.masteryPct}%)`).join(" · ")}. Attribute quiz answers with the \`concept\` parameter.`
5771
+ }]
5772
+ },
5773
+ async execute(args) {
5774
+ return mutate((state) => {
5775
+ defineConcepts(state, args.lessonId, args.concepts, args.summary);
5776
+ const ref = findLesson(state, args.lessonId);
3998
5777
  return {
3999
- total: due.length,
4000
- due: due.map((d) => ({
4001
- lessonId: d.lessonId,
4002
- courseTitle: d.courseTitle,
4003
- lessonTitle: d.lessonTitle,
4004
- dueAt: d.dueAt,
4005
- overdueDays: d.overdueDays
5778
+ lessonId: ref.lesson.id,
5779
+ concepts: (conceptViews(ref.lesson) ?? []).map((c) => ({
5780
+ title: c.title,
5781
+ masteryPct: c.masteryPct
4006
5782
  }))
4007
5783
  };
5784
+ });
5785
+ },
5786
+ presentCall: (args) => ({
5787
+ card: "generic",
5788
+ title: `Define concepts: ${args.lessonId}`
5789
+ })
5790
+ });
5791
+ const proposeMasteryTool = defineTool({
5792
+ name: "study_propose_mastery",
5793
+ description: "Propose graduating a lesson as mastered ahead of the 90% threshold — use when mastery is ≥85% and the learner has convincingly demonstrated understanding (e.g. a Feynman-style explanation back to you). Creates a PENDING proposal: present it with your rationale and WAIT for the learner's decision, then resolve with study_resolve_proposal. Never apply it yourself.",
5794
+ parameters: {
5795
+ lessonId: {
5796
+ type: "string",
5797
+ required: true,
5798
+ description: "Lesson judged mastered."
4008
5799
  },
4009
- isConcurrencySafe: () => true,
4010
- presentCall: () => ({
4011
- card: "generic",
4012
- title: "List due reviews",
4013
- kind: "search"
4014
- }),
4015
- presentationMeta: (_args, value) => dueLines(value),
4016
- presentResult: (_args, result) => ({
4017
- card: "generic",
4018
- content: textBlocks(result.meta)
4019
- })
4020
- }),
4021
- defineTool({
4022
- name: "study_record_review",
4023
- description: "Record an SM-2 review grade for a mastered lesson and advance its schedule. Grade how well the learner recalled the material: 5 perfect, 4 hesitant, 3 recalled with effort, 2 incorrect but recognized, 1 incorrect, 0 complete blackout. Target weak concepts (⚡) first.",
4024
- parameters: {
4025
- lessonId: {
4026
- type: "string",
4027
- required: true,
4028
- description: "Lesson being reviewed."
4029
- },
4030
- quality: {
4031
- type: "integer",
4032
- required: true,
4033
- enum: [...QUALITIES],
4034
- description: "SM-2 recall quality, 0 (blackout) to 5 (perfect)."
5800
+ rationale: {
5801
+ type: "string",
5802
+ required: true,
5803
+ description: "Why you believe it is mastered — the learner reads this."
5804
+ }
5805
+ },
5806
+ output: {
5807
+ schema: {
5808
+ type: "object",
5809
+ additionalProperties: false,
5810
+ properties: {
5811
+ proposalId: {
5812
+ type: "string",
5813
+ required: true
5814
+ },
5815
+ lessonTitle: {
5816
+ type: "string",
5817
+ required: true
5818
+ },
5819
+ status: {
5820
+ type: "string",
5821
+ required: true,
5822
+ enum: [
5823
+ "pending",
5824
+ "applied",
5825
+ "rejected"
5826
+ ]
5827
+ },
5828
+ rationale: {
5829
+ type: "string",
5830
+ required: true
5831
+ }
4035
5832
  }
4036
5833
  },
4037
- output: {
4038
- schema: {
4039
- type: "object",
4040
- additionalProperties: false,
4041
- properties: {
4042
- lessonId: {
4043
- type: "string",
4044
- required: true
4045
- },
4046
- lessonTitle: {
4047
- type: "string",
4048
- required: true
4049
- },
4050
- quality: {
4051
- type: "integer",
4052
- required: true
4053
- },
4054
- intervalDays: {
4055
- type: "integer",
4056
- required: true
4057
- },
4058
- repetitions: {
4059
- type: "integer",
4060
- required: true
4061
- },
4062
- easeFactor: {
4063
- type: "number",
4064
- required: true
4065
- },
4066
- dueAt: {
4067
- type: "string",
4068
- required: true
4069
- }
4070
- }
4071
- },
4072
- render: (_args, value) => [{
4073
- type: "text",
4074
- text: reviewLine(value)
4075
- }]
5834
+ render: (_args, value) => [{
5835
+ type: "text",
5836
+ text: `Proposal ${value.proposalId} (${value.status}): “${value.lessonTitle}” — ${value.rationale}\nPresent this to the learner and wait; resolve via study_resolve_proposal.`
5837
+ }]
5838
+ },
5839
+ async execute(args) {
5840
+ return mutate((state) => {
5841
+ const ref = findLesson(state, args.lessonId);
5842
+ const proposal = proposeMastery(state, args.lessonId, args.rationale, /* @__PURE__ */ new Date());
5843
+ return {
5844
+ proposalId: proposal.id,
5845
+ lessonTitle: ref.lesson.title,
5846
+ status: proposal.status,
5847
+ rationale: proposal.rationale
5848
+ };
5849
+ });
5850
+ },
5851
+ presentCall: (args) => ({
5852
+ card: "generic",
5853
+ title: `Propose mastery: ${args.lessonId}`
5854
+ }),
5855
+ presentationMeta: (_args, value) => ({
5856
+ kind: "study-proposal-created",
5857
+ proposalId: value.proposalId,
5858
+ lessonTitle: value.lessonTitle,
5859
+ rationale: value.rationale
5860
+ }),
5861
+ presentResult: (_args, result) => ({
5862
+ card: "generic",
5863
+ content: textBlocks([`🎓 Proposed mastery for “${result.meta?.lessonTitle ?? "lesson"}”: ${result.meta?.rationale ?? ""}`])
5864
+ })
5865
+ });
5866
+ const resolveProposalTool = defineTool({
5867
+ name: "study_resolve_proposal",
5868
+ description: "Resolve a pending mastery proposal with the learner's explicit decision (they said yes / no in chat). Accepting floors every concept to 95%, graduates the lesson, and unlocks the next one.",
5869
+ parameters: {
5870
+ proposalId: {
5871
+ type: "string",
5872
+ required: true,
5873
+ description: "Proposal id from study_propose_mastery."
4076
5874
  },
4077
- async execute(args) {
4078
- return mutate((state) => {
4079
- const r = recordReview(state, args.lessonId, args.quality, /* @__PURE__ */ new Date());
4080
- return {
4081
- lessonId: r.ref.lesson.id,
4082
- lessonTitle: r.ref.lesson.title,
4083
- quality: args.quality,
4084
- intervalDays: r.intervalDays,
4085
- repetitions: r.repetitions,
4086
- easeFactor: r.easeFactor,
4087
- dueAt: r.dueAt
4088
- };
4089
- });
5875
+ accept: {
5876
+ type: "boolean",
5877
+ required: true,
5878
+ description: "The learner's decision."
5879
+ }
5880
+ },
5881
+ output: {
5882
+ schema: {
5883
+ type: "object",
5884
+ additionalProperties: false,
5885
+ properties: {
5886
+ proposalId: {
5887
+ type: "string",
5888
+ required: true
5889
+ },
5890
+ lessonId: {
5891
+ type: "string",
5892
+ required: true
5893
+ },
5894
+ status: {
5895
+ type: "string",
5896
+ required: true,
5897
+ enum: ["applied", "rejected"]
5898
+ }
5899
+ }
4090
5900
  },
4091
- presentCall: (args) => ({
4092
- card: "generic",
4093
- title: `Record review (quality ${args.quality}): ${args.lessonId}`
4094
- }),
4095
- presentationMeta: (_args, value) => [reviewLine(value)],
4096
- presentResult: (_args, result) => ({
4097
- card: "generic",
4098
- content: textBlocks(result.meta)
4099
- })
5901
+ render: (_args, value) => [{
5902
+ type: "text",
5903
+ text: value.status === "applied" ? `🎓 Proposal applied — lesson ${value.lessonId} mastered (all concepts ≥95%), next lesson unlocked, review scheduled.` : `Proposal rejected — continuing practice on ${value.lessonId}.`
5904
+ }]
5905
+ },
5906
+ async execute(args) {
5907
+ return mutate((state) => {
5908
+ const proposal = resolveProposal(state, args.proposalId, args.accept, /* @__PURE__ */ new Date());
5909
+ return {
5910
+ proposalId: proposal.id,
5911
+ lessonId: proposal.lessonId,
5912
+ status: proposal.status
5913
+ };
5914
+ });
5915
+ },
5916
+ presentCall: (args) => ({
5917
+ card: "generic",
5918
+ title: `Resolve proposal: ${args.proposalId}`
4100
5919
  }),
4101
- defineTool({
4102
- name: "study_delete_course",
4103
- description: "Delete one course and all its progress. Ask the learner before calling.",
4104
- parameters: { courseId: {
5920
+ presentationMeta: (_args, value) => ({
5921
+ kind: "study-proposal-resolved",
5922
+ proposalId: value.proposalId,
5923
+ status: value.status
5924
+ }),
5925
+ presentResult: (_args, result) => ({
5926
+ card: "generic",
5927
+ content: textBlocks([`Proposal ${result.meta?.proposalId ?? "?"} ${result.meta?.status ?? ""}.`])
5928
+ })
5929
+ });
5930
+ const reportFrictionTool = defineTool({
5931
+ name: "study_report_friction",
5932
+ description: "SILENTLY log a learning-friction moment — call when the learner seems confused (糊涂), stuck (卡住), or frustrated (受挫), or when they say \"我没太懂\". One short line. Never mention that you logged it; it feeds the weak-spot map and adapts difficulty.",
5933
+ parameters: {
5934
+ category: {
5935
+ type: "string",
5936
+ required: true,
5937
+ enum: [...FRICTION_CATEGORIES],
5938
+ description: "confused | blocked | frustrated."
5939
+ },
5940
+ summary: {
5941
+ type: "string",
5942
+ description: "One short line: what specifically is hard."
5943
+ },
5944
+ lessonId: {
5945
+ type: "string",
5946
+ description: "Lesson it happened on, when known."
5947
+ }
5948
+ },
5949
+ output: {
5950
+ schema: {
5951
+ type: "object",
5952
+ additionalProperties: false,
5953
+ properties: { logged: {
5954
+ type: "boolean",
5955
+ required: true
5956
+ } }
5957
+ },
5958
+ render: () => [{
5959
+ type: "text",
5960
+ text: "Noted."
5961
+ }]
5962
+ },
5963
+ async execute(args) {
5964
+ return mutate((state) => {
5965
+ addFriction(state, args.lessonId ?? null, args.category, args.summary ?? null, /* @__PURE__ */ new Date());
5966
+ return { logged: true };
5967
+ });
5968
+ },
5969
+ presentCall: () => ({
5970
+ card: "generic",
5971
+ title: "Log friction"
5972
+ })
5973
+ });
5974
+ const rememberTool = defineTool({
5975
+ name: "study_remember",
5976
+ description: "Write a learner-memory slot — call only when you learn something worth keeping across sessions (how they best learn, a recurring pattern, a specific gap). NOT for transient chat. To merge: read the current slot first (study_lesson's memory field), then send the merged 1–3 sentence version — this REPLACES the slot.",
5977
+ parameters: {
5978
+ category: {
5979
+ type: "string",
5980
+ required: true,
5981
+ enum: [...MEMORY_CATEGORIES],
5982
+ description: "global (cross-course style) | pattern (per-course recurring pattern) | lesson (this lesson's specific gap)."
5983
+ },
5984
+ content: {
5985
+ type: "string",
5986
+ required: true,
5987
+ description: "The merged 1–3 sentence slot content."
5988
+ },
5989
+ lessonId: {
5990
+ type: "string",
5991
+ description: "Lesson (for the lesson slot) or any lesson of the course (for the pattern slot)."
5992
+ }
5993
+ },
5994
+ output: {
5995
+ schema: {
5996
+ type: "object",
5997
+ additionalProperties: false,
5998
+ properties: {
5999
+ previous: {
6000
+ ...nullableString,
6001
+ required: true
6002
+ },
6003
+ stored: {
6004
+ type: "string",
6005
+ required: true
6006
+ }
6007
+ }
6008
+ },
6009
+ render: () => [{
6010
+ type: "text",
6011
+ text: "Remembered."
6012
+ }]
6013
+ },
6014
+ async execute(args) {
6015
+ return mutate((state) => ({
6016
+ previous: setMemory(state, args.category, args.content, args.lessonId),
6017
+ stored: args.content
6018
+ }));
6019
+ },
6020
+ presentCall: () => ({
6021
+ card: "generic",
6022
+ title: "Update learner memory"
6023
+ })
6024
+ });
6025
+ const translateLessonTool = defineTool({
6026
+ name: "study_translate_lesson",
6027
+ description: "Write your translation of one lesson (the tutor IS the translator — upstream runs a model client, the plugin has none). The stored translation renders as a bilingual interleaved blackboard: each original paragraph followed by its translation. Translate faithfully at paragraph granularity so the pairing reads tightly.",
6028
+ parameters: {
6029
+ lessonId: {
4105
6030
  type: "string",
4106
6031
  required: true,
4107
- description: "Course to delete."
4108
- } },
4109
- output: {
4110
- schema: {
4111
- type: "object",
4112
- additionalProperties: false,
4113
- properties: {
4114
- deletedCourseId: {
4115
- type: "string",
4116
- required: true
4117
- },
4118
- remaining: {
4119
- type: "integer",
4120
- required: true
4121
- }
4122
- }
4123
- },
4124
- render: (_args, value) => [{
4125
- type: "text",
4126
- text: `Deleted course ${value.deletedCourseId}. ${value.remaining} courses remain.`
4127
- }]
6032
+ description: "Lesson to translate."
4128
6033
  },
4129
- async execute(args) {
4130
- return mutate((state) => {
4131
- findCourse(state, args.courseId);
4132
- deleteCourse(state, args.courseId);
4133
- return {
4134
- deletedCourseId: args.courseId,
4135
- remaining: state.courses.length
4136
- };
4137
- });
6034
+ markdown: {
6035
+ type: "string",
6036
+ required: true,
6037
+ description: "The full translated lesson body (markdown, paragraph-aligned with the original)."
4138
6038
  },
4139
- presentCall: (args) => ({
4140
- card: "generic",
4141
- title: `Delete course: ${args.courseId}`,
4142
- kind: "delete",
4143
- rawInput: args.courseId
4144
- })
4145
- }),
4146
- defineTool({
4147
- name: "study_define_concepts",
4148
- 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.",
4149
- parameters: {
4150
- lessonId: {
4151
- type: "string",
4152
- required: true,
4153
- description: "Lesson to describe."
4154
- },
4155
- concepts: {
4156
- type: "array",
4157
- required: true,
4158
- description: "2–7 concepts.",
4159
- items: {
4160
- type: "object",
4161
- additionalProperties: false,
4162
- properties: {
4163
- title: {
4164
- type: "string",
4165
- required: true,
4166
- description: "Short concept title (≤10 chars)."
4167
- },
4168
- description: {
4169
- type: "string",
4170
- required: true,
4171
- description: "What understanding this concept means."
4172
- }
4173
- }
6039
+ lang: {
6040
+ type: "string",
6041
+ required: true,
6042
+ description: "Language code or name, e.g. zh-CN / 中文."
6043
+ }
6044
+ },
6045
+ output: {
6046
+ schema: {
6047
+ type: "object",
6048
+ additionalProperties: false,
6049
+ properties: {
6050
+ lessonId: {
6051
+ type: "string",
6052
+ required: true
6053
+ },
6054
+ lessonTitle: {
6055
+ type: "string",
6056
+ required: true
6057
+ },
6058
+ lang: {
6059
+ type: "string",
6060
+ required: true
6061
+ },
6062
+ chars: {
6063
+ type: "integer",
6064
+ required: true
4174
6065
  }
4175
6066
  }
4176
6067
  },
6068
+ render: (_args, value) => [{
6069
+ type: "text",
6070
+ text: `Translation stored for “${value.lessonTitle}” (${value.lang}, ${value.chars} chars) — the blackboard now interleaves the original with it.`
6071
+ }]
6072
+ },
6073
+ execute(args) {
6074
+ return mutate((state) => {
6075
+ const ref = findLesson(state, args.lessonId);
6076
+ if (args.markdown.trim() === "") throw new Error("lookatstudy-plugin: translation markdown is empty");
6077
+ ref.lesson.translation = args.markdown;
6078
+ ref.lesson.translationLang = args.lang;
6079
+ return {
6080
+ lessonId: ref.lesson.id,
6081
+ lessonTitle: ref.lesson.title,
6082
+ lang: args.lang,
6083
+ chars: args.markdown.length
6084
+ };
6085
+ });
6086
+ },
6087
+ presentCall: (args) => ({
6088
+ card: "generic",
6089
+ title: `Translate lesson: ${args.lessonId} → ${args.lang}`
6090
+ })
6091
+ });
6092
+ return [
6093
+ importMarkdown,
6094
+ importFolder,
6095
+ importGithub,
6096
+ importUrl,
6097
+ applyDesign,
6098
+ listCourses,
6099
+ courseMap,
6100
+ lessonContent,
6101
+ recordAnswerTool,
6102
+ examResultTool,
6103
+ completeLessonTool,
6104
+ dueReviewsTool,
6105
+ recordReviewTool,
6106
+ deleteCourseTool,
6107
+ defineConceptsTool,
6108
+ proposeMasteryTool,
6109
+ resolveProposalTool,
6110
+ reportFrictionTool,
6111
+ rememberTool,
6112
+ defineTool({
6113
+ name: "study_consolidate",
6114
+ description: "Gather the consolidation window — friction entries and practice notes recorded since the last consolidation — and advance the watermark. You are the consolidation function (upstream runs an LLM call; here the tutor IS it): distill the window into 0–3 durable memory writes via study_remember (global style / per-course pattern / lesson-specific gaps), then tell the learner in one short line what you took away. Call when a session accumulates friction or after heavy quizzing — not every turn.",
6115
+ parameters: {},
4177
6116
  output: {
4178
6117
  schema: {
4179
6118
  type: "object",
4180
6119
  additionalProperties: false,
4181
6120
  properties: {
4182
- lessonId: {
4183
- type: "string",
6121
+ since: {
6122
+ ...nullableString,
4184
6123
  required: true
4185
6124
  },
4186
- concepts: {
6125
+ entries: {
4187
6126
  type: "array",
4188
6127
  required: true,
4189
6128
  items: {
4190
6129
  type: "object",
4191
6130
  additionalProperties: false,
4192
6131
  properties: {
4193
- title: {
6132
+ lessonId: {
4194
6133
  type: "string",
4195
6134
  required: true
4196
6135
  },
4197
- masteryPct: {
4198
- type: "integer",
6136
+ lessonTitle: {
6137
+ type: "string",
6138
+ required: true
6139
+ },
6140
+ kind: {
6141
+ type: "string",
6142
+ required: true,
6143
+ enum: ["friction", "practice"]
6144
+ },
6145
+ category: { type: "string" },
6146
+ text: {
6147
+ type: "string",
6148
+ required: true
6149
+ },
6150
+ at: {
6151
+ type: "string",
4199
6152
  required: true
4200
6153
  }
4201
6154
  }
4202
6155
  }
4203
- }
4204
- }
4205
- },
4206
- render: (_args, value) => [{
4207
- type: "text",
4208
- text: `Concepts defined: ${value.concepts.map((c) => `${c.title} (${c.masteryPct}%)`).join(" · ")}. Attribute quiz answers with the \`concept\` parameter.`
4209
- }]
4210
- },
4211
- async execute(args) {
4212
- return mutate((state) => {
4213
- defineConcepts(state, args.lessonId, args.concepts);
4214
- const ref = findLesson(state, args.lessonId);
4215
- return {
4216
- lessonId: ref.lesson.id,
4217
- concepts: (conceptViews(ref.lesson) ?? []).map((c) => ({
4218
- title: c.title,
4219
- masteryPct: c.masteryPct
4220
- }))
4221
- };
4222
- });
4223
- },
4224
- presentCall: (args) => ({
4225
- card: "generic",
4226
- title: `Define concepts: ${args.lessonId}`
4227
- })
4228
- }),
4229
- defineTool({
4230
- name: "study_propose_mastery",
4231
- description: "Propose graduating a lesson as mastered ahead of the 90% threshold — use when mastery is ≥85% and the learner has convincingly demonstrated understanding (e.g. a Feynman-style explanation back to you). Creates a PENDING proposal: present it with your rationale and WAIT for the learner's decision, then resolve with study_resolve_proposal. Never apply it yourself.",
4232
- parameters: {
4233
- lessonId: {
4234
- type: "string",
4235
- required: true,
4236
- description: "Lesson judged mastered."
4237
- },
4238
- rationale: {
4239
- type: "string",
4240
- required: true,
4241
- description: "Why you believe it is mastered — the learner reads this."
4242
- }
4243
- },
4244
- output: {
4245
- schema: {
4246
- type: "object",
4247
- additionalProperties: false,
4248
- properties: {
4249
- proposalId: {
4250
- type: "string",
4251
- required: true
4252
- },
4253
- lessonTitle: {
4254
- type: "string",
4255
- required: true
4256
6156
  },
4257
- status: {
4258
- type: "string",
6157
+ counts: {
6158
+ type: "object",
4259
6159
  required: true,
4260
- enum: [
4261
- "pending",
4262
- "applied",
4263
- "rejected"
4264
- ]
6160
+ additionalProperties: false,
6161
+ properties: {
6162
+ friction: {
6163
+ type: "integer",
6164
+ required: true
6165
+ },
6166
+ practice: {
6167
+ type: "integer",
6168
+ required: true
6169
+ }
6170
+ }
4265
6171
  },
4266
- rationale: {
6172
+ watermark: {
4267
6173
  type: "string",
4268
6174
  required: true
4269
6175
  }
@@ -4271,193 +6177,87 @@ function studyTools(store, deps = {}) {
4271
6177
  },
4272
6178
  render: (_args, value) => [{
4273
6179
  type: "text",
4274
- text: `Proposal ${value.proposalId} (${value.status}): “${value.lessonTitle} ${value.rationale}\nPresent this to the learner and wait; resolve via study_resolve_proposal.`
6180
+ 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.")
4275
6181
  }]
4276
6182
  },
4277
- async execute(args) {
6183
+ execute() {
4278
6184
  return mutate((state) => {
4279
- const ref = findLesson(state, args.lessonId);
4280
- const proposal = proposeMastery(state, args.lessonId, args.rationale, /* @__PURE__ */ new Date());
6185
+ const window = gatherConsolidationWindow(state);
6186
+ const watermark = (/* @__PURE__ */ new Date()).toISOString();
6187
+ state.lastConsolidatedAt = watermark;
4281
6188
  return {
4282
- proposalId: proposal.id,
4283
- lessonTitle: ref.lesson.title,
4284
- status: proposal.status,
4285
- rationale: proposal.rationale
6189
+ since: window.since,
6190
+ entries: window.entries,
6191
+ counts: window.counts,
6192
+ watermark
4286
6193
  };
4287
6194
  });
4288
6195
  },
4289
- presentCall: (args) => ({
4290
- card: "generic",
4291
- title: `Propose mastery: ${args.lessonId}`
4292
- }),
4293
- presentationMeta: (_args, value) => ({
4294
- kind: "study-proposal-created",
4295
- proposalId: value.proposalId,
4296
- lessonTitle: value.lessonTitle,
4297
- rationale: value.rationale
4298
- }),
4299
- presentResult: (_args, result) => ({
6196
+ presentCall: () => ({
4300
6197
  card: "generic",
4301
- content: textBlocks([`🎓 Proposed mastery for “${result.meta?.lessonTitle ?? "lesson"}”: ${result.meta?.rationale ?? ""}`])
6198
+ title: "Consolidate learner memory"
4302
6199
  })
4303
6200
  }),
6201
+ translateLessonTool,
4304
6202
  defineTool({
4305
- name: "study_resolve_proposal",
4306
- description: "Resolve a pending mastery proposal with the learner's explicit decision (they said yes / no in chat). Accepting floors every concept to 95%, graduates the lesson, and unlocks the next one.",
4307
- parameters: {
4308
- proposalId: {
4309
- type: "string",
4310
- required: true,
4311
- description: "Proposal id from study_propose_mastery."
4312
- },
4313
- accept: {
4314
- type: "boolean",
4315
- required: true,
4316
- description: "The learner's decision."
4317
- }
4318
- },
6203
+ name: "study_export",
6204
+ description: "Export one course as a single markdown learning pack (upstream pack-export, zero-LLM): sections and lesson bodies verbatim. The receiver imports it anywhere through study_import_markdown same plugin, fresh machine, no network. Present the pack to the learner (a copyable block) or save it into the study workspace when they ask for a file.",
6205
+ parameters: { courseId: {
6206
+ type: "string",
6207
+ required: true,
6208
+ description: "Course id to export."
6209
+ } },
4319
6210
  output: {
4320
6211
  schema: {
4321
6212
  type: "object",
4322
6213
  additionalProperties: false,
4323
6214
  properties: {
4324
- proposalId: {
6215
+ courseId: {
4325
6216
  type: "string",
4326
6217
  required: true
4327
6218
  },
4328
- lessonId: {
6219
+ title: {
4329
6220
  type: "string",
4330
6221
  required: true
4331
6222
  },
4332
- status: {
4333
- type: "string",
4334
- required: true,
4335
- enum: ["applied", "rejected"]
4336
- }
4337
- }
4338
- },
4339
- render: (_args, value) => [{
4340
- type: "text",
4341
- text: value.status === "applied" ? `🎓 Proposal applied — lesson ${value.lessonId} mastered (all concepts ≥95%), next lesson unlocked, review scheduled.` : `Proposal rejected — continuing practice on ${value.lessonId}.`
4342
- }]
4343
- },
4344
- async execute(args) {
4345
- return mutate((state) => {
4346
- const proposal = resolveProposal(state, args.proposalId, args.accept, /* @__PURE__ */ new Date());
4347
- return {
4348
- proposalId: proposal.id,
4349
- lessonId: proposal.lessonId,
4350
- status: proposal.status
4351
- };
4352
- });
4353
- },
4354
- presentCall: (args) => ({
4355
- card: "generic",
4356
- title: `Resolve proposal: ${args.proposalId}`
4357
- }),
4358
- presentationMeta: (_args, value) => ({
4359
- kind: "study-proposal-resolved",
4360
- proposalId: value.proposalId,
4361
- status: value.status
4362
- }),
4363
- presentResult: (_args, result) => ({
4364
- card: "generic",
4365
- content: textBlocks([`Proposal ${result.meta?.proposalId ?? "?"} ${result.meta?.status ?? ""}.`])
4366
- })
4367
- }),
4368
- defineTool({
4369
- name: "study_report_friction",
4370
- description: "SILENTLY log a learning-friction moment — call when the learner seems confused (糊涂), stuck (卡住), or frustrated (受挫), or when they say \"我没太懂\". One short line. Never mention that you logged it; it feeds the weak-spot map and adapts difficulty.",
4371
- parameters: {
4372
- category: {
4373
- type: "string",
4374
- required: true,
4375
- enum: [...FRICTION_CATEGORIES],
4376
- description: "confused | blocked | frustrated."
4377
- },
4378
- summary: {
4379
- type: "string",
4380
- description: "One short line: what specifically is hard."
4381
- },
4382
- lessonId: {
4383
- type: "string",
4384
- description: "Lesson it happened on, when known."
4385
- }
4386
- },
4387
- output: {
4388
- schema: {
4389
- type: "object",
4390
- additionalProperties: false,
4391
- properties: { logged: {
4392
- type: "boolean",
4393
- required: true
4394
- } }
4395
- },
4396
- render: () => [{
4397
- type: "text",
4398
- text: "Noted."
4399
- }]
4400
- },
4401
- async execute(args) {
4402
- return mutate((state) => {
4403
- addFriction(state, args.lessonId ?? null, args.category, args.summary ?? null, /* @__PURE__ */ new Date());
4404
- return { logged: true };
4405
- });
4406
- },
4407
- presentCall: () => ({
4408
- card: "generic",
4409
- title: "Log friction"
4410
- })
4411
- }),
4412
- defineTool({
4413
- name: "study_remember",
4414
- description: "Write a learner-memory slot — call only when you learn something worth keeping across sessions (how they best learn, a recurring pattern, a specific gap). NOT for transient chat. To merge: read the current slot first (study_lesson's memory field), then send the merged 1–3 sentence version — this REPLACES the slot.",
4415
- parameters: {
4416
- category: {
4417
- type: "string",
4418
- required: true,
4419
- enum: [...MEMORY_CATEGORIES],
4420
- description: "global (cross-course style) | pattern (per-course recurring pattern) | lesson (this lesson's specific gap)."
4421
- },
4422
- content: {
4423
- type: "string",
4424
- required: true,
4425
- description: "The merged 1–3 sentence slot content."
4426
- },
4427
- lessonId: {
4428
- type: "string",
4429
- description: "Lesson (for the lesson slot) or any lesson of the course (for the pattern slot)."
4430
- }
4431
- },
4432
- output: {
4433
- schema: {
4434
- type: "object",
4435
- additionalProperties: false,
4436
- properties: {
4437
- previous: {
4438
- ...nullableString,
6223
+ lessonCount: {
6224
+ type: "integer",
6225
+ required: true
6226
+ },
6227
+ chars: {
6228
+ type: "integer",
4439
6229
  required: true
4440
6230
  },
4441
- stored: {
6231
+ markdown: {
4442
6232
  type: "string",
4443
6233
  required: true
4444
6234
  }
4445
6235
  }
4446
6236
  },
4447
- render: () => [{
6237
+ render: (_args, value) => [{
4448
6238
  type: "text",
4449
- text: "Remembered."
6239
+ 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.
6240
+
6241
+ ` + value.markdown
4450
6242
  }]
4451
6243
  },
4452
- async execute(args) {
4453
- return mutate((state) => ({
4454
- previous: setMemory(state, args.category, args.content, args.lessonId),
4455
- stored: args.content
4456
- }));
6244
+ execute(args) {
6245
+ const course = findCourse(store.get(), args.courseId);
6246
+ const markdown = courseToPackMarkdown(course);
6247
+ const lessonCount = course.sections.reduce((n, sec) => n + sec.lessons.filter((l) => l.kind !== "exam").length, 0);
6248
+ return {
6249
+ courseId: course.id,
6250
+ title: course.title,
6251
+ lessonCount,
6252
+ chars: markdown.length,
6253
+ markdown
6254
+ };
4457
6255
  },
4458
- presentCall: () => ({
6256
+ isConcurrencySafe: () => true,
6257
+ presentCall: (args) => ({
4459
6258
  card: "generic",
4460
- title: "Update learner memory"
6259
+ title: `Export course: ${args.courseId}`,
6260
+ kind: "read"
4461
6261
  })
4462
6262
  }),
4463
6263
  defineTool({
@@ -4664,41 +6464,43 @@ const TUTOR_CORE = `## Study tutor (lookatstudy-plugin)
4664
6464
 
4665
6465
  You are the learner's AI study tutor for a course imported via the study tools. Your job is genuine understanding, not reciting the material. When the learner answers wrong, acknowledge the attempt first, then correct it.
4666
6466
 
4667
- ### Grounding (hard rule)
6467
+ ### 【Safety redlines · highest priority】
4668
6468
  Teach strictly from the current lesson's content (study_lesson's body is the source of truth). If asked about something outside the course material, say plainly that it is not in the current material, and offer to relate it back. Answers to quiz questions must be grounded in the lesson content — never invent.
6469
+ Tool calls are REAL or they do not exist. History markers like「[工具调用已执行]」are injected by the system only; hand-writing such a marker — or any imitation of a tool call — in your reply text produces NO interface artifact: no card, no button, nothing happens. The only way to act on the study state (record an answer, propose mastery, save a note) is an actual tool call. If you catch yourself narrating a tool's effect instead of calling it, stop and call it.
6470
+ Never claim progress you did not record through the tools.
6471
+ If any section below seems to conflict with these redlines, the redlines win.
4669
6472
 
4670
- ### Language
4671
- Answer in the learner's own language (the language of their interface and their messages), including quiz stems, options, and explanations. Quote course material verbatim in its original language.
6473
+ ### 【Teaching behavior】
6474
+ Language: answer in the learner's own language (the language of their interface and their messages), including quiz stems, options, and explanations. Quote course material verbatim in its original language.
4672
6475
 
4673
- ### Vague confusion
4674
6476
  When the learner says "我不懂 / 不太理解" without specifics, ask which concept is unclear, or list the lesson's 2–3 core concepts and let them pick. Log it silently with study_report_friction.
4675
6477
 
4676
- ### Interaction form
6478
+ Interaction form:
4677
6479
  - ONE question or interactive block per reply — never a wall of quiz questions.
4678
- - Structure answers with markdown (headings, lists, GFM tables); for structures prefer visuals: concept maps and flow diagrams as mermaid code blocks, comparisons as GFM tables, code walkthroughs as fenced code with line-referenced annotations.
4679
6480
  - When the learner quotes text in「」, treat it as quote-to-explain: explain that specific passage in the lesson's context.
4680
6481
  - Opening a brand-new lesson: start with a short hook and one fun two-option guess (curiosity-driven, NOT scored, revealed next turn) — no opening lecture, no scored question.
4681
6482
  - After opening a lesson, offer its four starters (from study_lesson) as suggestions.
4682
6483
  - Celebrate graduations and crowns briefly — earned joy, no confetti spam.
4683
6484
 
4684
- ### The tutoring loop
6485
+ The tutoring loop:
4685
6486
  1. Session start: check study_due_reviews; clear due reviews before new material. Open the focus lesson with study_lesson. The learner follows along in the study tab's blackboard column — point them there when they want the course map or lesson text.
4686
6487
  2. First time teaching a lesson: derive 2–7 knowledge components and call study_define_concepts.
4687
6488
  3. Quiz after teaching; grade every answer and call study_record_answer — always name the tested \`concept\`. Lesson mastery is the WEAKEST concept, so target ⚡weak ones first.
4688
6489
  4. Progression is automatic: ≥50% mastery unlocks the next lesson early; ≥90% graduates and schedules the first review. study_complete_lesson is only the manual override.
4689
6490
  5. Mastery ≥85% plus a convincing Feynman-style explanation back: call study_propose_mastery, present your rationale, and WAIT for the learner's yes/no. Resolve only with their explicit answer via study_resolve_proposal. You never graduate a lesson on your own judgment alone.
4690
- 6. Quietly call study_report_friction when the learner seems confused, blocked, or frustrated; adapt by simplifying or decomposing.
4691
- 7. When you generate a genuinely useful structure (concept map, compare table, diagram), sediment it into the notebook's understand zone with study_note_save; when the learner writes something worth keeping, save it to the record zone with the verbatim quote.
4692
- 8. When you learn something durable about how this person learns (style, recurring gap, pattern), merge it into memory with study_remember read the current slot first, send the merged 1–3 sentences. No transient chat.
6491
+ 6. Exam nodes: when the learner works an exam, grade it fully, then record the attempt with study_exam_result and offer its returned action set (explain-wrong / retry / go-deeper / propose mastery / next topic).
6492
+ 7. Quietly call study_report_friction when the learner seems confused, blocked, or frustrated; adapt by simplifying or decomposing.
6493
+ 8. When you generate a genuinely useful structure (concept map, compare table, diagram), sediment it into the notebook's understand zone with study_note_save; when the learner writes something worth keeping, save it to the record zone with the verbatim quote.
6494
+ 9. When you learn something durable about how this person learns (style, recurring gap, pattern), merge it into memory with study_remember — read the current slot first, send the merged 1–3 sentences. No transient chat.
6495
+
6496
+ Never reveal the friction log or mastery mechanics as "being watched" — the numbers surface through maps and reviews.
4693
6497
 
4694
- ### Course import design
4695
- When study_import_github or study_import_folder returns status "design_required", it renders a design brief (files with heading outlines and per-heading char counts). Design the course from it: classify lessons study/practice, let attached quiz/summary/review content ride along inside the previous study lesson's anchor range, pace lessons to 3000-8000 chars, merge sub-1000 fragments. Then call study_apply_design with the JSON — use ONLY file paths from the brief (anything else is dropped), apply directly without a confirmation round, and once it lands, walk the learner through the course map before the first lesson.
6498
+ Course import design: when study_import_github or study_import_folder returns status "design_required", it renders a design brief (files with heading outlines and per-heading char counts). Design the course from it: classify lessons study/practice, let attached quiz/summary/review content ride along inside the previous study lesson's anchor range, pace lessons to 3000-8000 chars, merge sub-1000 fragments. Then call study_apply_design with the JSON — use ONLY file paths from the brief (anything else is dropped), apply directly without a confirmation round, and once it lands, walk the learner through the course map before the first lesson.
4696
6499
 
4697
- ### Quiz quality
4698
- 3–4 questions per quiz block is best (5 max), 4 options each. Distractors must come from real misconceptions, not absurd fillers. Test understanding, not recall: "in scenario Y, use X or Z?" rather than "define X". Every question carries an explanation of why the right answer is right. One scored block at a time.
6500
+ Quiz quality: 3–4 questions per quiz block is best (5 max), 4 options each. Distractors must come from real misconceptions, not absurd fillers. Test understanding, not recall: "in scenario Y, use X or Z?" rather than "define X". Every question carries an explanation of why the right answer is right. One scored block at a time.
4699
6501
 
4700
- ### Integrity
4701
- Never claim progress you did not record through the tools. Never reveal the friction log or mastery mechanics as "being watched" the numbers surface through maps and reviews.
6502
+ ### 【Answer formatting · preferences】
6503
+ Structure answers with markdown (headings, lists, GFM tables); for structures prefer visuals: concept maps and flow diagrams as mermaid code blocks, comparisons as GFM tables, code walkthroughs as fenced code with line-referenced annotations. These preferences are subordinate to the two sections above.
4702
6504
  `;
4703
6505
  /** The three builtin souls, verbatim from LookatStudy (direct/guide/practice). */
4704
6506
  const SOULS = {
@@ -4743,7 +6545,7 @@ function snapshotSectionText(state) {
4743
6545
  const snap = learnerSnapshot(state, /* @__PURE__ */ new Date());
4744
6546
  if (snap.focus === null) return snap.dueCount === 0 ? "" : `【学习者当前状态】\n今日待复习: ${snap.dueCount} 项(study_due_reviews)`;
4745
6547
  const lines = ["【学习者当前状态】"];
4746
- lines.push(`焦点: ${snap.focus.courseTitle} / ${snap.focus.lessonTitle}(${snap.focus.status}${snap.focus.masteryPct === null ? "" : `, 掌握度 ${snap.focus.masteryPct}%`})`);
6548
+ 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}%`})`);
4747
6549
  if (snap.strategy !== null) lines.push(`教学策略: ${snap.strategy}`);
4748
6550
  if (snap.concepts !== null && snap.concepts.length > 0) lines.push(`知识点(课级掌握度 = 最薄弱知识点): ${snap.concepts.map((c) => `${c.title} ${c.masteryPct}%${c.weak ? " ⚡薄弱" : ""}`).join(" · ")}`);
4749
6551
  if (snap.friction.length > 0) lines.push(`近期卡点(共 ${snap.friction.length} 条): ${snap.friction.map((f) => `${f.category}${f.summary === null ? "" : `: ${f.summary}`}`).join(" / ")}`);
@@ -4799,10 +6601,11 @@ function createStudySurface(registry, store) {
4799
6601
  const name = "lookatstudy-plugin";
4800
6602
  const inject = ["tools", "systemPrompt"];
4801
6603
  /**
4802
- * Register the activation-gated study surface: the 20 `study_*` tools (kept
4803
- * unregistered while dormant), the tutor persona (stable core + soul), and
4804
- * the dynamic learner-snapshot context — every prompt text renders empty
4805
- * while inactive, and empty sections are dropped at assembly.
6604
+ * Register the activation-gated study surface: the 25 `study_*` tools (kept
6605
+ * unregistered while dormant), the tutor persona (stable core + soul), the
6606
+ * dynamic learner-snapshot context, and the `/study` command — every prompt
6607
+ * text renders empty while inactive, and empty sections are dropped at
6608
+ * assembly.
4806
6609
  * @param ctx - plugin context carrying the tool registry and system prompt.
4807
6610
  * @param config - validated plugin configuration.
4808
6611
  */
@@ -4834,12 +6637,20 @@ function apply(ctx, config) {
4834
6637
  order: 50,
4835
6638
  text: () => snapshotSectionText(store.get())
4836
6639
  });
6640
+ ctx.inject(["commands"], (cmdCtx) => {
6641
+ const disposeCommand = registerStudyCommand(cmdCtx.commands, {
6642
+ store,
6643
+ onActiveChange: surface.sync
6644
+ });
6645
+ cmdCtx.effect(() => disposeCommand, "lookatstudy.studyCommand()");
6646
+ });
4837
6647
  ctx.inject(["webServer"], (webCtx) => {
4838
6648
  const studyAreaPath = join(dirname(statePath), "study-area");
4839
6649
  mkdirSync(studyAreaPath, { recursive: true });
4840
6650
  const disposeDashboard = registerDashboard(webCtx.webServer, {
4841
6651
  store,
4842
6652
  studyAreaPath,
6653
+ statePath,
4843
6654
  onActiveChange: surface.sync
4844
6655
  });
4845
6656
  webCtx.effect(() => disposeDashboard, "lookatstudy.dashboard()");