dsh-plugin-lookatstudy 0.11.0 → 0.11.1

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