dsh-plugin-lookatstudy 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -625,6 +625,7 @@ function emptyState() {
625
625
  focus: null,
626
626
  memoryGlobal: null,
627
627
  memoryPatterns: {},
628
+ artifacts: {},
628
629
  proposals: [],
629
630
  lessonSessions: {},
630
631
  lastConsolidatedAt: null,
@@ -700,6 +701,7 @@ function loadState(path) {
700
701
  memoryPatterns: raw.memoryPatterns ?? {},
701
702
  proposals: raw.proposals ?? [],
702
703
  lessonSessions: raw.lessonSessions ?? {},
704
+ artifacts: raw.artifacts ?? {},
703
705
  lastConsolidatedAt: raw.lastConsolidatedAt ?? null,
704
706
  xp: raw.xp ?? {
705
707
  total: 0,
@@ -822,6 +824,7 @@ function deleteCourse(state, courseId) {
822
824
  if (i < 0) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
823
825
  state.courses.splice(i, 1);
824
826
  state.proposals = state.proposals.filter((p) => !p.lessonId.startsWith(`${courseId}:`));
827
+ for (const lessonId of Object.keys(state.artifacts)) if (lessonId.startsWith(`${courseId}:`)) delete state.artifacts[lessonId];
825
828
  }
826
829
  /**
827
830
  * Locate a course; unknown ids fail loud.
@@ -1188,6 +1191,28 @@ function addNote(state, lessonId, zone, title, text, source, quote, now) {
1188
1191
  * @param noteId - id of the note to remove.
1189
1192
  * @throws when the lesson or the note id is unknown (fail loud, like every id lookup).
1190
1193
  */
1194
+ /**
1195
+ * Record one artifact on its lesson, idempotently: the key is the content
1196
+ * hash (type + stable-stringified data) — re-importing or re-generating the
1197
+ * same artifact returns the existing row instead of duplicating it (upstream
1198
+ * learned this the hard way with message-id keys).
1199
+ * @returns the stored artifact and whether this call created it.
1200
+ */
1201
+ function recordArtifact(state, lessonId, artifact) {
1202
+ findLesson(state, lessonId);
1203
+ const existing = state.artifacts[lessonId] ?? [];
1204
+ const hit = existing.find((a) => a.hash === artifact.hash);
1205
+ if (hit !== void 0) return {
1206
+ artifact: hit,
1207
+ created: false
1208
+ };
1209
+ const next = [...existing, artifact];
1210
+ state.artifacts[lessonId] = next;
1211
+ return {
1212
+ artifact,
1213
+ created: true
1214
+ };
1215
+ }
1191
1216
  function deleteNote(state, lessonId, noteId) {
1192
1217
  const ref = findLesson(state, lessonId);
1193
1218
  const index = ref.lesson.notes.findIndex((n) => n.id === noteId);
@@ -1621,6 +1646,13 @@ function workbenchState(state, now) {
1621
1646
  masteryPct: ref.lesson.mastery === null ? null : Math.round(ref.lesson.mastery * 100),
1622
1647
  strategy: strategyBand(ref.lesson.mastery),
1623
1648
  concepts: conceptViews(ref.lesson) ?? [],
1649
+ due: dueIds.has(ref.lesson.id),
1650
+ artifacts: (state.artifacts[ref.lesson.id] ?? []).map((a) => ({
1651
+ id: a.id,
1652
+ artifactType: a.artifactType,
1653
+ title: a.title,
1654
+ data: a.data
1655
+ })),
1624
1656
  starters: starterPrompts(ref.lesson.title).map((s) => ({
1625
1657
  label: s.label,
1626
1658
  message: s.message
@@ -1858,6 +1890,60 @@ function registerDashboard(webServer, deps) {
1858
1890
  }
1859
1891
  return;
1860
1892
  }
1893
+ if (req.method === "POST" && pathname === "/lookatstudy/api/note/user") {
1894
+ const body = await readJsonBodySafe(req, res);
1895
+ if (body === void 0) return;
1896
+ if (typeof body.lessonId !== "string" || typeof body.quote !== "string" || body.quote.trim().length < 2) {
1897
+ sendJson(res, 400, {
1898
+ ok: false,
1899
+ error: "lessonId and quote (a real selection) required"
1900
+ });
1901
+ return;
1902
+ }
1903
+ try {
1904
+ const quote = body.quote.trim();
1905
+ const text = typeof body.text === "string" && body.text.trim() !== "" ? body.text.trim() : quote;
1906
+ const note = addNote(deps.store.get(), body.lessonId, "record", quote.slice(0, 24), text, "content", quote, /* @__PURE__ */ new Date());
1907
+ deps.store.save();
1908
+ sendJson(res, 200, {
1909
+ ok: true,
1910
+ noteId: note.id
1911
+ });
1912
+ } catch (error) {
1913
+ sendJson(res, 404, {
1914
+ ok: false,
1915
+ error: error instanceof Error ? error.message : String(error)
1916
+ });
1917
+ }
1918
+ return;
1919
+ }
1920
+ if (req.method === "POST" && pathname === "/lookatstudy/api/review") {
1921
+ const body = await readJsonBodySafe(req, res);
1922
+ if (body === void 0) return;
1923
+ const quality = body.quality;
1924
+ if (typeof body.lessonId !== "string" || typeof quality !== "number" || ![
1925
+ 1,
1926
+ 4,
1927
+ 5
1928
+ ].includes(quality)) {
1929
+ sendJson(res, 400, {
1930
+ ok: false,
1931
+ error: "lessonId and quality (1 | 4 | 5) required"
1932
+ });
1933
+ return;
1934
+ }
1935
+ try {
1936
+ recordReview(deps.store.get(), body.lessonId, quality, /* @__PURE__ */ new Date());
1937
+ deps.store.save();
1938
+ sendJson(res, 200, { ok: true });
1939
+ } catch (error) {
1940
+ sendJson(res, 404, {
1941
+ ok: false,
1942
+ error: error instanceof Error ? error.message : String(error)
1943
+ });
1944
+ }
1945
+ return;
1946
+ }
1861
1947
  if (req.method === "POST" && pathname === "/lookatstudy/api/lesson-session") {
1862
1948
  const body = await readJsonBodySafe(req, res);
1863
1949
  if (body === void 0) return;
@@ -1944,8 +2030,8 @@ const ZH = {
1944
2030
  "col.tutor": "导师",
1945
2031
  "col.bb": "黑板",
1946
2032
  "pane.rail": "课程",
1947
- "pane.tutor": "导师",
1948
- "pane.bb": "黑板",
2033
+ "pane.chat": "对话",
2034
+ "pane.note": "黑板",
1949
2035
  "viewtab.teach": "讲解",
1950
2036
  "viewtab.cmap": "概念图",
1951
2037
  "viewtab.cmap.title": "本课概念之间的关系图",
@@ -1969,6 +2055,71 @@ const ZH = {
1969
2055
  "rail.empty.placeholder": "GitHub 仓库链接,如 microsoft/AI-For-Beginners",
1970
2056
  "rail.empty.button": "导入",
1971
2057
  "composer.send": "发送",
2058
+ "quiz.card.title": "练习",
2059
+ "quiz.progress": "{cur}/{total}",
2060
+ "quiz.next": "下一题",
2061
+ "quiz.finish": "看成绩单",
2062
+ "quiz.score": "成绩:{correct}/{total}",
2063
+ "quiz.right": "答对了",
2064
+ "quiz.wrong": "答错了",
2065
+ "quiz.youChose": "你选了",
2066
+ "quiz.answer": "正确答案",
2067
+ "quiz.hook": "练习卡完成:{correct}/{total} 正确。请简短点评,针对错题讲讲思路。",
2068
+ "quiz.action.explain-wrong": "讲讲错题",
2069
+ "quiz.action.retry": "再来一组",
2070
+ "quiz.action.go-deeper": "深入原理",
2071
+ "quiz.action.mark-mastered": "标记我掌握了",
2072
+ "quiz.action.markMastered.hint": "会让导师出综合题检验,通过即标记掌握",
2073
+ "quiz.action.next-topic": "下一个知识点",
2074
+ "artifact.lines": "第 {from}-{to} 行:",
2075
+ "artifact.expand": "放大查看",
2076
+ "artifact.guess.title": "猜一猜",
2077
+ "artifact.guess.pick": "我选「{label}」",
2078
+ "artifact.guess.wait": "已记下你的直觉——导师下回合揭晓,看看你想得对不对",
2079
+ "artifact.sedimented": "有新的学习产物沉淀进笔记",
2080
+ "zone.artifacts": "AI 产物",
2081
+ "note.quote.ask": "提问这段",
2082
+ "note.quote.save": "加到笔记",
2083
+ "note.quote.template": "请讲解这段:「{text}」",
2084
+ "note.saved": "已存入记录区笔记",
2085
+ "review.rate.title": "复习自评——这课现在记得多牢?",
2086
+ "review.again": "再来一次",
2087
+ "review.remembered": "记住了",
2088
+ "review.mastered": "完全掌握",
2089
+ "review.done.good": "已安排下次复习",
2090
+ "review.done.again": "明天再来一遍",
2091
+ "review.jump": "点开这一课去复习",
2092
+ "review.nudge": "有 {n} 课到了复习时间,先回来练一遍?",
2093
+ "review.nudge.go": "去复习",
2094
+ "rail.search.jump": "打开这一课",
2095
+ "proposal.banner": "《{lesson}》可以提前毕业了",
2096
+ "proposal.accept": "接受",
2097
+ "proposal.decline": "再练练",
2098
+ "proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
2099
+ "proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
2100
+ "tutor.thinking": "导师思考中…",
2101
+ "header.xp": "经验值 {xp}",
2102
+ "header.streak": "连续学习天数",
2103
+ "header.level": "等级",
2104
+ "map.node.locked": "锁定 — 先完成前面的课时",
2105
+ "map.node.due": "到了复习时间",
2106
+ "map.exam.locked": "考试未解锁 — 本章节全部课时掌握度 ≥50% 后开放",
2107
+ "companion.poke": "戳一戳伴学伙伴",
2108
+ "companion.poked": "伴学伙伴向你眨了眨眼",
2109
+ "companion.form.ember": "小焰",
2110
+ "companion.form.frost": "霜绒",
2111
+ "companion.form.moss": "苔芽",
2112
+ "companion.form.star": "星尘",
2113
+ "companion.form.ink": "墨墨",
2114
+ "settings.companion": "伴学伙伴",
2115
+ "settings.companion.hint": "选择学习面板右下角的伴学生物形态。",
2116
+ "quiz.msg.explain-wrong": "我刚才有题答错了,帮我讲讲为什么错、正确的思路是什么。",
2117
+ "quiz.msg.retry": "再来一组类似的题巩固一下。",
2118
+ "quiz.msg.go-deeper": "这组我答得不错,帮我深入讲讲背后的原理和容易混淆的地方。",
2119
+ "quiz.msg.mark-mastered": "这课我觉得掌握了,帮我确认一下——出个综合题检验,通过了就标记为掌握。",
2120
+ "quiz.msg.next-topic": "进入下一个知识点。",
2121
+ "toast.region": "通知",
2122
+ "toast.close": "关闭",
1972
2123
  "composer.busy": "导师正在回复…",
1973
2124
  "rail.empty.demo": "导入示例课程",
1974
2125
  "rail.mastered": "毕业 {mastered}/{total} 课",
@@ -4247,6 +4398,196 @@ function completeLines(value) {
4247
4398
  return lines;
4248
4399
  }
4249
4400
  //#endregion
4401
+ //#region src/artifacts.ts
4402
+ /**
4403
+ * The artifact channel — the upstream artifacts system port (0.15.0 P1):
4404
+ * tools that produce display-worthy learning objects return them tagged with
4405
+ * an `artifactType` (quiz / guess / compare_table / code_walkthrough /
4406
+ * concept_map / diagram), the state records them idempotently per lesson
4407
+ * (upstream's canvas_items equivalent — content-hashed, because the lesson
4408
+ * there was burned by message-id keys duplicating saves), and the panel
4409
+ * renders them as interactive cards. Host-side pure logic + node:crypto.
4410
+ * @module dsh-plugin-lookatstudy/artifacts
4411
+ */
4412
+ /** Deterministic stringify (sorted object keys at every depth). */
4413
+ function stableStringify(value) {
4414
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
4415
+ if (value !== null && typeof value === "object") return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
4416
+ return JSON.stringify(value) ?? "null";
4417
+ }
4418
+ /** Short stable content hash (the dedup key — never a message id). */
4419
+ function contentHash(text) {
4420
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
4421
+ }
4422
+ const MAX_QUESTIONS = 20;
4423
+ /**
4424
+ * Sanitize a tutor-produced quiz: coerce strings, drop structurally invalid
4425
+ * questions (collecting a warning each), fail loud when nothing usable
4426
+ * remains. Mirrors upstream sanitizeArtifact('quiz') intent — the card never
4427
+ * renders a question it cannot judge.
4428
+ */
4429
+ function sanitizeQuiz(raw) {
4430
+ const warnings = [];
4431
+ const input = raw ?? {};
4432
+ const title = typeof input.title === "string" && input.title.trim() !== "" ? input.title.trim() : "练习";
4433
+ const questions = [];
4434
+ const list = Array.isArray(input.questions) ? input.questions : [];
4435
+ list.slice(0, MAX_QUESTIONS).forEach((q, index) => {
4436
+ const item = q ?? {};
4437
+ const prompt = typeof item.prompt === "string" ? item.prompt.trim() : "";
4438
+ const options = Array.isArray(item.options) ? item.options.map((o) => typeof o === "string" ? o.trim() : "").filter((o) => o !== "") : [];
4439
+ const answer = typeof item.answer === "number" && Number.isInteger(item.answer) ? item.answer : -1;
4440
+ const explanation = typeof item.explanation === "string" ? item.explanation.trim() : "";
4441
+ if (prompt === "") {
4442
+ warnings.push(`question ${index + 1}: empty prompt dropped`);
4443
+ return;
4444
+ }
4445
+ if (options.length < 2) {
4446
+ warnings.push(`question ${index + 1}: fewer than 2 usable options dropped`);
4447
+ return;
4448
+ }
4449
+ if (answer < 0 || answer >= options.length) {
4450
+ warnings.push(`question ${index + 1}: answer index out of range dropped`);
4451
+ return;
4452
+ }
4453
+ questions.push({
4454
+ prompt,
4455
+ options,
4456
+ answer,
4457
+ explanation
4458
+ });
4459
+ });
4460
+ if (questions.length === 0) throw new Error(`lookatstudy-plugin: quiz artifact has no usable questions${warnings.length > 0 ? ` (${warnings.join("; ")})` : ""}`);
4461
+ if (list.length > MAX_QUESTIONS) warnings.push(`truncated to ${MAX_QUESTIONS} questions`);
4462
+ const data = {
4463
+ artifactType: "quiz",
4464
+ title,
4465
+ questions
4466
+ };
4467
+ if (warnings.length > 0) data.warnings = warnings;
4468
+ return {
4469
+ data,
4470
+ warnings
4471
+ };
4472
+ }
4473
+ /** The artifact id derived from type + content (stable across re-imports). */
4474
+ function artifactId(artifactType, data) {
4475
+ return `${artifactType}-${contentHash(stableStringify(data))}`;
4476
+ }
4477
+ /** Coerce one string field, dropping empty. */
4478
+ function str(value) {
4479
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
4480
+ }
4481
+ /** Sanitize a guess (the opening two-option hook; unscored, revealed next turn). */
4482
+ function sanitizeGuess(raw) {
4483
+ const warnings = [];
4484
+ const input = raw ?? {};
4485
+ const prompt = str(input.prompt);
4486
+ if (prompt === null) throw new Error("lookatstudy-plugin: guess artifact needs a prompt");
4487
+ const list = Array.isArray(input.options) ? input.options : [];
4488
+ const options = [];
4489
+ list.slice(0, 2).forEach((o, index) => {
4490
+ const item = o ?? {};
4491
+ const id = str(item.id) ?? String.fromCharCode(97 + index);
4492
+ const label = str(item.label);
4493
+ if (label === null) {
4494
+ warnings.push(`option ${index + 1}: empty label dropped`);
4495
+ return;
4496
+ }
4497
+ options.push({
4498
+ id,
4499
+ label
4500
+ });
4501
+ });
4502
+ if (options.length !== 2) throw new Error(`lookatstudy-plugin: guess artifact needs exactly 2 options (${warnings.join("; ")})`);
4503
+ return {
4504
+ data: {
4505
+ artifactType: "guess",
4506
+ prompt,
4507
+ options
4508
+ },
4509
+ warnings
4510
+ };
4511
+ }
4512
+ /** Sanitize a compare table (headers ≥2; every row matches the header width). */
4513
+ function sanitizeCompareTable(raw) {
4514
+ const warnings = [];
4515
+ const input = raw ?? {};
4516
+ const title = str(input.title) ?? "对比";
4517
+ const headers = (Array.isArray(input.headers) ? input.headers : []).map((h) => str(h)).filter((h) => h !== null);
4518
+ if (headers.length < 2) throw new Error("lookatstudy-plugin: compare_table needs at least 2 headers");
4519
+ const rows = [];
4520
+ for (const row of Array.isArray(input.rows) ? input.rows : []) {
4521
+ const cells = (Array.isArray(row) ? row : []).map((c) => typeof c === "string" ? c.trim() : "");
4522
+ if (cells.length === headers.length) rows.push(cells);
4523
+ else warnings.push(`row dropped (${cells.length} cells ≠ ${headers.length} headers)`);
4524
+ }
4525
+ if (rows.length === 0) throw new Error(`lookatstudy-plugin: compare_table has no usable rows (${warnings.join("; ")})`);
4526
+ return {
4527
+ data: {
4528
+ artifactType: "compare_table",
4529
+ title,
4530
+ headers,
4531
+ rows
4532
+ },
4533
+ warnings
4534
+ };
4535
+ }
4536
+ /** Sanitize a mermaid diagram (type + non-empty code). */
4537
+ function sanitizeDiagram(raw) {
4538
+ const input = raw ?? {};
4539
+ const title = str(input.title) ?? "图示";
4540
+ const diagramType = input.diagramType === "sequence" || input.diagramType === "state" ? input.diagramType : "flowchart";
4541
+ const mermaid = str(input.mermaid);
4542
+ if (mermaid === null) throw new Error("lookatstudy-plugin: diagram artifact needs mermaid code");
4543
+ return {
4544
+ data: {
4545
+ artifactType: "diagram",
4546
+ title,
4547
+ diagramType,
4548
+ mermaid
4549
+ },
4550
+ warnings: []
4551
+ };
4552
+ }
4553
+ /** Sanitize a code walkthrough (annotations reference real 1-based line ranges). */
4554
+ function sanitizeCodeWalkthrough(raw) {
4555
+ const warnings = [];
4556
+ const input = raw ?? {};
4557
+ const title = str(input.title) ?? "代码讲解";
4558
+ const language = str(input.language) ?? "text";
4559
+ const code = str(input.code);
4560
+ if (code === null) throw new Error("lookatstudy-plugin: code_walkthrough artifact needs code");
4561
+ const lineCount = code.split("\n").length;
4562
+ const annotations = [];
4563
+ for (const a of Array.isArray(input.annotations) ? input.annotations : []) {
4564
+ const item = a ?? {};
4565
+ const note = str(item.note);
4566
+ const lineStart = typeof item.lineStart === "number" && Number.isInteger(item.lineStart) ? item.lineStart : -1;
4567
+ const lineEnd = typeof item.lineEnd === "number" && Number.isInteger(item.lineEnd) ? item.lineEnd : lineStart;
4568
+ if (note === null || lineStart < 1 || lineEnd < lineStart || lineEnd > lineCount) {
4569
+ warnings.push(`annotation dropped (line ${String(lineStart)}-${String(lineEnd)} of ${String(lineCount)})`);
4570
+ continue;
4571
+ }
4572
+ annotations.push({
4573
+ lineStart,
4574
+ lineEnd,
4575
+ note
4576
+ });
4577
+ }
4578
+ if (annotations.length === 0) throw new Error(`lookatstudy-plugin: code_walkthrough has no usable annotations (${warnings.join("; ")})`);
4579
+ return {
4580
+ data: {
4581
+ artifactType: "code_walkthrough",
4582
+ title,
4583
+ language,
4584
+ code,
4585
+ annotations
4586
+ },
4587
+ warnings
4588
+ };
4589
+ }
4590
+ //#endregion
4250
4591
  //#region src/tools.ts
4251
4592
  /**
4252
4593
  * The `study_*` tool surface, ported from LookatStudy's agent contract:
@@ -6407,211 +6748,555 @@ function studyTools(store, deps = {}) {
6407
6748
  title: `Translate lesson: ${args.lessonId} → ${args.lang}`
6408
6749
  })
6409
6750
  });
6410
- return [
6411
- importMarkdown,
6412
- importFolder,
6413
- importGithub,
6414
- importUrl,
6415
- applyDesign,
6416
- listCourses,
6417
- courseMap,
6418
- lessonContent,
6419
- recordAnswerTool,
6420
- examResultTool,
6421
- completeLessonTool,
6422
- dueReviewsTool,
6423
- recordReviewTool,
6424
- deleteCourseTool,
6425
- defineConceptsTool,
6426
- proposeMasteryTool,
6427
- resolveProposalTool,
6428
- reportFrictionTool,
6429
- rememberTool,
6430
- defineTool({
6431
- name: "study_consolidate",
6432
- 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.",
6433
- parameters: {},
6434
- output: {
6435
- schema: {
6436
- type: "object",
6437
- additionalProperties: false,
6438
- properties: {
6439
- since: {
6440
- ...nullableString,
6441
- required: true
6442
- },
6443
- entries: {
6444
- type: "array",
6445
- required: true,
6446
- items: {
6447
- type: "object",
6448
- additionalProperties: false,
6449
- properties: {
6450
- lessonId: {
6451
- type: "string",
6452
- required: true
6453
- },
6454
- lessonTitle: {
6455
- type: "string",
6456
- required: true
6457
- },
6458
- kind: {
6459
- type: "string",
6460
- required: true,
6461
- enum: ["friction", "practice"]
6462
- },
6463
- category: { type: "string" },
6464
- text: {
6465
- type: "string",
6466
- required: true
6467
- },
6468
- at: {
6469
- type: "string",
6470
- required: true
6471
- }
6472
- }
6473
- }
6474
- },
6475
- counts: {
6751
+ const consolidateTool = defineTool({
6752
+ name: "study_consolidate",
6753
+ 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.",
6754
+ parameters: {},
6755
+ output: {
6756
+ schema: {
6757
+ type: "object",
6758
+ additionalProperties: false,
6759
+ properties: {
6760
+ since: {
6761
+ ...nullableString,
6762
+ required: true
6763
+ },
6764
+ entries: {
6765
+ type: "array",
6766
+ required: true,
6767
+ items: {
6476
6768
  type: "object",
6477
- required: true,
6478
6769
  additionalProperties: false,
6479
6770
  properties: {
6480
- friction: {
6481
- type: "integer",
6771
+ lessonId: {
6772
+ type: "string",
6482
6773
  required: true
6483
6774
  },
6484
- practice: {
6485
- type: "integer",
6775
+ lessonTitle: {
6776
+ type: "string",
6777
+ required: true
6778
+ },
6779
+ kind: {
6780
+ type: "string",
6781
+ required: true,
6782
+ enum: ["friction", "practice"]
6783
+ },
6784
+ category: { type: "string" },
6785
+ text: {
6786
+ type: "string",
6787
+ required: true
6788
+ },
6789
+ at: {
6790
+ type: "string",
6486
6791
  required: true
6487
6792
  }
6488
6793
  }
6489
- },
6490
- watermark: {
6491
- type: "string",
6492
- required: true
6493
6794
  }
6494
- }
6495
- },
6496
- render: (_args, value) => [{
6497
- type: "text",
6498
- 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.")
6499
- }]
6500
- },
6501
- execute() {
6502
- return mutate((state) => {
6503
- const window = gatherConsolidationWindow(state);
6504
- const watermark = (/* @__PURE__ */ new Date()).toISOString();
6505
- state.lastConsolidatedAt = watermark;
6506
- return {
6507
- since: window.since,
6508
- entries: window.entries,
6509
- counts: window.counts,
6510
- watermark
6511
- };
6512
- });
6513
- },
6514
- presentCall: () => ({
6515
- card: "generic",
6516
- title: "Consolidate learner memory"
6517
- })
6518
- }),
6519
- translateLessonTool,
6520
- defineTool({
6521
- name: "study_export",
6522
- 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.",
6523
- parameters: { courseId: {
6524
- type: "string",
6525
- required: true,
6526
- description: "Course id to export."
6527
- } },
6528
- output: {
6529
- schema: {
6530
- type: "object",
6531
- additionalProperties: false,
6532
- properties: {
6533
- courseId: {
6534
- type: "string",
6535
- required: true
6536
- },
6537
- title: {
6538
- type: "string",
6539
- required: true
6540
- },
6541
- lessonCount: {
6542
- type: "integer",
6543
- required: true
6544
- },
6545
- chars: {
6546
- type: "integer",
6547
- required: true
6548
- },
6549
- markdown: {
6550
- type: "string",
6551
- required: true
6795
+ },
6796
+ counts: {
6797
+ type: "object",
6798
+ required: true,
6799
+ additionalProperties: false,
6800
+ properties: {
6801
+ friction: {
6802
+ type: "integer",
6803
+ required: true
6804
+ },
6805
+ practice: {
6806
+ type: "integer",
6807
+ required: true
6808
+ }
6552
6809
  }
6810
+ },
6811
+ watermark: {
6812
+ type: "string",
6813
+ required: true
6553
6814
  }
6554
- },
6555
- render: (_args, value) => [{
6556
- type: "text",
6557
- 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.
6558
-
6559
- ` + value.markdown
6560
- }]
6815
+ }
6561
6816
  },
6562
- execute(args) {
6563
- const course = findCourse(store.get(), args.courseId);
6564
- const markdown = courseToPackMarkdown(course);
6565
- const lessonCount = course.sections.reduce((n, sec) => n + sec.lessons.filter((l) => l.kind !== "exam").length, 0);
6817
+ render: (_args, value) => [{
6818
+ type: "text",
6819
+ 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.")
6820
+ }]
6821
+ },
6822
+ execute() {
6823
+ return mutate((state) => {
6824
+ const window = gatherConsolidationWindow(state);
6825
+ const watermark = (/* @__PURE__ */ new Date()).toISOString();
6826
+ state.lastConsolidatedAt = watermark;
6566
6827
  return {
6567
- courseId: course.id,
6568
- title: course.title,
6569
- lessonCount,
6570
- chars: markdown.length,
6571
- markdown
6828
+ since: window.since,
6829
+ entries: window.entries,
6830
+ counts: window.counts,
6831
+ watermark
6572
6832
  };
6573
- },
6574
- isConcurrencySafe: () => true,
6575
- presentCall: (args) => ({
6576
- card: "generic",
6577
- title: `Export course: ${args.courseId}`,
6578
- kind: "read"
6579
- })
6580
- }),
6581
- defineTool({
6582
- name: "study_note_save",
6583
- description: "Save an entry to the learner's Cornell notebook. Zones: `understand` (knowledge structures you generated — concept maps as mermaid, compare tables, diagrams; sediment your best structures here after showing them), `record` (the learner's own words — when they ask to take a note, or when they write something worth keeping, with the verbatim `quote`), `practice` (quiz log — normally written automatically by study_record_answer).",
6584
- parameters: {
6585
- lessonId: {
6586
- type: "string",
6587
- required: true,
6588
- description: "Lesson the note belongs to."
6589
- },
6590
- zone: {
6591
- type: "string",
6592
- required: true,
6593
- enum: [...NOTE_ZONES],
6594
- description: "understand | record | practice."
6595
- },
6596
- title: {
6597
- type: "string",
6598
- required: true,
6599
- description: "Short entry title."
6600
- },
6601
- text: {
6602
- type: "string",
6603
- required: true,
6604
- description: "Entry body — markdown for the understand zone."
6605
- },
6606
- source: {
6607
- type: "string",
6833
+ });
6834
+ },
6835
+ presentCall: () => ({
6836
+ card: "generic",
6837
+ title: "Consolidate learner memory"
6838
+ })
6839
+ });
6840
+ const exportTool = defineTool({
6841
+ name: "study_export",
6842
+ 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.",
6843
+ parameters: { courseId: {
6844
+ type: "string",
6845
+ required: true,
6846
+ description: "Course id to export."
6847
+ } },
6848
+ output: {
6849
+ schema: {
6850
+ type: "object",
6851
+ additionalProperties: false,
6852
+ properties: {
6853
+ courseId: {
6854
+ type: "string",
6855
+ required: true
6856
+ },
6857
+ title: {
6858
+ type: "string",
6859
+ required: true
6860
+ },
6861
+ lessonCount: {
6862
+ type: "integer",
6863
+ required: true
6864
+ },
6865
+ chars: {
6866
+ type: "integer",
6867
+ required: true
6868
+ },
6869
+ markdown: {
6870
+ type: "string",
6871
+ required: true
6872
+ }
6873
+ }
6874
+ },
6875
+ render: (_args, value) => [{
6876
+ type: "text",
6877
+ 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.
6878
+
6879
+ ` + value.markdown
6880
+ }]
6881
+ },
6882
+ execute(args) {
6883
+ const course = findCourse(store.get(), args.courseId);
6884
+ const markdown = courseToPackMarkdown(course);
6885
+ const lessonCount = course.sections.reduce((n, sec) => n + sec.lessons.filter((l) => l.kind !== "exam").length, 0);
6886
+ return {
6887
+ courseId: course.id,
6888
+ title: course.title,
6889
+ lessonCount,
6890
+ chars: markdown.length,
6891
+ markdown
6892
+ };
6893
+ },
6894
+ isConcurrencySafe: () => true,
6895
+ presentCall: (args) => ({
6896
+ card: "generic",
6897
+ title: `Export course: ${args.courseId}`,
6898
+ kind: "read"
6899
+ })
6900
+ });
6901
+ const noteSaveTool = defineTool({
6902
+ name: "study_note_save",
6903
+ description: "Save an entry to the learner's Cornell notebook. Zones: `understand` (knowledge structures you generated — concept maps as mermaid, compare tables, diagrams; sediment your best structures here after showing them), `record` (the learner's own words — when they ask to take a note, or when they write something worth keeping, with the verbatim `quote`), `practice` (quiz log — normally written automatically by study_record_answer).",
6904
+ parameters: {
6905
+ lessonId: {
6906
+ type: "string",
6907
+ required: true,
6908
+ description: "Lesson the note belongs to."
6909
+ },
6910
+ zone: {
6911
+ type: "string",
6912
+ required: true,
6913
+ enum: [...NOTE_ZONES],
6914
+ description: "understand | record | practice."
6915
+ },
6916
+ title: {
6917
+ type: "string",
6918
+ required: true,
6919
+ description: "Short entry title."
6920
+ },
6921
+ text: {
6922
+ type: "string",
6923
+ required: true,
6924
+ description: "Entry body — markdown for the understand zone."
6925
+ },
6926
+ source: {
6927
+ type: "string",
6928
+ required: true,
6929
+ enum: [...NOTE_SOURCES],
6930
+ description: "ai (you generated) | content (quoted from lesson) | chat (quoted from conversation)."
6931
+ },
6932
+ quote: {
6933
+ type: "string",
6934
+ description: "Verbatim source quote, for record-zone notes."
6935
+ }
6936
+ },
6937
+ output: {
6938
+ schema: {
6939
+ type: "object",
6940
+ additionalProperties: false,
6941
+ properties: {
6942
+ noteId: {
6943
+ type: "string",
6944
+ required: true
6945
+ },
6946
+ zone: {
6947
+ type: "string",
6948
+ required: true
6949
+ }
6950
+ }
6951
+ },
6952
+ render: (_args, value) => [{
6953
+ type: "text",
6954
+ text: `Saved ${value.zone}-zone note ${value.noteId}.`
6955
+ }]
6956
+ },
6957
+ async execute(args) {
6958
+ return mutate((state) => {
6959
+ const note = addNote(state, args.lessonId, args.zone, args.title, args.text, args.source, args.quote ?? null, /* @__PURE__ */ new Date());
6960
+ return {
6961
+ noteId: note.id,
6962
+ zone: note.zone
6963
+ };
6964
+ });
6965
+ },
6966
+ presentCall: (args) => ({
6967
+ card: "generic",
6968
+ title: `Save ${args.zone} note: ${args.title}`
6969
+ })
6970
+ });
6971
+ const notesTool = defineTool({
6972
+ name: "study_notes",
6973
+ description: "Read the learner's Cornell notebook: three zones per lesson (understand structures, learner records, practice log).",
6974
+ parameters: { lessonId: {
6975
+ type: "string",
6976
+ description: "One lesson's notes; omit for all lessons (most recent last)."
6977
+ } },
6978
+ output: {
6979
+ schema: {
6980
+ type: "object",
6981
+ additionalProperties: false,
6982
+ properties: {
6983
+ total: {
6984
+ type: "integer",
6985
+ required: true
6986
+ },
6987
+ notes: {
6988
+ type: "array",
6989
+ required: true,
6990
+ items: {
6991
+ type: "object",
6992
+ additionalProperties: false,
6993
+ properties: {
6994
+ id: {
6995
+ type: "string",
6996
+ required: true
6997
+ },
6998
+ lessonTitle: {
6999
+ type: "string",
7000
+ required: true
7001
+ },
7002
+ zone: {
7003
+ type: "string",
7004
+ required: true,
7005
+ enum: [...NOTE_ZONES]
7006
+ },
7007
+ title: {
7008
+ type: "string",
7009
+ required: true
7010
+ },
7011
+ text: {
7012
+ type: "string",
7013
+ required: true
7014
+ },
7015
+ source: {
7016
+ type: "string",
7017
+ required: true,
7018
+ enum: [...NOTE_SOURCES]
7019
+ },
7020
+ quote: {
7021
+ ...nullableString,
7022
+ required: true
7023
+ }
7024
+ }
7025
+ }
7026
+ }
7027
+ }
7028
+ },
7029
+ render: (_args, value) => [{
7030
+ type: "text",
7031
+ text: value.total === 0 ? "Notebook is empty." : value.notes.map((n) => `[${n.zone}] ${n.lessonTitle} — ${n.title}${n.quote === null ? "" : ` (quote: “${n.quote.slice(0, 60)}”)`}`).join("\n")
7032
+ }]
7033
+ },
7034
+ async execute(args) {
7035
+ const state = store.get();
7036
+ const notes = (args.lessonId === void 0 ? state.courses.flatMap((c) => c.sections.flatMap((s) => s.lessons)) : [findLesson(state, args.lessonId).lesson]).flatMap((l) => l.notes.map((n) => ({
7037
+ id: n.id,
7038
+ lessonTitle: l.title,
7039
+ zone: n.zone,
7040
+ title: n.title,
7041
+ text: n.text,
7042
+ source: n.source,
7043
+ quote: n.quote
7044
+ })));
7045
+ return {
7046
+ total: notes.length,
7047
+ notes
7048
+ };
7049
+ },
7050
+ isConcurrencySafe: () => true,
7051
+ presentCall: () => ({
7052
+ card: "generic",
7053
+ title: "Read notebook",
7054
+ kind: "read"
7055
+ })
7056
+ });
7057
+ const setModeTool = defineTool({
7058
+ name: "study_set_mode",
7059
+ description: "Switch the tutoring soul when the learner asks for a different style: `direct` 精讲 (explain first, then verify), `guide` 引导 (questions first, hand over steps), `practice` 实战 (learn inside real, messy problems). Takes effect from the next reply.",
7060
+ parameters: { mode: {
7061
+ type: "string",
7062
+ required: true,
7063
+ enum: [...MODES],
7064
+ description: "direct | guide | practice."
7065
+ } },
7066
+ output: {
7067
+ schema: {
7068
+ type: "object",
7069
+ additionalProperties: false,
7070
+ properties: { mode: {
7071
+ type: "string",
7072
+ required: true,
7073
+ enum: [...MODES]
7074
+ } }
7075
+ },
7076
+ render: (_args, value) => [{
7077
+ type: "text",
7078
+ text: `Tutoring soul switched to ${value.mode} (effective next reply).`
7079
+ }]
7080
+ },
7081
+ async execute(args) {
7082
+ return mutate((state) => {
7083
+ state.mode = args.mode;
7084
+ return { mode: state.mode };
7085
+ });
7086
+ },
7087
+ presentCall: (args) => ({
7088
+ card: "generic",
7089
+ title: `Switch soul: ${args.mode}`
7090
+ })
7091
+ });
7092
+ const generateQuizTool = defineTool({
7093
+ name: "study_generate_quiz",
7094
+ description: "Generate an interactive practice card (quiz artifact) for the focus lesson: 3-4 questions (5 max), each with 2-6 options, the 0-based correct `answer` index, and an `explanation` of why it is right. The learner answers on the card itself (locally judged, progress kept); when they finish, a summary hook arrives in the conversation — acknowledge it, address wrong answers, do NOT re-grade these through study_record_answer. Use for practice blocks and review consolidation; single conversational questions stay as prose A-D options.",
7095
+ parameters: {
7096
+ lessonId: {
7097
+ type: "string",
7098
+ required: true,
7099
+ description: "Lesson the practice card belongs to."
7100
+ },
7101
+ title: {
7102
+ type: "string",
7103
+ description: "Card title (defaults to 练习)."
7104
+ },
7105
+ questions: {
7106
+ type: "array",
7107
+ required: true,
7108
+ description: "The questions, in answering order.",
7109
+ items: {
7110
+ type: "object",
7111
+ additionalProperties: false,
7112
+ properties: {
7113
+ prompt: {
7114
+ type: "string",
7115
+ required: true
7116
+ },
7117
+ options: {
7118
+ type: "array",
7119
+ required: true,
7120
+ items: { type: "string" }
7121
+ },
7122
+ answer: {
7123
+ type: "integer",
7124
+ required: true,
7125
+ description: "0-based index into options."
7126
+ },
7127
+ explanation: {
7128
+ type: "string",
7129
+ required: true,
7130
+ description: "Why the right answer is right."
7131
+ }
7132
+ }
7133
+ }
7134
+ }
7135
+ },
7136
+ output: {
7137
+ schema: {
7138
+ type: "object",
7139
+ additionalProperties: false,
7140
+ properties: {
7141
+ artifactType: {
7142
+ type: "string",
7143
+ required: true,
7144
+ enum: ["quiz"]
7145
+ },
7146
+ artifactId: {
7147
+ type: "string",
7148
+ required: true
7149
+ },
7150
+ created: {
7151
+ type: "boolean",
7152
+ required: true
7153
+ },
7154
+ title: {
7155
+ type: "string",
7156
+ required: true
7157
+ },
7158
+ questions: {
7159
+ type: "array",
7160
+ required: true,
7161
+ items: {
7162
+ type: "object",
7163
+ additionalProperties: false,
7164
+ properties: {
7165
+ prompt: {
7166
+ type: "string",
7167
+ required: true
7168
+ },
7169
+ options: {
7170
+ type: "array",
7171
+ required: true,
7172
+ items: { type: "string" }
7173
+ },
7174
+ answer: {
7175
+ type: "integer",
7176
+ required: true
7177
+ },
7178
+ explanation: {
7179
+ type: "string",
7180
+ required: true
7181
+ }
7182
+ }
7183
+ }
7184
+ },
7185
+ warnings: {
7186
+ type: "array",
7187
+ items: { type: "string" }
7188
+ }
7189
+ }
7190
+ },
7191
+ render: (_args, value) => [{
7192
+ type: "text",
7193
+ text: `Practice card (${value.questions.length} questions): ${value.title}${value.created ? "" : " (already recorded)"}${value.warnings === void 0 || value.warnings.length === 0 ? "" : ` — warnings: ${value.warnings.join("; ")}`}`
7194
+ }]
7195
+ },
7196
+ async execute(args) {
7197
+ const sanitized = sanitizeQuiz(args);
7198
+ return mutate((state) => {
7199
+ const id = artifactId("quiz", sanitized.data);
7200
+ const artifact = {
7201
+ id,
7202
+ artifactType: "quiz",
7203
+ title: typeof sanitized.data.title === "string" ? sanitized.data.title : "练习",
7204
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7205
+ hash: id.slice(5),
7206
+ data: sanitized.data
7207
+ };
7208
+ const result = recordArtifact(state, args.lessonId, artifact);
7209
+ return {
7210
+ artifactType: "quiz",
7211
+ artifactId: result.artifact.id,
7212
+ created: result.created,
7213
+ title: result.artifact.title,
7214
+ questions: result.artifact.data.questions ?? [],
7215
+ warnings: result.artifact.data.warnings ?? []
7216
+ };
7217
+ });
7218
+ },
7219
+ presentCall: (args) => ({
7220
+ card: "generic",
7221
+ title: `Generate practice card: ${typeof args.title === "string" ? args.title : "练习"} (${args.questions?.length ?? 0} questions)`
7222
+ })
7223
+ });
7224
+ /** Record one sanitized artifact on its lesson (shared by the artifact tools). */
7225
+ const recordSanitized = (lessonId, result, type) => mutate((state) => {
7226
+ const id = artifactId(type, result.data);
7227
+ const stored = recordArtifact(state, lessonId, {
7228
+ id,
7229
+ artifactType: type,
7230
+ title: typeof result.data.title === "string" ? result.data.title : type,
7231
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7232
+ hash: id.slice(type.length + 1),
7233
+ data: result.data
7234
+ });
7235
+ return {
7236
+ artifactType: type,
7237
+ artifactId: stored.artifact.id,
7238
+ created: stored.created,
7239
+ ...stored.artifact.data
7240
+ };
7241
+ });
7242
+ return [
7243
+ importMarkdown,
7244
+ importFolder,
7245
+ importGithub,
7246
+ importUrl,
7247
+ applyDesign,
7248
+ listCourses,
7249
+ courseMap,
7250
+ lessonContent,
7251
+ recordAnswerTool,
7252
+ examResultTool,
7253
+ completeLessonTool,
7254
+ dueReviewsTool,
7255
+ recordReviewTool,
7256
+ deleteCourseTool,
7257
+ defineConceptsTool,
7258
+ proposeMasteryTool,
7259
+ resolveProposalTool,
7260
+ reportFrictionTool,
7261
+ rememberTool,
7262
+ consolidateTool,
7263
+ translateLessonTool,
7264
+ exportTool,
7265
+ noteSaveTool,
7266
+ notesTool,
7267
+ generateQuizTool,
7268
+ defineTool({
7269
+ name: "study_pose_guess",
7270
+ description: "Pose the opening two-option guess (the curiosity hook): one or two sentences of prose first (counter-intuitive, everyday-related), then this tool with exactly 2 short options. The learner picks one; you reveal the answer NEXT turn and teach the lesson's core point. Iron rules: unscored, never touches mastery, never say 答对/答错 — this is a hook, not a quiz.",
7271
+ parameters: {
7272
+ lessonId: {
7273
+ type: "string",
6608
7274
  required: true,
6609
- enum: [...NOTE_SOURCES],
6610
- description: "ai (you generated) | content (quoted from lesson) | chat (quoted from conversation)."
7275
+ description: "Lesson the guess belongs to."
6611
7276
  },
6612
- quote: {
7277
+ prompt: {
6613
7278
  type: "string",
6614
- description: "Verbatim source quote, for record-zone notes."
7279
+ required: true,
7280
+ description: "e.g. 你觉得:递归算阶乘会比循环——更慢,还是差不多?"
7281
+ },
7282
+ options: {
7283
+ type: "array",
7284
+ required: true,
7285
+ description: "Exactly 2 options.",
7286
+ items: {
7287
+ type: "object",
7288
+ additionalProperties: false,
7289
+ properties: {
7290
+ id: {
7291
+ type: "string",
7292
+ required: true
7293
+ },
7294
+ label: {
7295
+ type: "string",
7296
+ required: true
7297
+ }
7298
+ }
7299
+ }
6615
7300
  }
6616
7301
  },
6617
7302
  output: {
@@ -6619,156 +7304,331 @@ function studyTools(store, deps = {}) {
6619
7304
  type: "object",
6620
7305
  additionalProperties: false,
6621
7306
  properties: {
6622
- noteId: {
7307
+ artifactType: {
7308
+ type: "string",
7309
+ required: true,
7310
+ enum: ["guess"]
7311
+ },
7312
+ artifactId: {
6623
7313
  type: "string",
6624
7314
  required: true
6625
7315
  },
6626
- zone: {
7316
+ created: {
7317
+ type: "boolean",
7318
+ required: true
7319
+ },
7320
+ prompt: {
6627
7321
  type: "string",
6628
7322
  required: true
7323
+ },
7324
+ options: {
7325
+ type: "array",
7326
+ required: true,
7327
+ items: {
7328
+ type: "object",
7329
+ additionalProperties: false,
7330
+ properties: {
7331
+ id: {
7332
+ type: "string",
7333
+ required: true
7334
+ },
7335
+ label: {
7336
+ type: "string",
7337
+ required: true
7338
+ }
7339
+ }
7340
+ }
6629
7341
  }
6630
7342
  }
6631
7343
  },
6632
7344
  render: (_args, value) => [{
6633
7345
  type: "text",
6634
- text: `Saved ${value.zone}-zone note ${value.noteId}.`
7346
+ text: `Guess posed: ${value.prompt}`
6635
7347
  }]
6636
7348
  },
6637
7349
  async execute(args) {
6638
- return mutate((state) => {
6639
- const note = addNote(state, args.lessonId, args.zone, args.title, args.text, args.source, args.quote ?? null, /* @__PURE__ */ new Date());
6640
- return {
6641
- noteId: note.id,
6642
- zone: note.zone
6643
- };
6644
- });
7350
+ return recordSanitized(args.lessonId, sanitizeGuess(args), "guess");
6645
7351
  },
6646
7352
  presentCall: (args) => ({
6647
7353
  card: "generic",
6648
- title: `Save ${args.zone} note: ${args.title}`
7354
+ title: `Pose guess: ${typeof args.prompt === "string" ? args.prompt.slice(0, 40) : ""}`
6649
7355
  })
6650
7356
  }),
6651
7357
  defineTool({
6652
- name: "study_notes",
6653
- description: "Read the learner's Cornell notebook: three zones per lesson (understand structures, learner records, practice log).",
6654
- parameters: { lessonId: {
6655
- type: "string",
6656
- description: "One lesson's notes; omit for all lessons (most recent last)."
6657
- } },
7358
+ name: "study_compare_table",
7359
+ description: "Generate a compare table for two or more concepts/solutions/technologies — when the learner asks A B 有什么区别 or a horizontal comparison helps. Rendered as a table artifact card.",
7360
+ parameters: {
7361
+ lessonId: {
7362
+ type: "string",
7363
+ required: true,
7364
+ description: "Lesson the table belongs to."
7365
+ },
7366
+ title: {
7367
+ type: "string",
7368
+ required: true,
7369
+ description: "e.g. SQL vs NoSQL"
7370
+ },
7371
+ headers: {
7372
+ type: "array",
7373
+ required: true,
7374
+ items: { type: "string" },
7375
+ description: "Column names (first is usually the dimension)."
7376
+ },
7377
+ rows: {
7378
+ type: "array",
7379
+ required: true,
7380
+ items: {
7381
+ type: "array",
7382
+ items: { type: "string" }
7383
+ },
7384
+ description: "Rows; each row has headers.length cells."
7385
+ }
7386
+ },
6658
7387
  output: {
6659
7388
  schema: {
6660
7389
  type: "object",
6661
7390
  additionalProperties: false,
6662
7391
  properties: {
6663
- total: {
6664
- type: "integer",
7392
+ artifactType: {
7393
+ type: "string",
7394
+ required: true,
7395
+ enum: ["compare_table"]
7396
+ },
7397
+ artifactId: {
7398
+ type: "string",
7399
+ required: true
7400
+ },
7401
+ created: {
7402
+ type: "boolean",
7403
+ required: true
7404
+ },
7405
+ title: {
7406
+ type: "string",
6665
7407
  required: true
6666
7408
  },
6667
- notes: {
7409
+ headers: {
7410
+ type: "array",
7411
+ required: true,
7412
+ items: { type: "string" }
7413
+ },
7414
+ rows: {
6668
7415
  type: "array",
6669
7416
  required: true,
6670
7417
  items: {
6671
- type: "object",
6672
- additionalProperties: false,
6673
- properties: {
6674
- id: {
6675
- type: "string",
6676
- required: true
6677
- },
6678
- lessonTitle: {
6679
- type: "string",
6680
- required: true
6681
- },
6682
- zone: {
6683
- type: "string",
6684
- required: true,
6685
- enum: [...NOTE_ZONES]
6686
- },
6687
- title: {
6688
- type: "string",
6689
- required: true
6690
- },
6691
- text: {
6692
- type: "string",
6693
- required: true
6694
- },
6695
- source: {
6696
- type: "string",
6697
- required: true,
6698
- enum: [...NOTE_SOURCES]
6699
- },
6700
- quote: {
6701
- ...nullableString,
6702
- required: true
6703
- }
6704
- }
7418
+ type: "array",
7419
+ items: { type: "string" }
6705
7420
  }
7421
+ },
7422
+ warnings: {
7423
+ type: "array",
7424
+ items: { type: "string" }
6706
7425
  }
6707
7426
  }
6708
7427
  },
6709
7428
  render: (_args, value) => [{
6710
7429
  type: "text",
6711
- text: value.total === 0 ? "Notebook is empty." : value.notes.map((n) => `[${n.zone}] ${n.lessonTitle} — ${n.title}${n.quote === null ? "" : ` (quote: “${n.quote.slice(0, 60)}”)`}`).join("\n")
7430
+ text: `Compare table: ${value.title} (${value.rows.length} rows)`
6712
7431
  }]
6713
7432
  },
6714
7433
  async execute(args) {
6715
- const state = store.get();
6716
- const notes = (args.lessonId === void 0 ? state.courses.flatMap((c) => c.sections.flatMap((s) => s.lessons)) : [findLesson(state, args.lessonId).lesson]).flatMap((l) => l.notes.map((n) => ({
6717
- id: n.id,
6718
- lessonTitle: l.title,
6719
- zone: n.zone,
6720
- title: n.title,
6721
- text: n.text,
6722
- source: n.source,
6723
- quote: n.quote
6724
- })));
6725
- return {
6726
- total: notes.length,
6727
- notes
6728
- };
7434
+ return recordSanitized(args.lessonId, sanitizeCompareTable(args), "compare_table");
6729
7435
  },
6730
- isConcurrencySafe: () => true,
6731
- presentCall: () => ({
7436
+ presentCall: (args) => ({
6732
7437
  card: "generic",
6733
- title: "Read notebook",
6734
- kind: "read"
7438
+ title: `Compare table: ${typeof args.title === "string" ? args.title : ""}`
6735
7439
  })
6736
7440
  }),
6737
7441
  defineTool({
6738
- name: "study_set_mode",
6739
- description: "Switch the tutoring soul when the learner asks for a different style: `direct` 精讲 (explain first, then verify), `guide` 引导 (questions first, hand over steps), `practice` 实战 (learn inside real, messy problems). Takes effect from the next reply.",
6740
- parameters: { mode: {
6741
- type: "string",
6742
- required: true,
6743
- enum: [...MODES],
6744
- description: "direct | guide | practice."
6745
- } },
7442
+ name: "study_draw_diagram",
7443
+ description: "Draw a structured mermaid diagram: steps/decisions/causality flowchart TD (or LR for short chains); multi-party interactions sequenceDiagram; states/transitions stateDiagram-v2. Return valid mermaid syntax only (no outer fences). Rendered as a diagram artifact card.",
7444
+ parameters: {
7445
+ lessonId: {
7446
+ type: "string",
7447
+ required: true,
7448
+ description: "Lesson the diagram belongs to."
7449
+ },
7450
+ title: {
7451
+ type: "string",
7452
+ required: true
7453
+ },
7454
+ diagramType: {
7455
+ type: "string",
7456
+ required: true,
7457
+ enum: [
7458
+ "flowchart",
7459
+ "sequence",
7460
+ "state"
7461
+ ]
7462
+ },
7463
+ mermaid: {
7464
+ type: "string",
7465
+ required: true,
7466
+ description: "Mermaid code without fences."
7467
+ }
7468
+ },
6746
7469
  output: {
6747
7470
  schema: {
6748
7471
  type: "object",
6749
7472
  additionalProperties: false,
6750
- properties: { mode: {
6751
- type: "string",
6752
- required: true,
6753
- enum: [...MODES]
6754
- } }
7473
+ properties: {
7474
+ artifactType: {
7475
+ type: "string",
7476
+ required: true,
7477
+ enum: ["diagram"]
7478
+ },
7479
+ artifactId: {
7480
+ type: "string",
7481
+ required: true
7482
+ },
7483
+ created: {
7484
+ type: "boolean",
7485
+ required: true
7486
+ },
7487
+ title: {
7488
+ type: "string",
7489
+ required: true
7490
+ },
7491
+ diagramType: {
7492
+ type: "string",
7493
+ required: true
7494
+ },
7495
+ mermaid: {
7496
+ type: "string",
7497
+ required: true
7498
+ }
7499
+ }
6755
7500
  },
6756
7501
  render: (_args, value) => [{
6757
7502
  type: "text",
6758
- text: `Tutoring soul switched to ${value.mode} (effective next reply).`
7503
+ text: `Diagram: ${value.title} (${value.diagramType})`
6759
7504
  }]
6760
7505
  },
6761
7506
  async execute(args) {
6762
- return mutate((state) => {
6763
- state.mode = args.mode;
6764
- return { mode: state.mode };
6765
- });
7507
+ return recordSanitized(args.lessonId, sanitizeDiagram(args), "diagram");
6766
7508
  },
6767
7509
  presentCall: (args) => ({
6768
7510
  card: "generic",
6769
- title: `Switch soul: ${args.mode}`
7511
+ title: `Draw diagram: ${typeof args.title === "string" ? args.title : ""}`
6770
7512
  })
6771
- })
7513
+ }),
7514
+ defineTool({
7515
+ name: "study_code_walkthrough",
7516
+ description: "Walk through a piece of code line-by-line / segment-by-segment — when the learner asks what the code means or the lesson contains code needing teardown. Rendered with line numbers + per-segment notes.",
7517
+ parameters: {
7518
+ lessonId: {
7519
+ type: "string",
7520
+ required: true,
7521
+ description: "Lesson the walkthrough belongs to."
7522
+ },
7523
+ title: {
7524
+ type: "string",
7525
+ required: true
7526
+ },
7527
+ language: {
7528
+ type: "string",
7529
+ required: true,
7530
+ description: "e.g. typescript / python"
7531
+ },
7532
+ code: {
7533
+ type: "string",
7534
+ required: true
7535
+ },
7536
+ annotations: {
7537
+ type: "array",
7538
+ required: true,
7539
+ description: "Per-segment notes.",
7540
+ items: {
7541
+ type: "object",
7542
+ additionalProperties: false,
7543
+ properties: {
7544
+ lineStart: {
7545
+ type: "integer",
7546
+ required: true
7547
+ },
7548
+ lineEnd: {
7549
+ type: "integer",
7550
+ required: true
7551
+ },
7552
+ note: {
7553
+ type: "string",
7554
+ required: true
7555
+ }
7556
+ }
7557
+ }
7558
+ }
7559
+ },
7560
+ output: {
7561
+ schema: {
7562
+ type: "object",
7563
+ additionalProperties: false,
7564
+ properties: {
7565
+ artifactType: {
7566
+ type: "string",
7567
+ required: true,
7568
+ enum: ["code_walkthrough"]
7569
+ },
7570
+ artifactId: {
7571
+ type: "string",
7572
+ required: true
7573
+ },
7574
+ created: {
7575
+ type: "boolean",
7576
+ required: true
7577
+ },
7578
+ title: {
7579
+ type: "string",
7580
+ required: true
7581
+ },
7582
+ language: {
7583
+ type: "string",
7584
+ required: true
7585
+ },
7586
+ code: {
7587
+ type: "string",
7588
+ required: true
7589
+ },
7590
+ annotations: {
7591
+ type: "array",
7592
+ required: true,
7593
+ items: {
7594
+ type: "object",
7595
+ additionalProperties: false,
7596
+ properties: {
7597
+ lineStart: {
7598
+ type: "integer",
7599
+ required: true
7600
+ },
7601
+ lineEnd: {
7602
+ type: "integer",
7603
+ required: true
7604
+ },
7605
+ note: {
7606
+ type: "string",
7607
+ required: true
7608
+ }
7609
+ }
7610
+ }
7611
+ },
7612
+ warnings: {
7613
+ type: "array",
7614
+ items: { type: "string" }
7615
+ }
7616
+ }
7617
+ },
7618
+ render: (_args, value) => [{
7619
+ type: "text",
7620
+ text: `Code walkthrough: ${value.title} (${value.annotations.length} segments)`
7621
+ }]
7622
+ },
7623
+ async execute(args) {
7624
+ return recordSanitized(args.lessonId, sanitizeCodeWalkthrough(args), "code_walkthrough");
7625
+ },
7626
+ presentCall: (args) => ({
7627
+ card: "generic",
7628
+ title: `Code walkthrough: ${typeof args.title === "string" ? args.title : ""}`
7629
+ })
7630
+ }),
7631
+ setModeTool
6772
7632
  ];
6773
7633
  }
6774
7634
  //#endregion
@@ -6816,6 +7676,12 @@ Never reveal the friction log or mastery mechanics as "being watched" — the nu
6816
7676
  Course import design: when study_import_github or study_import_folder returns status "design_required", it renders a design brief (files with heading outlines and per-heading char counts). Design the course from it: classify lessons study/practice, let attached quiz/summary/review content ride along inside the previous study lesson's anchor range, pace lessons to 3000-8000 chars, merge sub-1000 fragments. Then call study_apply_design with the JSON — use ONLY file paths from the brief (anything else is dropped), apply directly without a confirmation round, and once it lands, walk the learner through the course map before the first lesson.
6817
7677
 
6818
7678
  Quiz quality: 3–4 questions per quiz block is best (5 max), 4 options each. Distractors must come from real misconceptions, not absurd fillers. Test understanding, not recall: "in scenario Y, use X or Z?" rather than "define X". Every question carries an explanation of why the right answer is right. One scored block at a time.
7679
+ Artifact surfaces beyond the practice card (each renders as an interactive card and settles into the notebook):
7680
+ - Opening a brand-new lesson: study_pose_guess — one or two prose sentences of hook, then exactly 2 short options; reveal the answer next turn (unscored, never 答对/答错).
7681
+ - The learner asks A 和 B 有什么区别 or a comparison clarifies: study_compare_table.
7682
+ - Structure/flow/interaction/state needs a picture: study_draw_diagram (flowchart TD / sequenceDiagram / stateDiagram-v2 — valid mermaid only).
7683
+ - The lesson contains code needing teardown, or the learner asks what code means: study_code_walkthrough (line ranges + notes).
7684
+ Practice cards: for multi-question practice blocks (3-4 questions, review consolidation, learner asks to 练一练/出一组题), call study_generate_quiz — the learner answers on the interactive card (locally judged, progress kept). When the completion hook arrives ("练习卡完成:…"), acknowledge briefly and address wrong answers; do NOT re-grade card answers through study_record_answer. Single conversational questions stay as prose options.
6819
7685
  Quiz option FORMAT (the study panel parses this to make options clickable): each option on its own plain line, exactly "A. <text>", "B. <text>", "C. <text>", "D. <text>" — letters consecutive from A; never tables, never nested lists, never bold/code wrapping the letter.
6820
7686
 
6821
7687
  ### 【Answer formatting · preferences】