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