dsh-plugin-lookatstudy 0.20.1 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -201,6 +201,9 @@ const ZH = {
201
201
  "note.retryRender": "重试渲染",
202
202
  "zoom.in": "放大面板字号",
203
203
  "zoom.out": "缩小面板字号",
204
+ "pane.rail.aria": "调整课程栏宽度",
205
+ "pane.chat.aria": "调整对话栏宽度",
206
+ "pane.tip": "拖拽调宽,双击重置",
204
207
  "palette.label": "命令面板",
205
208
  "palette.placeholder": "搜索课时/课程,或选择动作…",
206
209
  "palette.lessons": "课时",
@@ -226,6 +229,7 @@ const ZH = {
226
229
  "settings.budget.on": "开启历史预算",
227
230
  "settings.budget.off": "关闭历史预算",
228
231
  "attach.sent": "我发了一个文件:{name}(工作区路径 {path})——请读取它再继续。",
232
+ "attach.remove": "移除待发附件",
229
233
  "attach.fail": "附件上传失败",
230
234
  "attach.label": "附件(≤20MiB,存入学习工作区)",
231
235
  "rail.mastered": "毕业 {mastered}/{total} 课",
@@ -544,6 +548,9 @@ const EN = {
544
548
  "note.retryRender": "Retry render",
545
549
  "zoom.in": "Increase panel font size",
546
550
  "zoom.out": "Decrease panel font size",
551
+ "pane.rail.aria": "Resize the course pane width",
552
+ "pane.chat.aria": "Resize the chat pane width",
553
+ "pane.tip": "Drag to resize, double-click to reset",
547
554
  "palette.label": "Command palette",
548
555
  "palette.placeholder": "Search lessons/courses or pick an action…",
549
556
  "palette.lessons": "Lesson",
@@ -569,6 +576,7 @@ const EN = {
569
576
  "settings.budget.on": "Enable history budget",
570
577
  "settings.budget.off": "Disable history budget",
571
578
  "attach.sent": "I attached a file: {name} (workspace path {path}) — read it before continuing.",
579
+ "attach.remove": "Remove pending attachment",
572
580
  "attach.fail": "Attachment upload failed",
573
581
  "attach.label": "Attach (≤20MiB, lands in the study workspace)",
574
582
  "rail.mastered": "{mastered}/{total} lessons graduated",
@@ -772,10 +780,13 @@ function escapeHtml(text) {
772
780
  }
773
781
  /** Render inline markup (code, bold, italic, links, images) over escaped text.
774
782
  * Image src allowlist: https?:// or data:image/ — folder imports inline local
775
- * images as data URLs (capped), GitHub imports reference jsDelivr; anything
776
- * else stays literal text (no broken relative images, no exotic schemes). */
783
+ * images as data URLs (capped), GitHub imports reference jsDelivr; the
784
+ * plugin's own attachment route (/lookatstudy/api/attachment/<name>, issue #7
785
+ * — same-origin, server-side validated) renders workspace attachments;
786
+ * anything else stays literal text (no broken relative images, no exotic
787
+ * schemes). */
777
788
  function inline(escaped) {
778
- 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>");
789
+ return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+|data:image\/[^)\s]+|\/lookatstudy\/api\/attachment\/[^)\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>");
779
790
  }
780
791
  /** Interleave one body with its translation, paragraph by paragraph (upstream
781
792
  * translation-layout "microsoft" semantics: original paragraph then its
@@ -1485,18 +1496,23 @@ function freshLesson(title, anchor, body, kind = "study") {
1485
1496
  * @param parsed - course tree from an importer.
1486
1497
  * @param source - import origin.
1487
1498
  * @param sourceRef - markdown/folder/repo reference for display.
1499
+ * @param languageTarget - the taught language (BCP-47) when the course IS a
1500
+ * language course (design-apply path only; markdown imports stay undefined).
1488
1501
  * @returns the imported (or pre-existing) course.
1489
1502
  */
1490
- function importCourse(state, parsed, source, sourceRef) {
1491
- const id = slugify(parsed.title);
1492
- const existing = state.courses.find((c) => c.id === id);
1493
- if (existing) return existing;
1503
+ function importCourse(state, parsed, source, sourceRef, languageTarget) {
1504
+ const base = slugify(parsed.title);
1505
+ const sameTitle = state.courses.find((c) => c.id === base && c.title === parsed.title);
1506
+ if (sameTitle !== void 0) return sameTitle;
1507
+ let id = base;
1508
+ for (let n = 2; state.courses.some((c) => c.id === id); n++) id = `${base}-${String(n)}`;
1494
1509
  const course = {
1495
1510
  id,
1496
1511
  title: parsed.title,
1497
1512
  source,
1498
1513
  sourceRef,
1499
1514
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1515
+ languageTarget: languageTarget ?? null,
1500
1516
  sections: parsed.sections.map((section) => {
1501
1517
  const lessons = section.lessons.map((lesson) => (() => {
1502
1518
  const st = freshLesson(lesson.title, lesson.anchor, lesson.body, lesson.world === "practice" ? "practice" : "study");
@@ -3179,6 +3195,36 @@ function registerDashboard(webServer, deps) {
3179
3195
  }
3180
3196
  return;
3181
3197
  }
3198
+ if (req.method === "GET" && pathname.startsWith("/lookatstudy/api/attachment/")) {
3199
+ let name = "";
3200
+ try {
3201
+ name = decodeURIComponent(pathname.slice(28));
3202
+ } catch {
3203
+ name = "";
3204
+ }
3205
+ if (name === "" || name.includes("..") || /[\\/]/.test(name) || name.length > 250) {
3206
+ sendJson(res, 404, {
3207
+ ok: false,
3208
+ error: "not found"
3209
+ });
3210
+ return;
3211
+ }
3212
+ try {
3213
+ const buf = readFileSync(join(deps.studyAreaPath, "attachments", name));
3214
+ const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
3215
+ const mime = ext === "png" ? "image/png" : ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : ext === "bmp" ? "image/bmp" : "application/octet-stream";
3216
+ res.writeHead(200, {
3217
+ "content-type": mime,
3218
+ "cache-control": "public, max-age=31536000, immutable"
3219
+ }).end(buf);
3220
+ } catch {
3221
+ sendJson(res, 404, {
3222
+ ok: false,
3223
+ error: "not found"
3224
+ });
3225
+ }
3226
+ return;
3227
+ }
3182
3228
  sendJson(res, 404, {
3183
3229
  ok: false,
3184
3230
  error: "not found"
@@ -5045,8 +5091,13 @@ function renderDesignBrief(pending, part = 1) {
5045
5091
  lines.push("- if the directory layout is already clear (e.g. lessons/N-Topic/), keep its sections; do not over-reorganize.");
5046
5092
  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." : ""}`);
5047
5093
  lines.push("");
5094
+ lines.push("### Language-course check (decide while designing)");
5095
+ lines.push("Is this course teaching a language ITSELF (vocabulary, grammar, reading/writing/listening of English/Japanese/Korean/Chinese/…), rather than a knowledge/tech course that merely happens to be written in some language? Typical tells: vocab lists, grammar-point explanations, dialogues/readings, sentence-translation pairs, level markers (N5/N1, TOEFL/IELTS, HSK, JLPT), pronunciation content.");
5096
+ lines.push("- Yes → add \"languageTarget\": \"<BCP-47 of the TAUGHT language>\" (teaching English → \"en\", Japanese → \"ja\"). The writing language is NOT the taught language — a Chinese-written English-textbook repo has languageTarget \"en\".");
5097
+ lines.push("- No, or unsure → omit it entirely (a normal course misflagged as a language course is worse than the reverse).");
5098
+ lines.push("");
5048
5099
  lines.push("Now design the course and call study_apply_design with:");
5049
- lines.push("{ \"sections\": [ { \"title\": \"...\", \"lessons\": [ { \"title\": \"...\", \"file\": \"<exact path from this brief>\", \"anchor\": \"<optional full heading text>\", \"world\": \"study\" | \"practice\" } ] } ] }");
5100
+ lines.push("{ \"languageTarget\": \"<only for language courses>\", \"sections\": [ { \"title\": \"...\", \"lessons\": [ { \"title\": \"...\", \"file\": \"<exact path from this brief>\", \"anchor\": \"<optional full heading text>\", \"world\": \"study\" | \"practice\" } ] } ] }");
5050
5101
  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.");
5051
5102
  return lines.join("\n");
5052
5103
  }
@@ -6126,50 +6177,56 @@ function studyTools(store, deps = {}) {
6126
6177
  const applyDesign = defineTool({
6127
6178
  name: "study_apply_design",
6128
6179
  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.",
6129
- parameters: { sections: {
6130
- type: "array",
6131
- required: true,
6132
- description: "Designed sections in learning order.",
6133
- items: {
6134
- type: "object",
6135
- additionalProperties: false,
6136
- properties: {
6137
- title: {
6138
- type: "string",
6139
- required: true,
6140
- description: "Section title (in the learner's language)."
6141
- },
6142
- lessons: {
6143
- type: "array",
6144
- required: true,
6145
- items: {
6146
- type: "object",
6147
- additionalProperties: false,
6148
- properties: {
6149
- title: {
6150
- type: "string",
6151
- required: true,
6152
- description: "Lesson title."
6153
- },
6154
- file: {
6155
- type: "string",
6156
- required: true,
6157
- description: "Exact file path from the design brief."
6158
- },
6159
- anchor: {
6160
- type: "string",
6161
- description: "Full H2/H3 heading text the lesson body starts at; omit for whole-file lessons."
6162
- },
6163
- world: {
6164
- type: "string",
6165
- description: "\"study\" (explanation) or \"practice\" (exercise/lab/notebook); anything else is treated as study."
6180
+ parameters: {
6181
+ languageTarget: {
6182
+ type: "string",
6183
+ description: "Set ONLY when this course teaches a language ITSELF (vocabulary, grammar, reading/writing of English/Japanese/Chinese/…) rather than being a knowledge/tech course that merely happens to be written in some language: the taught language as a BCP-47 tag (\"en\", \"ja\", \"zh-CN\", …). The writing language is NOT the taught language — a Chinese-written English-textbook repo has languageTarget \"en\". Typical tells: vocab lists, grammar points, dialogues/readings, sentence-translation pairs, level markers (N5/N1, TOEFL, HSK, JLPT), pronunciation content. When unsure, omit it — a normal course misflagged as a language course is worse than the reverse."
6184
+ },
6185
+ sections: {
6186
+ type: "array",
6187
+ required: true,
6188
+ description: "Designed sections in learning order.",
6189
+ items: {
6190
+ type: "object",
6191
+ additionalProperties: false,
6192
+ properties: {
6193
+ title: {
6194
+ type: "string",
6195
+ required: true,
6196
+ description: "Section title (in the learner's language)."
6197
+ },
6198
+ lessons: {
6199
+ type: "array",
6200
+ required: true,
6201
+ items: {
6202
+ type: "object",
6203
+ additionalProperties: false,
6204
+ properties: {
6205
+ title: {
6206
+ type: "string",
6207
+ required: true,
6208
+ description: "Lesson title."
6209
+ },
6210
+ file: {
6211
+ type: "string",
6212
+ required: true,
6213
+ description: "Exact file path from the design brief."
6214
+ },
6215
+ anchor: {
6216
+ type: "string",
6217
+ description: "Full H2/H3 heading text the lesson body starts at; omit for whole-file lessons."
6218
+ },
6219
+ world: {
6220
+ type: "string",
6221
+ description: "\"study\" (explanation) or \"practice\" (exercise/lab/notebook); anything else is treated as study."
6222
+ }
6166
6223
  }
6167
6224
  }
6168
6225
  }
6169
6226
  }
6170
6227
  }
6171
6228
  }
6172
- } },
6229
+ },
6173
6230
  output: {
6174
6231
  schema: {
6175
6232
  type: "object",
@@ -6202,12 +6259,21 @@ function studyTools(store, deps = {}) {
6202
6259
  droppedLessons: {
6203
6260
  type: "integer",
6204
6261
  required: true
6262
+ },
6263
+ created: {
6264
+ type: "boolean",
6265
+ required: true,
6266
+ description: "False when an existing course short-circuited the import (same title) — the design did NOT land as a new course."
6267
+ },
6268
+ languageTarget: {
6269
+ type: "string",
6270
+ description: "Present only when the course teaches a language itself (BCP-47); the tutor then keeps quiz material in the target language."
6205
6271
  }
6206
6272
  }
6207
6273
  },
6208
6274
  render: (_args, value) => [{
6209
6275
  type: "text",
6210
- 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.`
6276
+ text: value.created === false ? `“${value.title}” (id ${value.courseId}) was ALREADY imported — the design did not create a new course. Continue with the existing course, or delete it first (study_delete_course) to rebuild from this design.` : `Imported designed course “${value.title}” (${value.sections} sections, ${value.lessons} lessons${value.droppedLessons > 0 ? `, ${value.droppedLessons} hallucinated lesson(s) dropped` : ""})${typeof value.languageTarget === "string" ? ` — language course (${value.languageTarget}): keep reading passages, example sentences, and quiz language material in the target language` : ""}. First lesson: “${value.firstLessonTitle}” (id ${value.firstLessonId}). Present the course map to the learner.`
6211
6277
  }]
6212
6278
  },
6213
6279
  async execute(args, exec) {
@@ -6236,12 +6302,20 @@ function studyTools(store, deps = {}) {
6236
6302
  }
6237
6303
  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);
6238
6304
  requireParsedLessons(parsed);
6239
- const value = mutate((state) => toImportValue(importCourse(state, parsed, pd.source, pd.url)));
6305
+ const languageTarget = typeof args.languageTarget === "string" && args.languageTarget.trim() !== "" ? args.languageTarget.trim().slice(0, 35) : null;
6306
+ const value = mutate((state) => {
6307
+ const before = state.courses.length;
6308
+ return {
6309
+ ...toImportValue(importCourse(state, parsed, pd.source, pd.url, languageTarget)),
6310
+ created: state.courses.length > before
6311
+ };
6312
+ });
6240
6313
  pendingDesign = null;
6241
6314
  pendingPart = 1;
6242
6315
  return {
6243
6316
  ...value,
6244
- droppedLessons: validated.droppedLessons
6317
+ droppedLessons: validated.droppedLessons,
6318
+ ...languageTarget !== null ? { languageTarget } : {}
6245
6319
  };
6246
6320
  },
6247
6321
  timeoutMs: 18e4,
@@ -8690,6 +8764,45 @@ function studyTools(store, deps = {}) {
8690
8764
  ];
8691
8765
  }
8692
8766
  //#endregion
8767
+ //#region src/vendor/locale-names.ts
8768
+ /**
8769
+ * Vendored from LookatStudy shared/locales.ts (MIT License,
8770
+ * https://github.com/Kaiji-Z/LookatStudy), upstream v0.33.2 — only the
8771
+ * name-map + lookup function (the language-course round's BCP-47 → display
8772
+ * name need). Unmodified except this provenance header.
8773
+ *
8774
+ * locales —— BCP-47 → 人类可读语言名的共享映射(纯函数)。
8775
+ * 未知 locale 一律原样返回(如 "pt" → "pt"),不猜名字。
8776
+ */
8777
+ /** locale → 该语言的自称名(尽量用母语写法,语言学习者认得出) */
8778
+ const LOCALE_LANGUAGE_NAMES = {
8779
+ en: "English",
8780
+ "zh-CN": "中文",
8781
+ "zh-TW": "繁體中文",
8782
+ ja: "日本語",
8783
+ ko: "한국어",
8784
+ fr: "Français",
8785
+ de: "Deutsch",
8786
+ es: "Español",
8787
+ "pt-BR": "Português",
8788
+ ru: "Русский",
8789
+ it: "Italiano",
8790
+ ar: "العربية",
8791
+ hi: "हिन्दी",
8792
+ tr: "Türkçe",
8793
+ pl: "Polski",
8794
+ nl: "Nederlands",
8795
+ id: "Indonesia",
8796
+ vi: "Tiếng Việt",
8797
+ th: "ไทย",
8798
+ sv: "Svenska",
8799
+ fi: "Suomi"
8800
+ };
8801
+ /** BCP-47 → 语言名;未映射的 locale 原样返回 */
8802
+ function localeToLanguageName(locale) {
8803
+ return LOCALE_LANGUAGE_NAMES[locale] ?? locale;
8804
+ }
8805
+ //#endregion
8693
8806
  //#region src/surface.ts
8694
8807
  /**
8695
8808
  * Stable tutor core (ported from LookatStudy's BASE_AGENT_PROMPT plus its
@@ -8800,6 +8913,16 @@ function snapshotSectionText(state) {
8800
8913
  if (memory.length > 0) lines.push(`记忆: ${memory.join(" | ")}`);
8801
8914
  if (snap.dueCount > 0) lines.push(`今日待复习: ${snap.dueCount} 项`);
8802
8915
  if (snap.pendingProposal !== null) lines.push(`待决提案 ${snap.pendingProposal.id}: ${snap.pendingProposal.rationale}(等学习者表态)`);
8916
+ const focusCourse = state.courses.find((c) => c.id === snap.focus?.courseId);
8917
+ if (focusCourse?.languageTarget != null && focusCourse.languageTarget !== "") {
8918
+ const t = localeToLanguageName(focusCourse.languageTarget);
8919
+ lines.push("");
8920
+ lines.push(`【语言教学姿态】这门课程教的是${t}——教学语言(讲解、指令、反馈)跟随学习者的界面语言,${t}是学习对象:`);
8921
+ lines.push(`- 课文引用、例句、题目素材保持${t}原文,考察目标语言本身——不要把目标语言材料翻译成教学语言来讲解或出题(生词首次出现可附教学语言注释,注释是辅助不是替代);`);
8922
+ lines.push(`- 出题双轴:题干和选项中的语言素材(句子/单词/短语/语法点)用${t}原文,指令性文字和解析用教学语言;`);
8923
+ lines.push(`- 学习者用${t}造句或表达时,先肯定再纠正其中的语言错误(语法/用词/拼写),用教学语言讲清错在哪;`);
8924
+ lines.push(`- 随掌握度上升,渐进提高${t}素材的占比,把学习者往"直接用${t}读"带。`);
8925
+ }
8803
8926
  if (state.historyBudget === true) lines.push("历史预算已开启:当会话变长时,主动概括旧轮次的要点并基于概要继续,避免逐字复读历史。");
8804
8927
  return lines.join("\n");
8805
8928
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-lookatstudy",
3
- "version": "0.20.1",
3
+ "version": "0.21.1",
4
4
  "packageManager": "pnpm@11.7.0",
5
5
  "description": "Turn any markdown, local folder, or GitHub learning repo into a guided course inside DeepSeek Harness: gated skill-tree progression, BKT mastery tracking, SM-2 spaced repetition.",
6
6
  "type": "module",