dsh-plugin-lookatstudy 0.22.2 → 0.23.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
@@ -228,6 +228,13 @@ const ZH = {
228
228
  "review.banner": "正在复习:{lesson}",
229
229
  "review.threadTitle": "复习:{lesson}",
230
230
  "threads.label": "课时线程",
231
+ "threads.scope": "跨课时连续对话",
232
+ "threads.scope.on": "已开启",
233
+ "threads.scope.off": "未开启",
234
+ "threads.coverage": "覆盖",
235
+ "ask.title": "导师想先确认一下",
236
+ "ask.free": "在下方输入框直接回复即可",
237
+ "ask.answered": "已回答",
231
238
  "meter.label": "上下文用量",
232
239
  "meter.aria": "上下文已用 {pct}%",
233
240
  "meter.brk.system": "系统提示",
@@ -576,6 +583,13 @@ const EN = {
576
583
  "palette.kind.action": "Action",
577
584
  "palette.empty": "No matches",
578
585
  "threads.label": "Lesson threads",
586
+ "threads.scope": "Continuous course thread",
587
+ "threads.scope.on": "on",
588
+ "threads.scope.off": "off",
589
+ "threads.coverage": "covers",
590
+ "ask.title": "The tutor wants a quick confirm",
591
+ "ask.free": "Answer in the composer below",
592
+ "ask.answered": "answered",
579
593
  "threads.open": "{n} threads",
580
594
  "threads.now": "current",
581
595
  "threads.new": "New thread (your next message starts it)",
@@ -1482,6 +1496,23 @@ function computeStreakTransition(state, now = /* @__PURE__ */ new Date()) {
1482
1496
  };
1483
1497
  }
1484
1498
  //#endregion
1499
+ //#region src/thread-key.ts
1500
+ /** Resolve the thread-group map key for a send from `lessonId`. Absent scope
1501
+ * is legacy lesson behavior — old files never move. */
1502
+ function threadOwnerKey(scope, courseId, lessonId) {
1503
+ return scope === "course" ? `course:${courseId}` : lessonId;
1504
+ }
1505
+ /** True when a thread-group key names a course-scoped group. Disambiguated
1506
+ * by COLON COUNT, not by prefix alone: a course titled "Course" slugs to
1507
+ * `course`, and its lesson keys (`course:0:0`) live inside the naive
1508
+ * `course:` prefix space (caught live by the B7 trash test). Lesson ids are
1509
+ * always `<slug>:<si>:<li>` — three segments, since slugs strip the colon —
1510
+ * while a course key is exactly `course:<slug>`, two segments. */
1511
+ function isCourseOwnerKey(key) {
1512
+ if (!key.startsWith("course:")) return false;
1513
+ return !key.slice(7).includes(":");
1514
+ }
1515
+ //#endregion
1485
1516
  //#region src/state.ts
1486
1517
  /**
1487
1518
  * Learning state: courses → sections → lessons with mastery-driven gating,
@@ -1814,7 +1845,7 @@ function deleteCourse(state, courseId) {
1814
1845
  const [course] = state.courses.splice(i, 1);
1815
1846
  const prefix = `${courseId}:`;
1816
1847
  const lessonThreads = [];
1817
- for (const key of Object.keys(state.lessonThreads)) if (key.startsWith(prefix)) {
1848
+ for (const key of Object.keys(state.lessonThreads)) if (key.startsWith(prefix) || key === `course:${courseId}`) {
1818
1849
  lessonThreads.push([key, state.lessonThreads[key]]);
1819
1850
  delete state.lessonThreads[key];
1820
1851
  }
@@ -1871,7 +1902,11 @@ function restoreCourse(state, courseId) {
1871
1902
  }));
1872
1903
  for (const p of entry.proposals) p.lessonId = remap(p.lessonId);
1873
1904
  entry.artifacts = entry.artifacts.map(([k, v]) => [remap(k), v]);
1874
- entry.lessonThreads = entry.lessonThreads.map(([k, v]) => [remap(k), v]);
1905
+ entry.lessonThreads = entry.lessonThreads.map(([k, v]) => {
1906
+ if (!isCourseOwnerKey(k)) return [remap(k), v];
1907
+ for (const t of v.threads) if (t.touchedLessons !== void 0) t.touchedLessons = t.touchedLessons.map(remap);
1908
+ return [`course:${id}`, v];
1909
+ });
1875
1910
  entry.lessonSessions = entry.lessonSessions.map(([k, v]) => [remap(k), v]);
1876
1911
  if (entry.focusLessonId !== null) entry.focusLessonId = remap(entry.focusLessonId);
1877
1912
  }
@@ -2303,25 +2338,67 @@ function recordAnswer(state, lessonId, correct, concept, now) {
2303
2338
  * @returns completion result including the unlocked lessons.
2304
2339
  */
2305
2340
  /**
2341
+ * Set a course's thread granularity (0.23.0). Switching never touches the
2342
+ * sediment: both sides' groups stay put and are resumed when the scope flips
2343
+ * back. Unknown ids fail loud.
2344
+ */
2345
+ function setCourseThreadScope(state, courseId, scope) {
2346
+ const course = state.courses.find((c) => c.id === courseId);
2347
+ if (course === void 0) throw new Error(`lookatstudy-plugin: unknown course id ${JSON.stringify(courseId)}`);
2348
+ course.threadScope = scope;
2349
+ }
2350
+ /**
2351
+ * Resolve the thread-group map key a send from `lessonId` belongs to — the
2352
+ * 0.23.0 granularity funnel: lesson scope keeps legacy lessonId keys, course
2353
+ * scope namespaces the course. Every thread-group access in this module goes
2354
+ * through here (or through an ownerKey already derived from it).
2355
+ */
2356
+ function ownerKeyFor(state, lessonId) {
2357
+ const ref = findLesson(state, lessonId);
2358
+ const scope = ref.course.threadScope === "course" ? "course" : "lesson";
2359
+ return {
2360
+ key: threadOwnerKey(scope, ref.course.id, lessonId),
2361
+ scope
2362
+ };
2363
+ }
2364
+ /**
2365
+ * Under course scope, mirrors drift per lesson (each remembers the last
2366
+ * thread IT used) — drift onto a LIVE thread is fine. The moment a thread
2367
+ * leaves the live set (archived, deleted, or rolled off the active pointer),
2368
+ * every mirror still naming it is a dead reference — re-point them at the
2369
+ * group's current active thread (or drop them when none is left). Lesson
2370
+ * scope keeps the legacy exact-mirror behavior at its own key.
2371
+ */
2372
+ function repointMirrorsOff(state, key, threadId, group) {
2373
+ if (threadId === null || !isCourseOwnerKey(key)) return;
2374
+ for (const lessonId of Object.keys(state.lessonSessions)) if (state.lessonSessions[lessonId] === threadId) {
2375
+ if (group.active === null) delete state.lessonSessions[lessonId];
2376
+ else state.lessonSessions[lessonId] = group.active;
2377
+ }
2378
+ }
2379
+ /**
2306
2380
  * Bind a dsh session into a lesson's thread group (the issue-#11 write, the
2307
2381
  * upstream sendMessage/ensureThreadForSend translation): an UNKNOWN session id
2308
2382
  * appends a new thread (titled by the caller — the first message's text or a
2309
2383
  * short action label) and becomes active; a KNOWN id re-activates it (bumping
2310
2384
  * lastAt); `null` clears the active pointer (the +新建 affordance — the next
2311
2385
  * send mints a fresh thread). The legacy lessonSessions map stays in lockstep
2312
- * with the active pointer so pre-0.22 readers never disagree.
2386
+ * with the active pointer so pre-0.22 readers never disagree. Under course
2387
+ * scope (0.23.0) the group is the course's, the send-from lesson joins the
2388
+ * thread's touchedLessons coverage, and mirrors sync per
2389
+ * {@link repointRolledMirrors}.
2313
2390
  */
2314
2391
  function bindLessonThread(state, lessonId, sessionId, title) {
2315
- findLesson(state, lessonId);
2392
+ const { key, scope } = ownerKeyFor(state, lessonId);
2316
2393
  state.lessonThreads ??= {};
2317
- const group = state.lessonThreads[lessonId] ?? {
2394
+ const group = state.lessonThreads[key] ?? {
2318
2395
  active: null,
2319
2396
  threads: []
2320
2397
  };
2321
2398
  if (sessionId === null) {
2322
2399
  group.active = null;
2323
- state.lessonThreads[lessonId] = group;
2324
- delete state.lessonSessions[lessonId];
2400
+ state.lessonThreads[key] = group;
2401
+ if (scope === "lesson") delete state.lessonSessions[lessonId];
2325
2402
  return group;
2326
2403
  }
2327
2404
  const existing = group.threads.find((t) => t.id === sessionId);
@@ -2331,31 +2408,37 @@ function bindLessonThread(state, lessonId, sessionId, title) {
2331
2408
  id: sessionId,
2332
2409
  title: (title ?? "").trim() !== "" ? title.trim() : lessonId,
2333
2410
  createdAt: now,
2334
- lastAt: now
2411
+ lastAt: now,
2412
+ touchedLessons: [lessonId]
2335
2413
  });
2336
2414
  } else {
2337
2415
  existing.lastAt = (/* @__PURE__ */ new Date()).toISOString();
2338
2416
  if (existing.status === "archived") existing.status = "active";
2417
+ const touched = existing.touchedLessons ??= [];
2418
+ if (!touched.includes(lessonId)) touched.push(lessonId);
2339
2419
  }
2340
2420
  group.active = sessionId;
2341
- state.lessonThreads[lessonId] = group;
2421
+ state.lessonThreads[key] = group;
2342
2422
  state.lessonSessions[lessonId] = sessionId;
2343
2423
  return group;
2344
2424
  }
