dsh-plugin-lookatstudy 0.12.4 → 0.13.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
@@ -4,11 +4,11 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
4
4
  import { basename, dirname, join, relative, sep } from "node:path";
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import https from "node:https";
7
9
  import { homedir } from "node:os";
8
- import { randomBytes } from "node:crypto";
9
10
  import { readFile, readdir } from "node:fs/promises";
10
11
  import { defineTool } from "@deepseek-ai/dsh-tools";
11
- import https from "node:https";
12
12
  //#region \0rolldown/runtime.js
13
13
  var __defProp = Object.defineProperty;
14
14
  var __exportAll = (all, no_symbols) => {
@@ -233,6 +233,228 @@ function levelFromTotalXp(totalXp) {
233
233
  levelSpan: span
234
234
  };
235
235
  }
236
+ "])".slice(0, 1);
237
+ function normalizeSpeechText(md) {
238
+ let s = md;
239
+ s = s.replace(/\r\n?/g, "\n");
240
+ s = s.replace(/(?:^|\n)[ \t]*(?:```|~~~)[^\n]*\n[\s\S]*?(?:\n[ \t]*(?:```|~~~)[^\n]*|\n?$)/g, "\n");
241
+ s = s.replace(/`[^`\n]*`/g, "");
242
+ s = s.replace(/!\[[^\]]*\]\([^)\n]*\)/g, "");
243
+ s = s.replace(/\[([^\]]+)\]\([^)\n]*\)/g, "$1");
244
+ s = s.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, "");
245
+ s = s.replace(/^[ \t]*(?:[-*+]|\d+\.)[ \t]+/gm, "");
246
+ s = s.replace(/^[ \t]*>[ \t]?/gm, "");
247
+ s = s.replace(/\|/g, " ");
248
+ s = s.replace(/^[ \t]*[-: ]{3,}[ \t]*$/gm, "");
249
+ s = s.replace(/(\*\*|__)(.*?)\1/g, "$2");
250
+ s = s.replace(/(?<![*\w])(\*|_)(?!\s)(.+?)(?<!\s)\1(?![*\w])/g, "$2");
251
+ s = s.replace(/~~(.+?)~~/g, "$1");
252
+ s = s.replace(/\n{3,}/g, "\n\n");
253
+ return s.trim();
254
+ }
255
+ //#endregion
256
+ //#region src/tts.ts
257
+ /**
258
+ * Edge read-aloud synthesis: Microsoft's read-aloud endpoint (the one behind
259
+ * Edge's own 朗读 feature), spoken to over a hand-rolled WebSocket on
260
+ * node:https/node:crypto — zero dependencies. Synthesis runs HOST-side (the
261
+ * browser cannot open that socket), so the dashboard streams the cached MP3
262
+ * down and the client falls back to speechSynthesis when this path fails.
263
+ *
264
+ * Three live-verified protocol details this module pins (2026-09-06 probes):
265
+ * 1. Sec-MS-GEC ticks are ~1.36e17 — past Number's 2^53. BigInt or the hash
266
+ * silently mismatches (403).
267
+ * 2. `Sec-MS-GEC-Version` must carry a CURRENT Edge full version; stale
268
+ * strings are rejected with 403 before any upgrade.
269
+ * 3. The service sends WebSocket PINGs; unanswered PONGs get the socket reset
270
+ * mid-turn (read the turn.end path name with the dot — `\w+` eats it).
271
+ * @module dsh-plugin-lookatstudy/tts
272
+ */
273
+ /** Public read-aloud client token (same constant every edge-tts client ships). */
274
+ const TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4";
275
+ /** Must track a shipped Edge release; a stale string is rejected outright. */
276
+ const CHROMIUM_FULL_VERSION = "143.0.3650.75";
277
+ /** The plugin's default tutor voice (晓晓, zh-CN female neural). */
278
+ const DEFAULT_TTS_VOICE = "zh-CN-XiaoxiaoNeural";
279
+ /** Voices the dashboard route accepts (others fall back to the default). */
280
+ const VOICES = /* @__PURE__ */ new Set([
281
+ DEFAULT_TTS_VOICE,
282
+ "zh-CN-YunxiNeural",
283
+ "zh-CN-YunyangNeural",
284
+ "zh-CN-XiaoyiNeural",
285
+ "en-US-AriaNeural",
286
+ "en-US-GuyNeural"
287
+ ]);
288
+ function normalizeVoice(voice) {
289
+ return voice !== void 0 && VOICES.has(voice) ? voice : DEFAULT_TTS_VOICE;
290
+ }
291
+ /**
292
+ * The DRM token (Sec-MS-GEC): SHA-256 over the Windows-filetime ticks of the
293
+ * current 5-minute window plus the trusted token. BigInt is mandatory — the
294
+ * ticks exceed Number.MAX_SAFE_INTEGER and the hash consumes exact digits.
295
+ * @param nowMs - caller clock in unix milliseconds.
296
+ */
297
+ function secMsGec(nowMs = Date.now()) {
298
+ let ticks = (BigInt(Math.floor(nowMs / 1e3)) + 11644473600n) * 10000000n;
299
+ ticks -= ticks % 3000000000n;
300
+ return createHash("sha256").update(`${ticks}${TRUSTED_CLIENT_TOKEN}`).digest("hex").toUpperCase();
301
+ }
302
+ function xmlEscape(s) {
303
+ return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
304
+ }
305
+ /** Build one speak utterance. */
306
+ function buildSsml(text, voice) {
307
+ return `<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='${voice.split("-").slice(0, 2).join("-")}'><voice name='${voice}'><prosody rate='+0%' pitch='+0Hz'>${xmlEscape(text)}</prosody></voice></speak>`;
308
+ }
309
+ /** Client→server frames are masked; server→client frames are not. */
310
+ function maskFrame(opcode, payload) {
311
+ const mask = randomBytes(4);
312
+ const head = payload.length < 126 ? Buffer.from([128 | opcode, 128 | payload.length]) : Buffer.from([
313
+ 128 | opcode,
314
+ 254,
315
+ payload.length >> 8,
316
+ payload.length & 255
317
+ ]);
318
+ const masked = Buffer.from(payload.map((b, i) => b ^ mask[i % 4]));
319
+ return Buffer.concat([
320
+ head,
321
+ mask,
322
+ masked
323
+ ]);
324
+ }
325
+ const timestamp = () => (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
326
+ /**
327
+ * Synthesize one utterance to MP3 (audio-24khz-48kbit-mono-mp3).
328
+ * @param text - plain speakable text (short: one sentence or a small group).
329
+ * @param voice - neural voice name.
330
+ * @param timeoutMs - hard ceiling on the whole turn.
331
+ * @returns the MP3 bytes.
332
+ */
333
+ function synthesizeSpeech(text, voice = DEFAULT_TTS_VOICE, timeoutMs = 2e4) {
334
+ return new Promise((resolve, reject) => {
335
+ const gec = secMsGec();
336
+ const host = "speech.platform.bing.com";
337
+ const path = `/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=${TRUSTED_CLIENT_TOKEN}&Sec-MS-GEC=${gec}&Sec-MS-GEC-Version=1-${CHROMIUM_FULL_VERSION}`;
338
+ const key = randomBytes(16).toString("base64");
339
+ const req = https.request(`https://${host}${path}`, { headers: {
340
+ Connection: "Upgrade",
341
+ Upgrade: "websocket",
342
+ "Sec-WebSocket-Key": key,
343
+ "Sec-WebSocket-Version": "13",
344
+ Pragma: "no-cache",
345
+ "Cache-Control": "no-cache",
346
+ Origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
347
+ "User-Agent": `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_FULL_VERSION.split(".")[0]}.0.0.0 Safari/537.36 Edg/${CHROMIUM_FULL_VERSION}`
348
+ } });
349
+ const timer = setTimeout(() => {
350
+ socket?.destroy();
351
+ reject(/* @__PURE__ */ new Error("edge tts: turn timed out"));
352
+ }, timeoutMs);
353
+ const debug = process.env.LKS_TTS_DEBUG === "1";
354
+ const log = (line) => {
355
+ if (debug) console.error(`[lks-tts] ${line}`);
356
+ };
357
+ let socket;
358
+ const finish = (err, mp3) => {
359
+ clearTimeout(timer);
360
+ socket?.destroy();
361
+ if (err !== null) reject(err);
362
+ else resolve(mp3);
363
+ };
364
+ req.on("upgrade", (res, sock) => {
365
+ log(`upgrade ok (${String(res.statusCode)})`);
366
+ socket = sock;
367
+ const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64");
368
+ if (res.headers["sec-websocket-accept"] !== accept) return finish(/* @__PURE__ */ new Error("edge tts: bad upgrade accept"));
369
+ if (res.statusCode !== 101) return finish(/* @__PURE__ */ new Error(`edge tts: upgrade refused (${String(res.statusCode)})`));
370
+ sock.setNoDelay(true);
371
+ sock.write(maskFrame(1, Buffer.from(`X-Timestamp:${timestamp()}\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n` + JSON.stringify({ context: { synthesis: { audio: {
372
+ metadataoptions: {
373
+ sentenceBoundaryEnabled: "false",
374
+ wordBoundaryEnabled: "false"
375
+ },
376
+ outputFormat: "audio-24khz-48kbitrate-mono-mp3"
377
+ } } } }))));
378
+ sock.write(maskFrame(1, Buffer.from(`X-RequestId:${randomBytes(16).toString("hex")}\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:${timestamp()}\r\nPath:ssml\r\n\r\n${buildSsml(text, voice)}`)));
379
+ let acc = Buffer.alloc(0);
380
+ const audio = [];
381
+ sock.on("data", (chunk) => {
382
+ if (debug) log(`recv ${chunk.length}B head=${chunk.subarray(0, 8).toString("hex")}`);
383
+ acc = Buffer.concat([acc, chunk]);
384
+ for (;;) {
385
+ if (acc.length < 2) return;
386
+ const opcode = acc[0] & 15;
387
+ let len = acc[1] & 127;
388
+ let off = 2;
389
+ if (len === 126) {
390
+ if (acc.length < 4) return;
391
+ len = acc.readUInt16BE(2);
392
+ off = 4;
393
+ } else if (len === 127) {
394
+ if (acc.length < 10) return;
395
+ len = Number(acc.readBigUInt64BE(2));
396
+ off = 10;
397
+ }
398
+ if (acc.length < off + len) return;
399
+ const payload = acc.subarray(off, off + len);
400
+ acc = acc.subarray(off + len);
401
+ if (opcode === 9) {
402
+ log("ping→pong");
403
+ sock.write(maskFrame(10, payload));
404
+ continue;
405
+ }
406
+ if (opcode === 8) {
407
+ log("close frame");
408
+ return finish(audio.length > 0 ? null : /* @__PURE__ */ new Error("edge tts: closed before audio"));
409
+ }
410
+ if (opcode !== 1 && opcode !== 2) continue;
411
+ const headerLen = payload.readUInt16BE(0);
412
+ const head = payload.subarray(2, 2 + headerLen).toString("utf8");
413
+ if (debug) log(`frame op=${opcode} len=${len} path=${/Path:([\w.]+)/.exec(head)?.[1] ?? "?"}`);
414
+ if (opcode === 1 && /Path:turn\.end/.test(head)) {
415
+ log(`turn.end with ${audio.length} audio chunk(s)`);
416
+ return finish(audio.length > 0 ? null : /* @__PURE__ */ new Error("edge tts: turn ended without audio"), Buffer.concat(audio));
417
+ }
418
+ if (opcode === 2 && /Path:audio/.test(head)) {
419
+ const chunk = payload.subarray(2 + headerLen);
420
+ audio.push(chunk);
421
+ if (debug) log(`audio chunk ${chunk.length}B (total ${audio.reduce((n, c) => n + c.length, 0)}B)`);
422
+ }
423
+ }
424
+ });
425
+ sock.on("error", (err) => finish(err));
426
+ sock.on("close", () => {
427
+ if (timer.hasRef()) finish(/* @__PURE__ */ new Error("edge tts: socket closed mid-turn"));
428
+ });
429
+ });
430
+ req.on("response", (res) => {
431
+ res.resume();
432
+ finish(/* @__PURE__ */ new Error(`edge tts: endpoint refused the upgrade (HTTP ${String(res.statusCode)})`));
433
+ });
434
+ req.on("error", (err) => finish(err));
435
+ req.end();
436
+ });
437
+ }
438
+ /** Cache key: voice-scoped (different voices never share files). */
439
+ function ttsCachePath(cacheDir, text, voice) {
440
+ const hash = createHash("sha256").update(`${voice}\n${text}`).digest("hex");
441
+ return join(cacheDir, `${hash}.mp3`);
442
+ }
443
+ /**
444
+ * Cache-first synthesis: study-area/tts-cache/{sha256(voice+text)}.mp3.
445
+ * Misses synthesize once and persist; later listens (and other learners on
446
+ * the same install) replay from disk without touching the endpoint.
447
+ * @param synth - the synthesizer (injectable for tests; real Edge TTS by default).
448
+ */
449
+ async function cachedTtsMp3(cacheDir, text, voice = DEFAULT_TTS_VOICE, synth = synthesizeSpeech) {
450
+ const file = ttsCachePath(cacheDir, text, voice);
451
+ if (existsSync(file)) return readFileSync(file);
452
+ mkdirSync(cacheDir, { recursive: true });
453
+ const mp3 = await synth(text, voice);
454
+ if (mp3.length === 0) throw new Error("edge tts: synthesis returned empty audio (not cached)");
455
+ writeFileSync(file, mp3);
456
+ return mp3;
457
+ }
236
458
  //#endregion
