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