2345
2425
  /** Roll the group's active pointer to the freshest ACTIVE thread (or null)
2346
2426
  * and keep the legacy lessonSessions mirror in lockstep. Shared by the
2347
2427
  * archive/delete operations when they take out the current thread. */
2348
- function rollActiveToFresh(state, lessonId, group) {
2428
+ function rollActiveToFresh(state, key, group) {
2429
+ const prev = group.active;
2349
2430
  const live = group.threads.filter((t) => t.status !== "archived");
2350
2431
  group.active = live.length > 0 ? live.reduce((a, b) => a.lastAt > b.lastAt ? a : b).id : null;
2351
- if (group.active !== null) state.lessonSessions[lessonId] = group.active;
2352
- else delete state.lessonSessions[lessonId];
2432
+ if (isCourseOwnerKey(key)) repointMirrorsOff(state, key, prev, group);
2433
+ else if (group.active !== null) state.lessonSessions[key] = group.active;
2434
+ else delete state.lessonSessions[key];
2353
2435
  }
2354
2436
  /** Rename a thread's stored title (issue #11 management: upstream gear menu).
2355
2437
  * The dsh session's own title is renamed host-side by the client (best
2356
2438
  * effort) — this is the durable plugin-side half. Throws on unknown ids. */
2357
2439
  function renameLessonThread(state, lessonId, sessionId, title) {
2358
- const group = state.lessonThreads?.[lessonId];
2440
+ const key = ownerKeyFor(state, lessonId).key;
2441
+ const group = state.lessonThreads?.[key];
2359
2442
  if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
2360
2443
  const thread = group.threads.find((t) => t.id === sessionId);
2361
2444
  if (thread === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
@@ -2368,12 +2451,14 @@ function renameLessonThread(state, lessonId, sessionId, title) {
2368
2451
  * threads leave the switcher list entirely (no unarchive entry there; the
2369
2452
  * state API keeps `archived: false` for symmetry/future UI). */
2370
2453
  function archiveLessonThread(state, lessonId, sessionId, archived) {
2371
- const group = state.lessonThreads?.[lessonId];
2454
+ const key = ownerKeyFor(state, lessonId).key;
2455
+ const group = state.lessonThreads?.[key];
2372
2456
  if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
2373
2457
  const thread = group.threads.find((t) => t.id === sessionId);
2374
2458
  if (thread === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
2375
2459
  thread.status = archived ? "archived" : "active";
2376
- if (archived && group.active === sessionId) rollActiveToFresh(state, lessonId, group);
2460
+ if (archived && group.active === sessionId) rollActiveToFresh(state, key, group);
2461
+ if (archived) repointMirrorsOff(state, key, sessionId, group);
2377
2462
  if (!archived && group.active === null) {
2378
2463
  group.active = sessionId;
2379
2464
  state.lessonSessions[lessonId] = sessionId;
@@ -2385,12 +2470,14 @@ function archiveLessonThread(state, lessonId, sessionId, archived) {
2385
2470
  * the host session face (no delete primitive) — it stays in the host's session
2386
2471
  * list; the UI discloses this. Throws on unknown ids. */
2387
2472
  function deleteLessonThread(state, lessonId, sessionId) {
2388
- const group = state.lessonThreads?.[lessonId];
2473
+ const key = ownerKeyFor(state, lessonId).key;
2474
+ const group = state.lessonThreads?.[key];
2389
2475
  if (group === void 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread group`);
2390
2476
  const idx = group.threads.findIndex((t) => t.id === sessionId);
2391
2477
  if (idx < 0) throw new Error(`lookatstudy-plugin: lesson ${JSON.stringify(lessonId)} has no thread ${JSON.stringify(sessionId)}`);
2392
2478
  group.threads.splice(idx, 1);
2393
- if (group.active === sessionId) rollActiveToFresh(state, lessonId, group);
2479
+ if (group.active === sessionId) rollActiveToFresh(state, key, group);
2480
+ repointMirrorsOff(state, key, sessionId, group);
2394
2481
  return group;
2395
2482
  }
2396
2483
  function completeLesson(state, lessonId, now) {
@@ -2965,6 +3052,7 @@ function workbenchState(state, now) {
2965
3052
  return {
2966
3053
  courseId: course.id,
2967
3054
  title: course.title,
3055
+ threadScope: course.threadScope === "course" ? "course" : "lesson",
2968
3056
  mastered: lessons.filter((l) => l.status === "mastered").length,
2969
3057
  total: lessons.length,
2970
3058
  avgMasteryPct: answered.length === 0 ? null : Math.round(answered.reduce((sum, l) => sum + (l.mastery ?? 0), 0) / answered.length * 100),
@@ -3662,6 +3750,7 @@ function registerDashboard(webServer, deps) {
3662
3750
  return;
3663
3751
  }
3664
3752
  const state = deps.store.get();
3753
+ let group = null;
3665
3754
  try {
3666
3755
  if (body.op === "rename") {
3667
3756
  if (typeof body.title !== "string" || body.title.trim() === "") {
@@ -3671,7 +3760,7 @@ function registerDashboard(webServer, deps) {
3671
3760
  });
3672
3761
  return;
3673
3762
  }
3674
- renameLessonThread(state, body.lessonId, body.sessionId, body.title);
3763
+ group = renameLessonThread(state, body.lessonId, body.sessionId, body.title);
3675
3764
  } else if (body.op === "archive") {
3676
3765
  if (typeof body.archived !== "boolean") {
3677
3766
  sendJson(res, 400, {
@@ -3680,8 +3769,8 @@ function registerDashboard(webServer, deps) {
3680
3769
  });
3681
3770
  return;
3682
3771
  }
3683
- archiveLessonThread(state, body.lessonId, body.sessionId, body.archived);
3684
- } else deleteLessonThread(state, body.lessonId, body.sessionId);
3772
+ group = archiveLessonThread(state, body.lessonId, body.sessionId, body.archived);
3773
+ } else group = deleteLessonThread(state, body.lessonId, body.sessionId);
3685
3774
  } catch (error) {
3686
3775
  sendJson(res, 404, {
3687
3776
  ok: false,
@@ -3690,7 +3779,6 @@ function registerDashboard(webServer, deps) {
3690
3779
  return;
3691
3780
  }
3692
3781
  deps.store.save();
3693
- const group = state.lessonThreads[body.lessonId];
3694
3782
  sendJson(res, 200, {
3695
3783
  ok: true,
3696
3784
  active: group?.active ?? null,
@@ -3698,6 +3786,32 @@ function registerDashboard(webServer, deps) {
3698
3786
  });
3699
3787
  return;
3700
3788
  }
3789
+ if (req.method === "POST" && pathname === "/lookatstudy/api/course-scope") {
3790
+ const body = await readJsonBodySafe(req, res);
3791
+ if (body === void 0) return;
3792
+ if (typeof body.courseId !== "string" || body.scope !== "lesson" && body.scope !== "course") {
3793
+ sendJson(res, 400, {
3794
+ ok: false,
3795
+ error: "courseId and scope (lesson|course) required"
3796
+ });
3797
+ return;
3798
+ }
3799
+ try {
3800
+ setCourseThreadScope(deps.store.get(), body.courseId, body.scope);
3801
+ } catch (error) {
3802
+ sendJson(res, 404, {
3803
+ ok: false,
3804
+ error: error instanceof Error ? error.message : String(error)
3805
+ });
3806
+ return;
3807
+ }
3808
+ deps.store.save();
3809
+ sendJson(res, 200, {
3810
+ ok: true,
3811
+ scope: body.scope
3812
+ });
3813
+ return;
3814
+ }
3701
3815
  if (req.method === "GET" && pathname === "/lookatstudy/api/course-pack") {
3702
3816
  const courseId = new URL(req.url ?? "/", "http://x").searchParams.get("courseId") ?? "";
3703
3817
  const course = findCourseForPack(deps.store.get(), courseId);
@@ -10144,6 +10258,26 @@ function snapshotSectionText(state) {
10144
10258
  if (snap.dueCount > 0) lines.push(`今日待复习: ${snap.dueCount} 项`);
10145
10259
  if (snap.pendingProposal !== null) lines.push(`待决提案 ${snap.pendingProposal.id}: ${sanitizePromptText(snap.pendingProposal.rationale, 160)}(等学习者表态)`);
10146
10260
  const focusCourse = state.courses.find((c) => c.id === snap.focus?.courseId);
10261
+ if (focusCourse?.threadScope === "course" && snap.focus !== null) {
10262
+ const group = state.lessonThreads[threadOwnerKey("course", focusCourse.id, snap.focus.lessonId)];
10263
+ const touched = group?.threads.find((t) => t.id === group.active)?.touchedLessons;
10264
+ const titleOf = /* @__PURE__ */ new Map();
10265
+ for (const sec of focusCourse.sections) for (const l of sec.lessons) titleOf.set(l.id, l.title);
10266
+ const digest = focusCourse.sections.flatMap((sec) => sec.lessons.filter((l) => l.kind !== "exam")).slice(0, 60).map((l) => {
10267
+ const pct = l.mastery === null ? "" : ` ${String(Math.round(l.mastery * 100))}%`;
10268
+ const weak = (conceptViews(l) ?? []).some((c) => c.weak) ? " ⚡薄弱" : "";
10269
+ const fric = l.friction.length > 0 ? ` ⚑卡点${String(l.friction.length)}` : "";
10270
+ return `- ${sanitizePromptText(l.title)}[${String(l.status)}${pct}]${weak}${fric}`;
10271
+ });
10272
+ lines.push("");
10273
+ lines.push("【本课程进度摘要】(跨课时连续对话:本线跨课时延续,换课时不改线)");
10274
+ lines.push(...digest);
10275
+ if (touched !== void 0 && touched.length > 0) {
10276
+ const names = touched.map((id) => sanitizePromptText(titleOf.get(id) ?? id)).join(" · ");
10277
+ lines.push(`本对话线已覆盖课时: ${names}; 当前焦点: ${sanitizePromptText(snap.focus.lessonTitle)}`);
10278
+ }
10279
+ lines.push("出跨课时综合题前,先调用 study_lesson 调出相关课时正文再命题——组合题优先利用本线已有的跨课时现场。");
10280
+ }
10147
10281
  if (focusCourse?.languageTarget != null && focusCourse.languageTarget !== "") {
10148
10282
  const t = localeToLanguageName(focusCourse.languageTarget);
10149
10283
  lines.push("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-lookatstudy",
3
- "version": "0.22.2",
3
+ "version": "0.23.0",
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",