237
459
  //#region src/vendor/sm2.ts
238
460
  function computeSm2(prev, quality, now = /* @__PURE__ */ new Date()) {
@@ -945,7 +1167,10 @@ function setMemory(state, category, content, lessonId) {
945
1167
  function addNote(state, lessonId, zone, title, text, source, quote, now) {
946
1168
  const ref = findLesson(state, lessonId);
947
1169
  const note = {
948
- id: `${lessonId}:n${ref.lesson.notes.length}`,
1170
+ id: `${lessonId}:n${ref.lesson.notes.reduce((max, n) => {
1171
+ const m = /:n(\d+)$/.exec(n.id);
1172
+ return m !== null ? Math.max(max, Number(m[1])) : max;
1173
+ }, -1) + 1}`,
949
1174
  zone,
950
1175
  title,
951
1176
  text,
@@ -957,6 +1182,19 @@ function addNote(state, lessonId, zone, title, text, source, quote, now) {
957
1182
  return note;
958
1183
  }
959
1184
  /**
1185
+ * Delete one notebook entry from a lesson's Cornell zones.
1186
+ * @param state - state to mutate.
1187
+ * @param lessonId - lesson the note belongs to.
1188
+ * @param noteId - id of the note to remove.
1189
+ * @throws when the lesson or the note id is unknown (fail loud, like every id lookup).
1190
+ */
1191
+ function deleteNote(state, lessonId, noteId) {
1192
+ const ref = findLesson(state, lessonId);
1193
+ const index = ref.lesson.notes.findIndex((n) => n.id === noteId);
1194
+ if (index === -1) throw new Error(`lookatstudy-plugin: note ${JSON.stringify(noteId)} not found on lesson ${JSON.stringify(lessonId)}`);
1195
+ ref.lesson.notes.splice(index, 1);
1196
+ }
1197
+ /**
960
1198
  * Propose early mastery graduation for the learner to accept or reject in chat.
961
1199
  * @param state - state to mutate.
962
1200
  * @param lessonId - lesson judged mastered.
@@ -1396,7 +1634,8 @@ function workbenchState(state, now) {
1396
1634
  quote: n.quote
1397
1635
  })),
1398
1636
  html: renderMarkdown(normalizeMathNotation(ref.lesson.translation === void 0 ? ref.lesson.body : renderBilingual(ref.lesson.body, ref.lesson.translation))),
1399
- markdown: ref.lesson.body
1637
+ markdown: ref.lesson.body,
1638
+ speechText: normalizeSpeechText(normalizeMathNotation(ref.lesson.body))
1400
1639
  };
1401
1640
  } catch {
1402
1641
  lesson = null;
@@ -1597,6 +1836,28 @@ function registerDashboard(webServer, deps) {
1597
1836
  }
1598
1837
  return;
1599
1838
  }
1839
+ if (req.method === "POST" && pathname === "/lookatstudy/api/note/delete") {
1840
+ const body = await readJsonBodySafe(req, res);
1841
+ if (body === void 0) return;
1842
+ if (typeof body.lessonId !== "string" || typeof body.noteId !== "string") {
1843
+ sendJson(res, 400, {
1844
+ ok: false,
1845
+ error: "lessonId and noteId (strings) required"
1846
+ });
1847
+ return;
1848
+ }
1849
+ try {
1850
+ deleteNote(deps.store.get(), body.lessonId, body.noteId);
1851
+ deps.store.save();
1852
+ sendJson(res, 200, { ok: true });
1853
+ } catch (error) {
1854
+ sendJson(res, 404, {
1855
+ ok: false,
1856
+ error: error instanceof Error ? error.message : String(error)
1857
+ });
1858
+ }
1859
+ return;
1860
+ }
1600
1861
  if (req.method === "POST" && pathname === "/lookatstudy/api/lesson-session") {
1601
1862
  const body = await readJsonBodySafe(req, res);
1602
1863
  if (body === void 0) return;
@@ -1612,6 +1873,40 @@ function registerDashboard(webServer, deps) {
1612
1873
  sendJson(res, 200, { ok: true });
1613
1874
  return;
1614
1875
  }
1876
+ if (req.method === "POST" && pathname === "/lookatstudy/api/tts") {
1877
+ const body = await readJsonBodySafe(req, res);
1878
+ if (body === void 0) return;
1879
+ const text = typeof body.text === "string" ? body.text.trim() : "";
1880
+ if (text === "") {
1881
+ sendJson(res, 400, {
1882
+ ok: false,
1883
+ error: "text (non-empty string) required"
1884
+ });
1885
+ return;
1886
+ }
1887
+ if (text.length > 4e3) {
1888
+ sendJson(res, 400, {
1889
+ ok: false,
1890
+ error: "text exceeds 4000 chars (speak sentence groups, not whole lessons)"
1891
+ });
1892
+ return;
1893
+ }
1894
+ const voice = normalizeVoice(typeof body.voice === "string" ? body.voice : void 0);
1895
+ const cacheDir = join(deps.studyAreaPath, "tts-cache");
1896
+ try {
1897
+ sendJson(res, 200, {
1898
+ ok: true,
1899
+ mime: "audio/mpeg",
1900
+ dataBase64: (await cachedTtsMp3(cacheDir, text, voice, deps.tts?.synthesize)).toString("base64")
1901
+ });
1902
+ } catch (error) {
1903
+ sendJson(res, 502, {
1904
+ ok: false,
1905
+ error: error instanceof Error ? error.message : String(error)
1906
+ });
1907
+ }
1908
+ return;
1909
+ }
1615
1910
  if (req.method === "POST" && pathname === "/lookatstudy/api/mode") {
1616
1911
  const body = await readJsonBodySafe(req, res);
1617
1912
  if (body === void 0) return;
@@ -1687,6 +1982,14 @@ const ZH = {
1687
1982
  "rail.delete": "删除本课程",
1688
1983
  "rail.delete.confirm": "确认删除?",
1689
1984
  "rail.delete.title.confirm": "再点一次确认删除(含全部进度与笔记)。反悔前可先在设置页备份状态文件",
1985
+ "note.delete": "删除本条笔记",
1986
+ "note.delete.confirm": "确认删除?",
1987
+ "read.play": "朗读本课",
1988
+ "read.stop": "停止朗读",
1989
+ "read.pause": "暂停朗读",
1990
+ "read.resume": "继续朗读",
1991
+ "read.engine.system": "网络合成不可用,已切换为系统语音",
1992
+ "read.unavailable": "朗读不可用",
1690
1993
  "rail.section.collapse": "折叠本章节",
1691
1994
  "rail.section.expand": "展开本章节({count} 课时)",
1692
1995
  "rail.section.count": "{count} 课",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-lookatstudy",
3
- "version": "0.12.4",
3
+ "version": "0.13.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",