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/client.js +246 -19
- package/lib/client.js.map +1 -1
- package/lib/index.mjs +155 -21
- package/package.json +1 -1
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]) =>
|
|
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
|
-
|
|
2392
|
+
const { key, scope } = ownerKeyFor(state, lessonId);
|
|
2316
2393
|
state.lessonThreads ??= {};
|
|
2317
|
-
const group = state.lessonThreads[
|
|
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[
|
|
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[
|
|
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,
|
|
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 (
|
|
2352
|
-
else
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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.
|
|
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",
|