dsh-aha-tutor 1.3.0 → 1.5.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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as courseProgress, A as streamCounter, B as documentParts, C as reworkPrompt, D as generateJson, E as TutorError, F as materialsSummary, G as createConceptState, H as passageRange, I as passagesFor, J as extractJson, K as masteryPercent, L as readMaterials, M as emptyProgress, N as masteryFor, O as generateSteps, P as materialImages, Q as conceptIndex, R as readProfile, S as revisePrompt, T as summaryPrompt, U as spreadPassages, V as formatPassages, W as applyEvidence, X as normalizeBoard, Y as boardToPlainText, Z as blueprintOutline, _ as projectReviewPrompt, _t as newId, a as blueprintPrompt, at as nextLesson, b as quizPrompt, c as detectLanguage, ct as normalizeCheatsheet, d as flashcardsPrompt, dt as normalizeProject, et as findLesson, f as gradePrompt, ft as normalizeQuestionnaire, g as profilePrompt, gt as assertId, h as practicePrompt, ht as text, i as assistPrompt, it as lessonAfter, j as EMPTY_PROFILE, k as generateText, l as digestPartPrompt, lt as normalizeLesson, m as materialBriefPrompt, mt as safeRelativePath, n as publishRuntime, nt as insertLesson, o as cheatsheetPrompt, ot as normalizeBlueprint, p as lessonPrompt, pt as outlineForPrompt, q as recommendNext, r as askPrompt, rt as intakeSummary, s as correctionPrompt, st as normalizeCards, tt as flattenLessons, u as digestPrompt, ut as normalizePractice, v as projectsPrompt, w as shortPrompt, x as reexplainPrompt, y as questionnairePrompt, z as readProgress } from "./runtime-BCGFUDlg.js";
1
+ import { $ as conceptIndex, A as generateText, B as readProgress, C as revisePrompt, D as TutorError, E as summaryPrompt, F as materialImages, G as applyEvidence, H as formatPassages, I as materialsSummary, J as recommendNext, K as createConceptState, L as passagesFor, M as EMPTY_PROFILE, N as emptyProgress, O as generateJson, P as masteryFor, Q as blueprintOutline, R as readMaterials, S as researchQueriesPrompt, T as shortPrompt, U as passageRange, V as documentParts, W as spreadPassages, X as boardToPlainText, Y as extractJson, Z as normalizeBoard, _ as projectReviewPrompt, _t as assertId, a as blueprintPrompt, at as lessonAfter, b as quizPrompt, c as detectLanguage, ct as normalizeCards, d as flashcardsPrompt, dt as normalizePractice, et as courseProgress, f as gradePrompt, ft as normalizeProject, g as profilePrompt, gt as text, h as practicePrompt, ht as safeRelativePath, i as assistPrompt, it as intakeSummary, j as streamCounter, k as generateSteps, l as digestPartPrompt, lt as normalizeCheatsheet, m as materialBriefPrompt, mt as outlineForPrompt, n as publishRuntime, nt as flattenLessons, o as cheatsheetPrompt, ot as nextLesson, p as lessonPrompt, pt as normalizeQuestionnaire, q as masteryPercent, r as askPrompt, rt as insertLesson, s as correctionPrompt, st as normalizeBlueprint, tt as findLesson, u as digestPrompt, ut as normalizeLesson, v as projectsPrompt, vt as newId, w as reworkPrompt, x as reexplainPrompt, y as questionnairePrompt, z as readProfile } from "./runtime-DqCjJ0yL.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname, join, relative, sep } from "node:path";
4
4
  import { credentialKey } from "@deepseek-ai/dsh-credentials";
@@ -46,17 +46,20 @@ function exportCoursePack({ course, progress, boards = {}, practice = {}, sheets
46
46
  function parseCoursePack(input) {
47
47
  let pack = input;
48
48
  if (typeof input === "string") {
49
- if (input.length > 8388608) throw new Error("course pack is too large");
49
+ if (input.length > 8388608) throw packError("pack-too-large", "course pack is too large");
50
50
  try {
51
51
  pack = JSON.parse(input);
52
52
  } catch {
53
- throw new Error("course pack is not valid JSON");
53
+ throw packError("bad-pack", "course pack is not valid JSON");
54
54
  }
55
55
  }
56
- if (pack?.format !== "aha-tutor.coursepack") throw new Error("not a course pack");
57
- if (!Number.isInteger(pack.version) || pack.version > 1) throw new Error("course pack was made by a newer version of the plugin");
56
+ if (pack?.format !== "aha-tutor.coursepack") throw packError("bad-pack", "not a course pack");
57
+ if (!Number.isInteger(pack.version) || pack.version > 1) throw packError("pack-too-new", "course pack was made by a newer version of the plugin");
58
58
  return pack;
59
59
  }
60
+ function packError(code, message) {
61
+ return Object.assign(new Error(message), { code });
62
+ }
60
63
  function importCoursePack(input, { now = /* @__PURE__ */ new Date() } = {}) {
61
64
  const pack = parseCoursePack(input);
62
65
  const blueprint = normalizeBlueprint(stripIds(pack.course), { language: pack.course?.language });
@@ -236,7 +239,7 @@ function outlineStream(api) {
236
239
  counter(delta);
237
240
  };
238
241
  }
239
- function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date() }) {
242
+ function createCourses({ store, llm, jobs, research, now = () => /* @__PURE__ */ new Date() }) {
240
243
  const stamp = () => now().toISOString();
241
244
  async function readCourse(courseId) {
242
245
  const course = await store.readJson(store.paths.course(assertId(courseId, "crs")), null);
@@ -309,9 +312,9 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
309
312
  }
310
313
  };
311
314
  }
312
- function startCraft({ goal, materialIds = [], quick = false, language }) {
315
+ function startCraft({ goal, materialIds = [], quick = false, language, web = false }) {
313
316
  const cleanGoal = text(goal, 2e3);
314
- if (!cleanGoal && !materialIds.length) throw new TutorError("invalid", "Tell me what you want to learn");
317
+ if (!cleanGoal && !materialIds.length) throw new TutorError("empty-goal", "Tell me what you want to learn");
315
318
  const ids = materialIds.filter((id) => /^mat_[a-z0-9]+$/u.test(id)).slice(0, 8);
316
319
  const courseId = newId("crs");
317
320
  return jobs.start("craft/start", async (api) => {
@@ -327,10 +330,29 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
327
330
  createdAt: stamp()
328
331
  };
329
332
  await saveCourse(draft);
330
- if (quick) return {
331
- courseId,
332
- questionnaire: null
333
- };
333
+ let found = null;
334
+ if (web && research) {
335
+ api.progress(0, quick ? 1 : 2, "research");
336
+ found = await research.run({
337
+ goal: draft.goal,
338
+ language: draft.language,
339
+ signal: api.signal
340
+ });
341
+ api.progress(0, quick ? 1 : 2, "questions");
342
+ }
343
+ if (quick) {
344
+ if (found) await store.writeJson(store.paths.intake(courseId), {
345
+ questionnaire: null,
346
+ answers: {},
347
+ research: found,
348
+ at: stamp()
349
+ });
350
+ return {
351
+ courseId,
352
+ questionnaire: null,
353
+ research: found
354
+ };
355
+ }
334
356
  const profile = await readProfile(store);
335
357
  const questionnaire = await generateJson(llm, {
336
358
  purpose: "teach",
@@ -338,7 +360,8 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
338
360
  goal: draft.goal,
339
361
  language: draft.language,
340
362
  materialsSummary: materialsSummary(materials),
341
- profile
363
+ profile,
364
+ sources: found?.sources
342
365
  }),
343
366
  maxTokens: 3e3,
344
367
  signal: api.signal,
@@ -351,12 +374,14 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
351
374
  await store.writeJson(store.paths.intake(courseId), {
352
375
  questionnaire,
353
376
  answers: {},
377
+ ...found ? { research: found } : {},
354
378
  at: stamp()
355
379
  });
356
380
  api.progress(1, 2, "questions");
357
381
  return {
358
382
  courseId,
359
- questionnaire
383
+ questionnaire,
384
+ research: found
360
385
  };
361
386
  }, {
362
387
  key: `questions:${courseId}`,
@@ -397,7 +422,8 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
397
422
  profile,
398
423
  timeline: settings.timeline,
399
424
  size: settings.size,
400
- weeklyMinutes: settings.weeklyMinutes
425
+ weeklyMinutes: settings.weeklyMinutes,
426
+ sources: intake.research?.sources
401
427
  }),
402
428
  images: llm.supportsImages?.("teach") ? await materialImages(store, materials) : void 0,
403
429
  maxTokens: 16e3,
@@ -405,12 +431,14 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
405
431
  onText: outlineStream(api),
406
432
  normalize: (raw) => attachProjectUnits(raw, normalizeBlueprint(raw, { language: course.language }))
407
433
  });
434
+ const sources = intake.research?.sources ?? [];
408
435
  const next = {
409
436
  ...course,
410
437
  ...blueprint,
411
438
  language: course.language,
412
439
  status: "blueprint",
413
- settings
440
+ settings,
441
+ ...sources.length ? { sources } : {}
414
442
  };
415
443
  await saveCourse(next);
416
444
  return { course: next };
@@ -421,10 +449,10 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
421
449
  }
422
450
  function revise({ courseId, instruction }) {
423
451
  const request = text(instruction, 1500);
424
- if (!request) throw new TutorError("invalid", "Describe the change you want");
452
+ if (!request) throw new TutorError("empty-change", "Describe the change you want");
425
453
  return jobs.start("craft/revise", async (api) => {
426
454
  const course = await readCourse(courseId);
427
- if (!course.units) throw new TutorError("invalid", "There is no blueprint to revise yet");
455
+ if (!course.units) throw new TutorError("no-blueprint", "There is no blueprint to revise yet");
428
456
  const current = {
429
457
  title: course.title,
430
458
  subtitle: course.subtitle,
@@ -465,7 +493,7 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
465
493
  async function applyProposal({ courseId }) {
466
494
  const course = await readCourse(courseId);
467
495
  const proposal = await store.readJson(store.paths.proposal(courseId), null);
468
- if (!proposal?.blueprint?.units) throw new TutorError("not-found", "There is no proposed change");
496
+ if (!proposal?.blueprint?.units) throw new TutorError("no-proposal", "There is no proposed change");
469
497
  const next = {
470
498
  ...course,
471
499
  ...proposal.blueprint,
@@ -569,7 +597,7 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
569
597
  function confirm({ courseId }) {
570
598
  return jobs.start("craft/confirm", async (api) => {
571
599
  const course = await readCourse(courseId);
572
- if (!course.units) throw new TutorError("invalid", "Generate a blueprint first");
600
+ if (!course.units) throw new TutorError("no-blueprint", "Generate a blueprint first");
573
601
  const lectures = course.units.slice(0, 1).flatMap((unit) => unit.lectures.map((lecture) => ({
574
602
  unit,
575
603
  lecture
@@ -699,7 +727,17 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
699
727
  };
700
728
  }
701
729
  async function importPack({ json }) {
702
- const imported = importCoursePack(json);
730
+ let imported;
731
+ try {
732
+ imported = importCoursePack(json);
733
+ } catch (error) {
734
+ const known = [
735
+ "bad-pack",
736
+ "pack-too-large",
737
+ "pack-too-new"
738
+ ].includes(error?.code);
739
+ throw new TutorError(known ? error.code : "bad-pack", known ? error.message : "not a course pack");
740
+ }
703
741
  const { course } = imported;
704
742
  await saveCourse(course);
705
743
  for (const [lessonId, board] of Object.entries(imported.boards)) await store.writeJson(store.paths.board(course.id, lessonId), board);
@@ -830,6 +868,150 @@ const BOARD_FONT_FILES = Object.freeze([
830
868
  })
831
869
  ]);
832
870
  //#endregion
871
+ //#region src/host/jobs.js
872
+ const KEEP_FINISHED_MS = 10 * 6e4;
873
+ const MAX_JOBS = 200;
874
+ function createJobs({ logger, now = Date.now } = {}) {
875
+ const jobs = /* @__PURE__ */ new Map();
876
+ const byKey = /* @__PURE__ */ new Map();
877
+ function sweep() {
878
+ const cutoff = now() - KEEP_FINISHED_MS;
879
+ for (const [id, job] of jobs) if (job.finishedAt && job.finishedAt < cutoff) {
880
+ jobs.delete(id);
881
+ if (job.key && byKey.get(job.key) === id) byKey.delete(job.key);
882
+ }
883
+ if (jobs.size > MAX_JOBS) {
884
+ const finished = [...jobs.values()].filter((job) => job.finishedAt).sort((a, b) => a.finishedAt - b.finishedAt);
885
+ for (const job of finished.slice(0, jobs.size - MAX_JOBS)) jobs.delete(job.id);
886
+ }
887
+ }
888
+ function start(kind, run, { key, meta } = {}) {
889
+ sweep();
890
+ if (key && byKey.has(key)) {
891
+ const existing = jobs.get(byKey.get(key));
892
+ if (existing && !existing.finishedAt && !existing.controller.signal.aborted) return snapshot(existing);
893
+ }
894
+ const controller = new AbortController();
895
+ const job = {
896
+ id: newId("job"),
897
+ kind,
898
+ key: key ?? null,
899
+ meta: meta ?? {},
900
+ status: "running",
901
+ progress: {
902
+ done: 0,
903
+ total: 0,
904
+ label: ""
905
+ },
906
+ partial: [],
907
+ preview: null,
908
+ result: void 0,
909
+ error: void 0,
910
+ startedAt: now(),
911
+ finishedAt: null,
912
+ controller
913
+ };
914
+ jobs.set(job.id, job);
915
+ if (key) byKey.set(key, job.id);
916
+ const api = {
917
+ signal: controller.signal,
918
+ progress(done, total, label = job.progress.label) {
919
+ job.progress = {
920
+ done,
921
+ total,
922
+ label
923
+ };
924
+ },
925
+ push(item) {
926
+ job.partial.push(item);
927
+ },
928
+ preview(value) {
929
+ job.preview = value ?? null;
930
+ }
931
+ };
932
+ const aborted = new Promise((_, reject) => {
933
+ controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? /* @__PURE__ */ new Error("cancelled")), { once: true });
934
+ });
935
+ Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
936
+ job.status = "done";
937
+ job.result = result;
938
+ }).catch((error) => {
939
+ job.status = controller.signal.aborted ? "cancelled" : "failed";
940
+ job.error = controller.signal.aborted ? {
941
+ code: "cancelled",
942
+ message: "Cancelled"
943
+ } : {
944
+ code: publicCode(error),
945
+ message: publicMessage(error)
946
+ };
947
+ if (job.status === "failed") logger?.warn?.("aha-tutor job %s failed: %s", kind, error?.message);
948
+ }).finally(() => {
949
+ job.finishedAt = now();
950
+ });
951
+ return snapshot(job);
952
+ }
953
+ function snapshot(job, { since = 0 } = {}) {
954
+ return {
955
+ id: job.id,
956
+ kind: job.kind,
957
+ meta: job.meta,
958
+ status: job.status,
959
+ progress: job.progress,
960
+ partial: job.partial.slice(since),
961
+ partialCount: job.partial.length,
962
+ preview: job.preview ?? void 0,
963
+ elapsedMs: (job.finishedAt ?? now()) - job.startedAt,
964
+ result: job.status === "done" ? job.result : void 0,
965
+ error: job.error
966
+ };
967
+ }
968
+ function get(id, options) {
969
+ const job = jobs.get(id);
970
+ return job ? snapshot(job, options) : null;
971
+ }
972
+ function cancel(id) {
973
+ const job = jobs.get(id);
974
+ if (job && !job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("cancelled"));
975
+ return job ? snapshot(job) : null;
976
+ }
977
+ function running(key) {
978
+ const id = byKey.get(key);
979
+ const job = id ? jobs.get(id) : void 0;
980
+ return job && !job.finishedAt && !job.controller.signal.aborted ? snapshot(job) : null;
981
+ }
982
+ function cancelKey(key) {
983
+ const job = running(key);
984
+ return job ? cancel(job.id) : null;
985
+ }
986
+ function dispose() {
987
+ for (const job of jobs.values()) if (!job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("plugin stopped"));
988
+ jobs.clear();
989
+ byKey.clear();
990
+ }
991
+ return Object.freeze({
992
+ start,
993
+ get,
994
+ cancel,
995
+ cancelKey,
996
+ running,
997
+ dispose
998
+ });
999
+ }
1000
+ function publicMessage(error) {
1001
+ if (error?.userMessage) return String(error.userMessage);
1002
+ if (USER_ERRORS$1.includes(error?.name)) return String(error.message);
1003
+ return "Generation failed";
1004
+ }
1005
+ function publicCode(error) {
1006
+ if (!USER_ERRORS$1.includes(error?.name)) return "failed";
1007
+ return error.name === "TtsError" ? `tts-${error.code ?? "failed"}` : error.code ?? "failed";
1008
+ }
1009
+ const USER_ERRORS$1 = [
1010
+ "MaterialError",
1011
+ "TtsError",
1012
+ "TutorError"
1013
+ ];
1014
+ //#endregion
833
1015
  //#region src/host/web-fetch.js
834
1016
  const LOCAL_ADDRESS_MESSAGE = "Local network addresses are not supported";
835
1017
  var AddressError = class extends Error {
@@ -1096,7 +1278,7 @@ function detectKind(name, bytes) {
1096
1278
  if (head[0] === 137 && head[1] === 80) return "image";
1097
1279
  if (head[0] === 255 && head[1] === 216) return "image";
1098
1280
  if (head.subarray(0, 2).toString("latin1") === "PK") {
1099
- if (lower.endsWith(".doc") || lower.endsWith(".ppt")) throw new MaterialError("unsupported", "Old .doc/.ppt files are not supported; save as .docx/.pptx");
1281
+ if (lower.endsWith(".doc") || lower.endsWith(".ppt")) throw new MaterialError("old-office", "Old .doc/.ppt files are not supported; save as .docx/.pptx");
1100
1282
  }
1101
1283
  throw new MaterialError("unsupported", "This file type is not supported");
1102
1284
  }
@@ -1350,7 +1532,7 @@ function extractPlain(bytes, kind) {
1350
1532
  chunks.push(...chunkText(part, heading ? { section: heading.slice(0, 80) } : {}));
1351
1533
  }
1352
1534
  } else chunks.push(...chunkText(value, {}));
1353
- if (!chunks.length) throw new MaterialError("no-text", "This file is empty");
1535
+ if (!chunks.length) throw new MaterialError("empty", "This file is empty");
1354
1536
  return {
1355
1537
  title: kind === "markdown" ? /^#\s+(.+)$/mu.exec(value)?.[1]?.trim() ?? "" : "",
1356
1538
  chunks
@@ -1362,7 +1544,7 @@ async function ingestFile({ name, bytes, signal }) {
1362
1544
  const kind = detectKind(name, bytes);
1363
1545
  const baseTitle = String(name ?? "").replace(/\.[^.]+$/u, "").slice(0, 120) || "Untitled";
1364
1546
  if (kind === "image") {
1365
- if (bytes.length > 10485760) throw new MaterialError("too-large", "Images must be smaller than 10 MB");
1547
+ if (bytes.length > 10485760) throw new MaterialError("image-too-large", "Images must be smaller than 10 MB");
1366
1548
  return {
1367
1549
  kind,
1368
1550
  title: baseTitle,
@@ -1417,7 +1599,7 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
1417
1599
  } catch (error) {
1418
1600
  if (signal?.aborted) throw signal.reason ?? error;
1419
1601
  if (error?.name === "AddressError") throw new MaterialError("bad-url", error.message);
1420
- if (error?.code === "ETOOLARGE") throw new MaterialError("too-large", "The page is too large");
1602
+ if (error?.code === "ETOOLARGE") throw new MaterialError("page-too-large", "The page is too large");
1421
1603
  throw new MaterialError("network", timeout.aborted ? "The page took too long to load" : "The page could not be loaded");
1422
1604
  }
1423
1605
  if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
@@ -1429,7 +1611,7 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
1429
1611
  if (!response?.ok) throw new MaterialError("http", `The page returned an error (${response?.status ?? "redirect loop"})`);
1430
1612
  const type = String(response.headers.get("content-type") ?? "").toLowerCase();
1431
1613
  const bytes = Buffer.from(await response.arrayBuffer());
1432
- if (bytes.length > MAX_PAGE_BYTES && !type.includes("pdf")) throw new MaterialError("too-large", "The page is too large");
1614
+ if (bytes.length > MAX_PAGE_BYTES && !type.includes("pdf")) throw new MaterialError("page-too-large", "The page is too large");
1433
1615
  if (type.includes("application/pdf") || bytes.subarray(0, 5).toString("latin1") === "%PDF-") return {
1434
1616
  ...await ingestFile({
1435
1617
  name: `${url.pathname.split("/").pop() || "page"}.pdf`,
@@ -1455,14 +1637,77 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
1455
1637
  }
1456
1638
  const title = String(article?.title || document.querySelector("title")?.textContent || url.hostname).trim().slice(0, 160);
1457
1639
  const chunks = chunkText(article?.textContent || document.body?.textContent || "", { section: url.hostname });
1458
- if (!chunks.length) throw new MaterialError("no-text", "No readable text on this page");
1640
+ if (!chunks.length) throw new MaterialError("page-no-text", "No readable text on this page");
1459
1641
  return finish("url", title, chunks, { url: url.href });
1460
1642
  }
1643
+ const BYTES_PER_SECOND = 32e3;
1644
+ const MAX_SPEECH_BYTES = 3840044;
1645
+ const MAX_TEXT = 2e3;
1646
+ const USABLE = /* @__PURE__ */ new Set(["ready", "standby"]);
1647
+ const PREPARING = /* @__PURE__ */ new Set([
1648
+ "checking",
1649
+ "downloading",
1650
+ "cancelling"
1651
+ ]);
1652
+ function wavSeconds(bytes) {
1653
+ const data = bytes instanceof Uint8Array ? bytes : null;
1654
+ if (!data || data.length < 46) return null;
1655
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
1656
+ const tag = (at) => String.fromCharCode(...data.subarray(at, at + 4));
1657
+ return tag(0) === "RIFF" && tag(8) === "WAVE" && tag(12) === "fmt " && tag(36) === "data" && view.getUint32(4, true) === data.length - 8 && view.getUint32(16, true) === 16 && view.getUint16(20, true) === 1 && view.getUint16(22, true) === 1 && view.getUint32(24, true) === 16e3 && view.getUint32(28, true) === BYTES_PER_SECOND && view.getUint16(32, true) === 2 && view.getUint16(34, true) === 16 && view.getUint32(40, true) === data.length - 44 && (data.length - 44) % 2 === 0 ? (data.length - 44) / BYTES_PER_SECOND : null;
1658
+ }
1659
+ function createSpeechInput({ getSpeech, logger }) {
1660
+ function status() {
1661
+ const speech = getSpeech();
1662
+ let snapshot = null;
1663
+ try {
1664
+ snapshot = speech?.snapshot?.() ?? null;
1665
+ } catch {
1666
+ snapshot = null;
1667
+ }
1668
+ const chosen = snapshot?.selection?.providerId;
1669
+ const provider = snapshot?.providers?.find((entry) => entry.id === chosen);
1670
+ if (!provider) return {
1671
+ state: "off",
1672
+ maxSeconds: 120
1673
+ };
1674
+ const phase = provider.preparation?.phase ?? "ready";
1675
+ return {
1676
+ state: USABLE.has(phase) ? "ready" : PREPARING.has(phase) ? "preparing" : phase === "failed" ? "failed" : "unprepared",
1677
+ maxSeconds: 120
1678
+ };
1679
+ }
1680
+ async function transcribe({ audio, signal }) {
1681
+ const { state } = status();
1682
+ if (state === "off") throw new TutorError("speech-off", "Voice input is not enabled in DSH");
1683
+ if (state !== "ready") throw new TutorError("speech-unprepared", "The speech recognizer is not ready yet");
1684
+ const seconds = wavSeconds(audio);
1685
+ if (seconds === null) throw new TutorError("speech-audio", "The recording could not be read");
1686
+ if (seconds > 120) throw new TutorError("speech-too-long", `Recordings are limited to 120 seconds`);
1687
+ const speech = getSpeech();
1688
+ let result;
1689
+ try {
1690
+ result = await speech.transcribe(speech.resolve({ audio: Buffer.from(audio.buffer, audio.byteOffset, audio.byteLength) }), signal ?? new AbortController().signal);
1691
+ } catch (error) {
1692
+ if (signal?.aborted) throw error;
1693
+ logger?.debug?.("aha-tutor speech recognition failed: %s", error?.message);
1694
+ throw new TutorError("speech-failed", "Speech recognition failed");
1695
+ }
1696
+ const text = String(typeof result === "string" ? result : result?.text ?? "").replace(/\s+/gu, " ").trim().slice(0, MAX_TEXT);
1697
+ if (!text) throw new TutorError("speech-empty", "No speech was heard");
1698
+ return { text };
1699
+ }
1700
+ return Object.freeze({
1701
+ status,
1702
+ transcribe
1703
+ });
1704
+ }
1461
1705
  //#endregion
1462
1706
  //#region src/host/http.js
1463
1707
  const UPLOAD_PATH = "/api/aha-tutor/upload";
1464
1708
  const TTS_PATH = "/api/aha-tutor/tts";
1465
1709
  const FONT_PATH = "/api/aha-tutor/font";
1710
+ const SPEECH_PATH = "/api/aha-tutor/speech";
1466
1711
  async function readCapped(stream, max, signal) {
1467
1712
  const parts = [];
1468
1713
  let size = 0;
@@ -1510,7 +1755,35 @@ function uploadRoute({ studio }) {
1510
1755
  }) }
1511
1756
  });
1512
1757
  } catch (error) {
1513
- return failure$1(error?.code ?? "failed", error?.name === "TutorError" ? error.message : "Upload failed", 400);
1758
+ return failure$1(publicCode(error), error?.name === "TutorError" ? error.message : "Upload failed", 400);
1759
+ }
1760
+ }
1761
+ };
1762
+ }
1763
+ function speechRoute({ speechInput }) {
1764
+ return {
1765
+ path: SPEECH_PATH,
1766
+ methods: ["POST"],
1767
+ requestBody: "streaming",
1768
+ fetch: async (request) => {
1769
+ let audio;
1770
+ try {
1771
+ audio = await readCapped(request.body, MAX_SPEECH_BYTES, request.signal);
1772
+ } catch {
1773
+ return failure$1("aborted", "Upload interrupted", 400);
1774
+ }
1775
+ if (audio === null) return failure$1("speech-too-long", "The recording is too long", 413);
1776
+ try {
1777
+ return json({
1778
+ ok: true,
1779
+ value: await speechInput.transcribe({
1780
+ audio: new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength),
1781
+ signal: request.signal
1782
+ })
1783
+ });
1784
+ } catch (error) {
1785
+ const known = error?.name === "TutorError";
1786
+ return failure$1(known ? publicCode(error) : "speech-failed", known ? error.message : "Speech recognition failed", known && error.code !== "speech-failed" ? 400 : 502);
1514
1787
  }
1515
1788
  }
1516
1789
  };
@@ -1541,7 +1814,7 @@ function ttsRoute({ speak }) {
1541
1814
  });
1542
1815
  } catch (error) {
1543
1816
  const known = error?.name === "TtsError" || error?.name === "TutorError";
1544
- return failure$1(error?.code ?? "tts", known ? error.message : "Speech synthesis failed", error?.code === "auth" ? 401 : 502);
1817
+ return failure$1(known ? publicCode(error) : "tts-failed", known ? error.message : "Speech synthesis failed", ["auth", "no-key"].includes(error?.code) ? 401 : 502);
1545
1818
  }
1546
1819
  }
1547
1820
  };
@@ -1575,144 +1848,6 @@ function fontRoute({ dir = fontsDir() } = {}) {
1575
1848
  };
1576
1849
  }
1577
1850
  //#endregion
1578
- //#region src/host/jobs.js
1579
- const KEEP_FINISHED_MS = 10 * 6e4;
1580
- const MAX_JOBS = 200;
1581
- function createJobs({ logger, now = Date.now } = {}) {
1582
- const jobs = /* @__PURE__ */ new Map();
1583
- const byKey = /* @__PURE__ */ new Map();
1584
- function sweep() {
1585
- const cutoff = now() - KEEP_FINISHED_MS;
1586
- for (const [id, job] of jobs) if (job.finishedAt && job.finishedAt < cutoff) {
1587
- jobs.delete(id);
1588
- if (job.key && byKey.get(job.key) === id) byKey.delete(job.key);
1589
- }
1590
- if (jobs.size > MAX_JOBS) {
1591
- const finished = [...jobs.values()].filter((job) => job.finishedAt).sort((a, b) => a.finishedAt - b.finishedAt);
1592
- for (const job of finished.slice(0, jobs.size - MAX_JOBS)) jobs.delete(job.id);
1593
- }
1594
- }
1595
- function start(kind, run, { key, meta } = {}) {
1596
- sweep();
1597
- if (key && byKey.has(key)) {
1598
- const existing = jobs.get(byKey.get(key));
1599
- if (existing && !existing.finishedAt && !existing.controller.signal.aborted) return snapshot(existing);
1600
- }
1601
- const controller = new AbortController();
1602
- const job = {
1603
- id: newId("job"),
1604
- kind,
1605
- key: key ?? null,
1606
- meta: meta ?? {},
1607
- status: "running",
1608
- progress: {
1609
- done: 0,
1610
- total: 0,
1611
- label: ""
1612
- },
1613
- partial: [],
1614
- preview: null,
1615
- result: void 0,
1616
- error: void 0,
1617
- startedAt: now(),
1618
- finishedAt: null,
1619
- controller
1620
- };
1621
- jobs.set(job.id, job);
1622
- if (key) byKey.set(key, job.id);
1623
- const api = {
1624
- signal: controller.signal,
1625
- progress(done, total, label = job.progress.label) {
1626
- job.progress = {
1627
- done,
1628
- total,
1629
- label
1630
- };
1631
- },
1632
- push(item) {
1633
- job.partial.push(item);
1634
- },
1635
- preview(value) {
1636
- job.preview = value ?? null;
1637
- }
1638
- };
1639
- const aborted = new Promise((_, reject) => {
1640
- controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? /* @__PURE__ */ new Error("cancelled")), { once: true });
1641
- });
1642
- Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
1643
- job.status = "done";
1644
- job.result = result;
1645
- }).catch((error) => {
1646
- job.status = controller.signal.aborted ? "cancelled" : "failed";
1647
- job.error = controller.signal.aborted ? {
1648
- code: "cancelled",
1649
- message: "Cancelled"
1650
- } : {
1651
- code: error?.code ?? "failed",
1652
- message: publicMessage(error)
1653
- };
1654
- if (job.status === "failed") logger?.warn?.("aha-tutor job %s failed: %s", kind, error?.message);
1655
- }).finally(() => {
1656
- job.finishedAt = now();
1657
- });
1658
- return snapshot(job);
1659
- }
1660
- function snapshot(job, { since = 0 } = {}) {
1661
- return {
1662
- id: job.id,
1663
- kind: job.kind,
1664
- meta: job.meta,
1665
- status: job.status,
1666
- progress: job.progress,
1667
- partial: job.partial.slice(since),
1668
- partialCount: job.partial.length,
1669
- preview: job.preview ?? void 0,
1670
- result: job.status === "done" ? job.result : void 0,
1671
- error: job.error
1672
- };
1673
- }
1674
- function get(id, options) {
1675
- const job = jobs.get(id);
1676
- return job ? snapshot(job, options) : null;
1677
- }
1678
- function cancel(id) {
1679
- const job = jobs.get(id);
1680
- if (job && !job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("cancelled"));
1681
- return job ? snapshot(job) : null;
1682
- }
1683
- function running(key) {
1684
- const id = byKey.get(key);
1685
- const job = id ? jobs.get(id) : void 0;
1686
- return job && !job.finishedAt && !job.controller.signal.aborted ? snapshot(job) : null;
1687
- }
1688
- function cancelKey(key) {
1689
- const job = running(key);
1690
- return job ? cancel(job.id) : null;
1691
- }
1692
- function dispose() {
1693
- for (const job of jobs.values()) if (!job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("plugin stopped"));
1694
- jobs.clear();
1695
- byKey.clear();
1696
- }
1697
- return Object.freeze({
1698
- start,
1699
- get,
1700
- cancel,
1701
- cancelKey,
1702
- running,
1703
- dispose
1704
- });
1705
- }
1706
- function publicMessage(error) {
1707
- if (error?.userMessage) return String(error.userMessage);
1708
- if ([
1709
- "MaterialError",
1710
- "TtsError",
1711
- "TutorError"
1712
- ].includes(error?.name)) return String(error.message);
1713
- return "Generation failed";
1714
- }
1715
- //#endregion
1716
1851
  //#region src/core/planner.js
1717
1852
  const DAY_MS$1 = 1440 * 60 * 1e3;
1718
1853
  const HORIZON_DAYS = 400;
@@ -1756,7 +1891,7 @@ function clampNumber(value, min, max, fallback) {
1756
1891
  if (!Number.isFinite(number)) return fallback;
1757
1892
  return Math.min(max, Math.max(min, number));
1758
1893
  }
1759
- function buildPlan({ today, settings, courses = [], reviews = [], exams = [] }) {
1894
+ function buildPlan({ today, settings, courses = [], reviews = [], exams = [], spentToday = 0 }) {
1760
1895
  const start = today ?? toDay(/* @__PURE__ */ new Date());
1761
1896
  const { studyDays, weeklyMinutes, dailyMinutes } = normalizeSettings(settings);
1762
1897
  const queues = courses.map((course) => ({
@@ -1790,8 +1925,9 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
1790
1925
  minutes: 0
1791
1926
  }));
1792
1927
  if (studyDays.includes(weekday)) {
1793
- let budget = dailyMinutes;
1794
- const reviewBudget = Math.round(dailyMinutes * REVIEW_SHARE);
1928
+ const spent = offset === 0 ? Math.max(0, Number(spentToday) || 0) : 0;
1929
+ let budget = dailyMinutes - spent;
1930
+ const reviewBudget = Math.min(Math.max(0, budget), Math.round(dailyMinutes * REVIEW_SHARE));
1795
1931
  let reviewUsed = 0;
1796
1932
  while (pendingReviews.length && pendingReviews[0].dueDay <= day && reviewUsed + pendingReviews[0].minutes <= reviewBudget) {
1797
1933
  const review = pendingReviews.shift();
@@ -1801,6 +1937,8 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
1801
1937
  refId: review.id,
1802
1938
  courseId: review.courseId ?? null,
1803
1939
  title: review.title,
1940
+ courseTitle: review.courseTitle ?? "",
1941
+ courseEmoji: review.courseEmoji ?? "",
1804
1942
  minutes: review.minutes
1805
1943
  });
1806
1944
  }
@@ -1813,7 +1951,7 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
1813
1951
  for (const queue of ordered) {
1814
1952
  const lesson = queue.remaining[0];
1815
1953
  if (!lesson) continue;
1816
- const dayIsEmpty = !items.some((item) => item.kind === "lesson");
1954
+ const dayIsEmpty = !spent && !items.some((item) => item.kind === "lesson");
1817
1955
  if (lesson.minutes <= budget || dayIsEmpty) {
1818
1956
  queue.remaining.shift();
1819
1957
  budget -= lesson.minutes;
@@ -1899,9 +2037,61 @@ function neededWeeklyMinutes(course, start, deadline, studyDays) {
1899
2037
  const perDay = remaining / studyDayCount / (1 - REVIEW_SHARE);
1900
2038
  return Math.ceil(perDay * studyDays.length / 10) * 10;
1901
2039
  }
2040
+ const itemKey = (item) => `${item.kind}:${item.refId}`;
2041
+ function mergeAgenda(saved = [], planned = []) {
2042
+ const seen = new Set(saved.map(itemKey));
2043
+ return [...saved, ...planned.filter((item) => !seen.has(itemKey(item)))];
2044
+ }
2045
+ function planCalendar({ today, agenda = {}, plan, done = {}, checks = [], ahead = 120 }) {
2046
+ const lessonsDone = done.lessons ?? /* @__PURE__ */ new Map();
2047
+ const reviewsDone = done.reviews ?? /* @__PURE__ */ new Map();
2048
+ const last = addDays(today, ahead);
2049
+ const days = /* @__PURE__ */ new Map();
2050
+ const put = (day, item) => {
2051
+ const list = days.get(day) ?? [];
2052
+ if (!list.some((entry) => itemKey(entry) === itemKey(item))) list.push(item);
2053
+ days.set(day, list);
2054
+ };
2055
+ for (const [day, items] of Object.entries(agenda)) if (day <= today) for (const item of items) put(day, item);
2056
+ for (const entry of plan?.days ?? []) if (entry.day >= today && entry.day <= last) for (const item of entry.items) put(entry.day, item);
2057
+ for (const { day, item } of lessonsDone.values()) if (day <= today) put(day, item);
2058
+ for (const check of checks) if (check.day <= today && check.item) put(check.day, check.item);
2059
+ const status = (item, day) => {
2060
+ if (day > today || item.kind === "exam") return false;
2061
+ if (checks.some((check) => check.refId === item.refId && check.day === day)) return "manual";
2062
+ if (item.kind === "lesson" && lessonsDone.get(item.refId)?.day === day) return "auto";
2063
+ if (item.kind === "review" && reviewsDone.get(item.refId) === day) return "auto";
2064
+ return false;
2065
+ };
2066
+ const out = [...days.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, items]) => ({
2067
+ day,
2068
+ items: items.map((item) => ({
2069
+ ...item,
2070
+ checked: status(item, day)
2071
+ }))
2072
+ }));
2073
+ return {
2074
+ today: out.find((entry) => entry.day === today)?.items ?? [],
2075
+ days: out
2076
+ };
2077
+ }
1902
2078
  //#endregion
1903
2079
  //#region src/host/learner.js
1904
2080
  const PROFILE_REFRESH_MS = 10 * 6e4;
2081
+ function agendaItem(raw) {
2082
+ const kind = raw?.kind === "lesson" || raw?.kind === "review" ? raw.kind : null;
2083
+ const refId = text(raw?.refId, 80);
2084
+ if (!kind || !refId) return null;
2085
+ return {
2086
+ kind,
2087
+ refId,
2088
+ courseId: /^crs_[a-z0-9]{6,40}$/u.test(String(raw.courseId)) ? raw.courseId : null,
2089
+ title: text(raw.title, 160),
2090
+ courseTitle: text(raw.courseTitle, 160),
2091
+ courseEmoji: text(raw.courseEmoji, 8),
2092
+ minutes: Math.min(240, Math.max(0, Math.round(Number(raw.minutes) || 0)))
2093
+ };
2094
+ }
1905
2095
  function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), logger }) {
1906
2096
  let lastRefresh = 0;
1907
2097
  let refreshing = null;
@@ -1991,9 +2181,12 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
1991
2181
  0
1992
2182
  ]
1993
2183
  },
1994
- exams: Array.isArray(plan.exams) ? plan.exams : []
2184
+ exams: Array.isArray(plan.exams) ? plan.exams : [],
2185
+ checks: Array.isArray(plan.checks) ? plan.checks : [],
2186
+ agenda: plan.agenda && typeof plan.agenda === "object" ? plan.agenda : {}
1995
2187
  };
1996
2188
  }
2189
+ const validDay = (value) => /^\d{4}-\d{2}-\d{2}$/u.test(String(value ?? "")) ? value : null;
1997
2190
  async function activeCourses() {
1998
2191
  const ids = await store.listIds(store.paths.courses(), "crs");
1999
2192
  const result = [];
@@ -2009,25 +2202,56 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2009
2202
  }
2010
2203
  async function plan({ today } = {}) {
2011
2204
  const file = await readPlanFile();
2012
- const day = /^\d{4}-\d{2}-\d{2}$/u.test(String(today ?? "")) ? today : toDay(now());
2205
+ const day = validDay(today) ?? toDay(now());
2013
2206
  const active = await activeCourses();
2207
+ const tickedLessons = new Set(file.checks.filter((check) => check.kind === "lesson" && check.day <= day).map((check) => check.refId));
2208
+ const tickedToday = new Set(file.checks.filter((check) => check.day === day).map((check) => check.refId));
2014
2209
  const reviews = [];
2210
+ const done = {
2211
+ lessons: /* @__PURE__ */ new Map(),
2212
+ reviews: /* @__PURE__ */ new Map()
2213
+ };
2015
2214
  for (const { course, progress } of active) {
2215
+ const label = {
2216
+ courseId: course.id,
2217
+ courseTitle: course.title,
2218
+ courseEmoji: course.emoji ?? ""
2219
+ };
2016
2220
  const index = conceptIndex(course);
2017
2221
  for (const [conceptId, card] of Object.entries(progress.reviews ?? {})) {
2018
2222
  const concept = index.get(conceptId);
2019
2223
  if (!concept) continue;
2020
- reviews.push({
2021
- id: `${course.id}:${conceptId}`,
2022
- courseId: course.id,
2224
+ const id = `${course.id}:${conceptId}`;
2225
+ if (card.lastReviewedAt) done.reviews.set(id, toDay(new Date(card.lastReviewedAt)));
2226
+ if (!tickedToday.has(id)) reviews.push({
2227
+ id,
2228
+ ...label,
2023
2229
  title: concept.name,
2024
2230
  dueDay: toDay(new Date(card.dueAt)),
2025
2231
  minutes: 5
2026
2232
  });
2027
2233
  }
2234
+ for (const { lesson } of flattenLessons(course)) {
2235
+ const record = progress.lessons?.[lesson.id];
2236
+ if (record?.status === "done" && record.completedAt) done.lessons.set(lesson.id, {
2237
+ day: toDay(new Date(record.completedAt)),
2238
+ item: {
2239
+ kind: "lesson",
2240
+ refId: lesson.id,
2241
+ title: lesson.title,
2242
+ minutes: lesson.estMinutes,
2243
+ ...label
2244
+ }
2245
+ });
2246
+ }
2028
2247
  }
2248
+ const spent = /* @__PURE__ */ new Map();
2249
+ for (const { day: doneDay, item } of done.lessons.values()) if (doneDay === day) spent.set(item.refId, item.minutes);
2250
+ for (const item of file.agenda[day] ?? []) if (item.kind === "review" && (done.reviews.get(item.refId) === day || tickedToday.has(item.refId))) spent.set(item.refId, item.minutes);
2251
+ for (const check of file.checks) if (check.day === day && check.item) spent.set(check.refId, check.item.minutes);
2029
2252
  const built = buildPlan({
2030
2253
  today: day,
2254
+ spentToday: [...spent.values()].reduce((sum, minutes) => sum + (Number(minutes) || 0), 0),
2031
2255
  settings: file.settings,
2032
2256
  courses: active.map(({ course, progress }) => ({
2033
2257
  id: course.id,
@@ -2038,7 +2262,7 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2038
2262
  id: lesson.id,
2039
2263
  title: lesson.title,
2040
2264
  minutes: lesson.estMinutes,
2041
- done: progress.lessons?.[lesson.id]?.status === "done"
2265
+ done: progress.lessons?.[lesson.id]?.status === "done" || tickedLessons.has(lesson.id)
2042
2266
  }))
2043
2267
  })),
2044
2268
  reviews,
@@ -2049,13 +2273,57 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2049
2273
  courseId: exam.courseId ?? null
2050
2274
  }))
2051
2275
  });
2276
+ const planned = (built.days.find((entry) => entry.day === day)?.items ?? []).filter((item) => item.kind !== "exam").map(agendaItem);
2277
+ const oldest = addDays(day, -180);
2278
+ const merged = mergeAgenda(file.agenda[day] ?? [], planned).slice(0, 40);
2279
+ let agenda = file.agenda;
2280
+ if (JSON.stringify(merged) !== JSON.stringify(file.agenda[day] ?? []) || Object.keys(file.agenda).some((key) => key < oldest)) agenda = (await store.updateJson(store.paths.plan(), {}, (current) => {
2281
+ const kept = Object.fromEntries(Object.entries(current.agenda ?? {}).filter(([key]) => validDay(key) && key >= oldest));
2282
+ kept[day] = mergeAgenda(kept[day] ?? [], planned).slice(0, 40);
2283
+ return {
2284
+ ...current,
2285
+ agenda: kept
2286
+ };
2287
+ })).agenda;
2288
+ const calendar = planCalendar({
2289
+ today: day,
2290
+ agenda,
2291
+ plan: built,
2292
+ done,
2293
+ checks: file.checks
2294
+ });
2052
2295
  return {
2053
2296
  settings: file.settings,
2054
2297
  exams: file.exams,
2055
2298
  plan: built,
2056
- today: built.days.find((entry) => entry.day === day)?.items ?? []
2299
+ today: calendar.today,
2300
+ calendar: calendar.days
2057
2301
  };
2058
2302
  }
2303
+ async function check({ today, day, item }) {
2304
+ const current = validDay(today) ?? toDay(now());
2305
+ const when = validDay(day);
2306
+ const entry = agendaItem(item ?? {});
2307
+ if (!when || !entry || when > current) throw new TutorError("cannot-tick", "Only today or an earlier day can be ticked");
2308
+ await store.updateJson(store.paths.plan(), {}, (current) => ({
2309
+ ...current,
2310
+ checks: [...(current.checks ?? []).filter((existing) => !(existing.refId === entry.refId && existing.day === when)), {
2311
+ refId: entry.refId,
2312
+ kind: entry.kind,
2313
+ day: when,
2314
+ item: entry,
2315
+ at: now().toISOString()
2316
+ }].slice(-1e3)
2317
+ }));
2318
+ return plan({ today });
2319
+ }
2320
+ async function uncheck({ today, day, refId }) {
2321
+ await store.updateJson(store.paths.plan(), {}, (current) => ({
2322
+ ...current,
2323
+ checks: (current.checks ?? []).filter((existing) => !(existing.refId === refId && existing.day === day))
2324
+ }));
2325
+ return plan({ today });
2326
+ }
2059
2327
  async function savePlanSettings({ weeklyMinutes, studyDays }) {
2060
2328
  await store.updateJson(store.paths.plan(), {}, (current) => ({
2061
2329
  ...current,
@@ -2068,7 +2336,7 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2068
2336
  }
2069
2337
  async function addExam({ title, day, courseId }) {
2070
2338
  const name = text(title, 120);
2071
- if (!name || !/^\d{4}-\d{2}-\d{2}$/u.test(String(day))) throw new Error("Exam needs a title and a date");
2339
+ if (!name || !/^\d{4}-\d{2}-\d{2}$/u.test(String(day))) throw new TutorError("exam-incomplete", "Exam needs a title and a date");
2072
2340
  await store.updateJson(store.paths.plan(), {}, (current) => ({
2073
2341
  ...current,
2074
2342
  exams: [...current.exams ?? [], {
@@ -2122,6 +2390,8 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2122
2390
  removeNote,
2123
2391
  noteLesson,
2124
2392
  plan,
2393
+ check,
2394
+ uncheck,
2125
2395
  savePlanSettings,
2126
2396
  addExam,
2127
2397
  removeExam,
@@ -2220,6 +2490,7 @@ function round2(value) {
2220
2490
  //#region src/host/lessons.js
2221
2491
  const EVENTS_KEPT = 200;
2222
2492
  const STEP_ID = /^s\d{1,4}$/u;
2493
+ const BOARD_JOBS = ["lesson/board", "lesson/prefetch"];
2223
2494
  function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__PURE__ */ new Date() }) {
2224
2495
  const stamp = () => now().toISOString();
2225
2496
  async function context(courseId, lessonId) {
@@ -2263,7 +2534,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2263
2534
  mastery: Object.fromEntries(row.lesson.concepts.map((concept) => [concept.id, masteryPercent(progress.concepts?.[concept.id] ?? createConceptState(concept.id, concept.name))]))
2264
2535
  };
2265
2536
  }
2266
- async function buildBoard({ course, row, signal, onStep, directive, style }) {
2537
+ async function buildBoard({ course, row, signal, onStep, onProgress, directive, style }) {
2267
2538
  const progress = await readProgress(store, course.id);
2268
2539
  const profile = await readProfile(store);
2269
2540
  const materials = await readMaterials(store, course.materialIds ?? []);
@@ -2275,7 +2546,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2275
2546
  const kindDirective = row.lesson.kind === "remedial" ? "remediate" : row.lesson.kind === "review" ? "review" : row.lesson.kind === "challenge" ? "challenge" : void 0;
2276
2547
  return generateSteps(llm, {
2277
2548
  purpose: "teach",
2278
- effort: "low",
2549
+ effort: "lowest",
2279
2550
  prompt: lessonPrompt({
2280
2551
  course,
2281
2552
  unit: row.unit,
@@ -2295,6 +2566,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2295
2566
  signal,
2296
2567
  conceptIds: row.lesson.concepts.map((concept) => concept.id),
2297
2568
  onStep,
2569
+ onProgress,
2298
2570
  minSteps: 3
2299
2571
  });
2300
2572
  }
@@ -2319,6 +2591,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2319
2591
  row,
2320
2592
  signal: api.signal,
2321
2593
  onStep: (step) => api.push(step),
2594
+ onProgress: (info) => api.preview(info),
2322
2595
  style: text(style, 300) || void 0
2323
2596
  });
2324
2597
  const saved = {
@@ -2351,7 +2624,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2351
2624
  course,
2352
2625
  row: next,
2353
2626
  signal: api.signal,
2354
- onStep: (step) => api.push(step)
2627
+ onStep: (step) => api.push(step),
2628
+ onProgress: (info) => api.preview(info)
2355
2629
  });
2356
2630
  const saved = {
2357
2631
  ...board,
@@ -2371,10 +2645,18 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2371
2645
  });
2372
2646
  }).catch(() => void 0);
2373
2647
  }
2648
+ function followedJob(scope, kinds, meta) {
2649
+ const job = scope?.jobId ? jobs.get(scope.jobId) : null;
2650
+ return job && kinds.includes(job.kind) && Object.entries(meta).every(([key, value]) => job.meta?.[key] === value) ? job : null;
2651
+ }
2374
2652
  async function scopeBoard(scope) {
2375
2653
  if (scope?.kind === "lesson") {
2376
2654
  const { course, row } = await context(scope.courseId, scope.lessonId);
2377
- const board = await store.readJson(store.paths.board(course.id, row.lesson.id), null);
2655
+ const writing = followedJob(scope, BOARD_JOBS, {
2656
+ courseId: course.id,
2657
+ lessonId: row.lesson.id
2658
+ });
2659
+ const board = writing ? { steps: writing.partial } : await store.readJson(store.paths.board(course.id, row.lesson.id), null);
2378
2660
  return {
2379
2661
  course,
2380
2662
  lesson: row.lesson,
@@ -2383,18 +2665,20 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2383
2665
  };
2384
2666
  }
2385
2667
  if (scope?.kind === "thread" || scope?.kind === "short") {
2386
- const kind = scope.kind === "thread" ? "assist" : "shorts";
2387
- const item = await store.readJson(store.paths.item(kind, scope.id), null);
2388
- if (!item) throw new TutorError("not-found", "Not found");
2389
- const board = scope.kind === "thread" ? item.turns?.[Math.max(0, Math.min(item.turns.length - 1, Number(scope.turn) || 0))]?.board : item.board;
2668
+ const thread = scope.kind === "thread";
2669
+ const writing = thread ? followedJob(scope, ["assist/turn"], { threadId: scope.id }) : followedJob(scope, ["short/make"], { id: scope.id });
2670
+ const item = await store.readJson(store.paths.item(thread ? "assist" : "shorts", scope.id), null);
2671
+ if (!item && !writing) throw new TutorError("not-found", "Not found");
2672
+ const board = writing ? { steps: writing.partial } : thread ? item.turns?.[Math.max(0, Math.min(item.turns.length - 1, Number(scope.turn) || 0))]?.board : item.board;
2673
+ const language = item?.language ?? detectLanguage((board?.steps ?? []).map((step) => step.say).join(" "));
2390
2674
  return {
2391
2675
  course: null,
2392
2676
  lesson: {
2393
- title: item.title,
2677
+ title: item?.title ?? writing.meta?.title ?? "",
2394
2678
  objectives: []
2395
2679
  },
2396
2680
  board,
2397
- language: item.language
2681
+ language
2398
2682
  };
2399
2683
  }
2400
2684
  throw new TutorError("invalid", "Unknown board");
@@ -2412,7 +2696,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2412
2696
  const anchorFor = (board, stepIndex, anchorStepId) => STEP_ID.test(String(anchorStepId)) ? anchorStepId : board?.steps?.[Math.max(0, Number(stepIndex) || 0)]?.id ?? "s1";
2413
2697
  function ask({ scope, stepIndex = 0, question, anchorStepId }) {
2414
2698
  const request = text(question, 1500);
2415
- if (!request) throw new TutorError("invalid", "Type your question");
2699
+ if (!request) throw new TutorError("empty-question", "Type your question");
2416
2700
  return jobs.start("lesson/ask", async (api) => {
2417
2701
  const { course, lesson, board, language } = await scopeBoard(scope);
2418
2702
  const profile = await readProfile(store);
@@ -2454,11 +2738,11 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2454
2738
  function reexplain({ scope, stepIndex = 0, style = "example", anchorStepId }) {
2455
2739
  return jobs.start("lesson/reexplain", async (api) => {
2456
2740
  const { course, lesson, board, language } = await scopeBoard(scope);
2457
- if (!board?.steps?.length) throw new TutorError("invalid", "Nothing to re-explain yet");
2741
+ if (!board?.steps?.length) throw new TutorError("no-board", "The board is not written yet. Try again once its first step is up.");
2458
2742
  const index = Math.min(board.steps.length - 1, Math.max(0, Number(stepIndex) || 0));
2459
2743
  const answer = await generateSteps(llm, {
2460
2744
  purpose: "teach",
2461
- effort: "low",
2745
+ effort: "lowest",
2462
2746
  prompt: reexplainPrompt({
2463
2747
  lesson,
2464
2748
  language,
@@ -2943,12 +3227,22 @@ const REASONING_ROOM = Object.freeze({
2943
3227
  off: 0,
2944
3228
  none: 0,
2945
3229
  minimal: 4e3,
2946
- low: 8e3,
3230
+ low: 16e3,
2947
3231
  medium: 16e3,
2948
3232
  high: 24e3,
2949
3233
  xhigh: 48e3,
2950
3234
  max: 48e3
2951
3235
  });
3236
+ const EFFORT_ORDER = [
3237
+ "off",
3238
+ "none",
3239
+ "minimal",
3240
+ "low",
3241
+ "medium",
3242
+ "high",
3243
+ "xhigh",
3244
+ "max"
3245
+ ];
2952
3246
  const STOPPED_AT_LIMIT = /* @__PURE__ */ new Set(["max-tokens", "length"]);
2953
3247
  const CALL_TIMEOUT_MS = 6 * 6e4;
2954
3248
  const MAX_RETRIES = 2;
@@ -2973,7 +3267,10 @@ function toTutorError(code, fallback) {
2973
3267
  }
2974
3268
  function pickEffort(efforts, wanted) {
2975
3269
  if (!efforts?.length || !wanted) return void 0;
2976
- if (wanted === "lowest") return efforts[0].id;
3270
+ if (wanted === "lowest") {
3271
+ const rank = (effort) => EFFORT_ORDER.includes(effort.id) ? EFFORT_ORDER.indexOf(effort.id) : EFFORT_ORDER.length;
3272
+ return efforts.reduce((lightest, effort) => rank(effort) < rank(lightest) ? effort : lightest).id;
3273
+ }
2977
3274
  return (efforts.find((effort) => effort.id === wanted) ?? efforts.find((effort) => effort.id !== "off" && effort.id !== "none") ?? efforts[0]).id;
2978
3275
  }
2979
3276
  function outputBudget(maxTokens, effort, model) {
@@ -3007,7 +3304,7 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
3007
3304
  provider: selection.provider,
3008
3305
  model: selection.model
3009
3306
  };
3010
- throw new TutorError("llm-unavailable", "No model is configured in DSH yet.");
3307
+ throw new TutorError("llm-missing", "No model is configured in DSH yet.");
3011
3308
  }
3012
3309
  async function modalities(purpose, signal) {
3013
3310
  const llm = getLlm();
@@ -3041,9 +3338,9 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
3041
3338
  }
3042
3339
  return blocks;
3043
3340
  }
3044
- async function attempt({ purpose, effort, system, user, images, maxTokens, signal, onText }) {
3341
+ async function attempt({ purpose, effort, system, user, images, maxTokens, signal, onText, onReasoning }) {
3045
3342
  const llm = getLlm();
3046
- if (!llm) throw new TutorError("llm-unavailable", "DSH has no model service in this profile.");
3343
+ if (!llm) throw new TutorError("llm-missing", "DSH has no model service in this profile.");
3047
3344
  const target = route(purpose);
3048
3345
  const timeout = AbortSignal.timeout(CALL_TIMEOUT_MS);
3049
3346
  const fused = signal ? AbortSignal.any([signal, timeout]) : timeout;
@@ -3079,7 +3376,8 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
3079
3376
  })) if (chunk.type === "text-delta" && typeof chunk.text === "string") {
3080
3377
  textValue += chunk.text;
3081
3378
  onText?.(chunk.text);
3082
- } else if (chunk.type === "finish") finish = chunk.reason;
3379
+ } else if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") onReasoning?.(chunk.text);
3380
+ else if (chunk.type === "finish") finish = chunk.reason;
3083
3381
  } catch (error) {
3084
3382
  if (signal?.aborted) throw signal.reason ?? error;
3085
3383
  throw Object.assign(toTutorError(timeout.aborted ? "TIMEOUT" : error?.code, error?.message), {
@@ -3122,7 +3420,8 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
3122
3420
  return Object.freeze({
3123
3421
  complete: (options) => call({
3124
3422
  ...options,
3125
- onText: void 0
3423
+ onText: void 0,
3424
+ onReasoning: void 0
3126
3425
  }),
3127
3426
  stream: (options) => call(options),
3128
3427
  route,
@@ -3782,7 +4081,7 @@ function createProjects({ store, llm, jobs, courses, projectsRoot = resolveProje
3782
4081
  const content = await readWorkspace(folderOf(course, project));
3783
4082
  if (content) parts.push(`Files in the project folder:\n${content}`);
3784
4083
  }
3785
- if (!parts.length) throw new TutorError("invalid", "Nothing to review yet: write a note or work in the project folder first");
4084
+ if (!parts.length) throw new TutorError("nothing-to-review", "Nothing to review yet: write a note or work in the project folder first");
3786
4085
  const result = await generateJson(llm, {
3787
4086
  purpose: "teach",
3788
4087
  prompt: projectReviewPrompt({
@@ -3844,6 +4143,100 @@ function createProjects({ store, llm, jobs, courses, projectsRoot = resolveProje
3844
4143
  });
3845
4144
  }
3846
4145
  //#endregion
4146
+ //#region src/host/research.js
4147
+ const MAX_QUERIES = 3;
4148
+ const RESULTS_PER_QUERY = 6;
4149
+ const MAX_SOURCES = 10;
4150
+ const UNAVAILABLE = /* @__PURE__ */ new Set([
4151
+ "WEB_PROVIDER_UNAVAILABLE",
4152
+ "WEB_PROVIDER_CONFIGURED_MISSING",
4153
+ "WEB_PROVIDER_CONFIGURED_UNAVAILABLE",
4154
+ "WEB_PROVIDER_AMBIGUOUS"
4155
+ ]);
4156
+ function cleanQueries(raw, goal) {
4157
+ const queries = [...new Set((Array.isArray(raw) ? raw : []).map((query) => text(query, 120)).filter(Boolean))].slice(0, MAX_QUERIES);
4158
+ return queries.length ? queries : [text(goal, 120)].filter(Boolean);
4159
+ }
4160
+ function mergeSources(lists) {
4161
+ const seen = /* @__PURE__ */ new Set();
4162
+ const out = [];
4163
+ const longest = Math.max(0, ...lists.map((list) => list.length));
4164
+ for (let rank = 0; rank < longest && out.length < MAX_SOURCES; rank += 1) for (const list of lists) {
4165
+ const source = list[rank];
4166
+ const url = safeUrl(source?.url);
4167
+ if (!url || seen.has(url) || out.length >= MAX_SOURCES) continue;
4168
+ seen.add(url);
4169
+ out.push({
4170
+ url,
4171
+ title: text(source.title, 200) || new URL(url).hostname,
4172
+ snippet: text(source.snippet ?? source.text ?? source.excerpt, 400),
4173
+ publishedAt: text(source.publishedAt ?? source.date, 40)
4174
+ });
4175
+ }
4176
+ return out;
4177
+ }
4178
+ function safeUrl(value) {
4179
+ const raw = text(value, 500);
4180
+ if (!raw) return "";
4181
+ try {
4182
+ const url = new URL(raw);
4183
+ return url.protocol === "https:" || url.protocol === "http:" ? url.href : "";
4184
+ } catch {
4185
+ return "";
4186
+ }
4187
+ }
4188
+ function skipReason(error) {
4189
+ const code = String(error?.code ?? "");
4190
+ if (code === "WEB_PROVIDER_CREDENTIAL_MISSING") return "no-credential";
4191
+ if (UNAVAILABLE.has(code)) return "unavailable";
4192
+ return error ? "failed" : "empty";
4193
+ }
4194
+ function createResearch({ getWeb, llm, logger }) {
4195
+ async function run({ goal, language, signal, onSearching }) {
4196
+ const web = getWeb?.();
4197
+ if (typeof web?.search !== "function") return {
4198
+ skipped: "unavailable",
4199
+ sources: []
4200
+ };
4201
+ let queries;
4202
+ try {
4203
+ queries = await generateJson(llm, {
4204
+ purpose: "quick",
4205
+ prompt: researchQueriesPrompt({
4206
+ goal,
4207
+ language
4208
+ }),
4209
+ maxTokens: 400,
4210
+ signal,
4211
+ normalize: (raw) => cleanQueries(raw?.queries, goal)
4212
+ });
4213
+ } catch (error) {
4214
+ if (signal?.aborted) throw error;
4215
+ queries = cleanQueries([], goal);
4216
+ }
4217
+ onSearching?.(queries);
4218
+ const settled = await Promise.allSettled(queries.map((query) => web.search({
4219
+ query,
4220
+ maxResults: RESULTS_PER_QUERY
4221
+ }, signal)));
4222
+ signal?.throwIfAborted?.();
4223
+ const failure = settled.find((result) => result.status === "rejected")?.reason;
4224
+ if (failure) logger?.debug?.("aha-tutor web research: %s", failure?.code ?? failure?.message);
4225
+ const sources = mergeSources(settled.filter((result) => result.status === "fulfilled").map((result) => result.value?.sources ?? []));
4226
+ if (!sources.length) return {
4227
+ skipped: skipReason(failure),
4228
+ queries,
4229
+ sources: []
4230
+ };
4231
+ return {
4232
+ queries,
4233
+ sources,
4234
+ at: (/* @__PURE__ */ new Date()).toISOString()
4235
+ };
4236
+ }
4237
+ return Object.freeze({ run });
4238
+ }
4239
+ //#endregion
3847
4240
  //#region src/host/rpc.js
3848
4241
  const ID = (prefix) => new RegExp(`^${prefix}_[a-z0-9]{6,40}$`, "u");
3849
4242
  const IDS = {
@@ -3873,19 +4266,24 @@ function idList(value, prefix, max = 8) {
3873
4266
  }
3874
4267
  function scopeOf(payload) {
3875
4268
  const scope = payload?.scope ?? {};
4269
+ const jobId = optional(scope, "jobId", "job");
4270
+ const writing = jobId ? { jobId } : {};
3876
4271
  if (scope.kind === "lesson") return {
3877
4272
  kind: "lesson",
3878
4273
  courseId: need(scope, "courseId", "crs"),
3879
- lessonId: need(scope, "lessonId", "les")
4274
+ lessonId: need(scope, "lessonId", "les"),
4275
+ ...writing
3880
4276
  };
3881
4277
  if (scope.kind === "thread") return {
3882
4278
  kind: "thread",
3883
4279
  id: need(scope, "id", "thd"),
3884
- turn: Number(scope.turn) || 0
4280
+ turn: Number(scope.turn) || 0,
4281
+ ...writing
3885
4282
  };
3886
4283
  if (scope.kind === "short") return {
3887
4284
  kind: "short",
3888
- id: need(scope, "id", "sht")
4285
+ id: need(scope, "id", "sht"),
4286
+ ...writing
3889
4287
  };
3890
4288
  throw new TutorError("invalid", "Invalid board scope");
3891
4289
  }
@@ -3908,7 +4306,7 @@ const STYLES = [
3908
4306
  "simpler",
3909
4307
  "deeper"
3910
4308
  ];
3911
- function createRoutes({ courses, lessons, learner, studio, projects, jobs, settings, tts, plugin, info }) {
4309
+ function createRoutes({ courses, lessons, learner, studio, projects, jobs, settings, tts, plugin, info, speechInput }) {
3912
4310
  return {
3913
4311
  "app/info": () => info(),
3914
4312
  "job/get": (payload) => jobs.get(need(payload, "id", "job"), { since: Math.max(0, Number(payload.since) || 0) }) ?? { status: "missing" },
@@ -3919,6 +4317,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
3919
4317
  goal: payload.goal,
3920
4318
  materialIds: idList(payload.materialIds, "mat"),
3921
4319
  quick: payload.quick === true,
4320
+ web: payload.web === true,
3922
4321
  language: typeof payload.language === "string" ? payload.language.slice(0, 12) : void 0
3923
4322
  }) }),
3924
4323
  "course/proposal-apply": (payload) => courses.applyProposal({ courseId: need(payload, "courseId", "crs") }),
@@ -4066,6 +4465,16 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4066
4465
  "profile/clear": () => learner.clearProfile(),
4067
4466
  "profile/remove-note": (payload) => learner.removeNote({ id: need(payload, "id", "nte") }),
4068
4467
  "plan/get": (payload) => learner.plan({ today: payload.today }),
4468
+ "plan/check": (payload) => learner.check({
4469
+ today: payload.today,
4470
+ day: payload.day,
4471
+ item: payload.item
4472
+ }),
4473
+ "plan/uncheck": (payload) => learner.uncheck({
4474
+ today: payload.today,
4475
+ day: String(payload.day ?? "").slice(0, 10),
4476
+ refId: String(payload.refId ?? "").slice(0, 80)
4477
+ }),
4069
4478
  "plan/settings": (payload) => learner.savePlanSettings({
4070
4479
  weeklyMinutes: payload.weeklyMinutes,
4071
4480
  studyDays: payload.studyDays
@@ -4173,6 +4582,14 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4173
4582
  cached: result.cached === true
4174
4583
  };
4175
4584
  },
4585
+ "speech/status": () => speechInput.status(),
4586
+ "speech/transcribe": async (payload) => {
4587
+ const encoded = typeof payload.base64 === "string" ? payload.base64 : "";
4588
+ if (encoded.length > Math.ceil(3840044 / 3) * 4) throw new TutorError("speech-too-long", "The recording is too long");
4589
+ const audio = Buffer.from(encoded, "base64");
4590
+ if (!encoded || audio.toString("base64") !== encoded) throw new TutorError("speech-audio", "The recording could not be read");
4591
+ return speechInput.transcribe({ audio: new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength) });
4592
+ },
4176
4593
  "settings/get": () => settings.get(),
4177
4594
  "settings/save": (payload) => settings.save(payload.settings ?? {}),
4178
4595
  "settings/set-key": (payload) => settings.setKey({
@@ -4198,13 +4615,12 @@ function createRpcHandler(routes, { logger, checkPeer = isOwner } = {}) {
4198
4615
  };
4199
4616
  } catch (error) {
4200
4617
  if (signal?.aborted) throw error;
4201
- const known = [
4618
+ if (![
4202
4619
  "TutorError",
4203
4620
  "MaterialError",
4204
4621
  "TtsError"
4205
- ].includes(error?.name);
4206
- if (!known) logger?.warn?.("aha-tutor %s failed: %s", endpoint, error?.message);
4207
- return failure(known ? error.code ?? "failed" : "failed", known ? error.message : publicMessage(error));
4622
+ ].includes(error?.name)) logger?.warn?.("aha-tutor %s failed: %s", endpoint, error?.message);
4623
+ return failure(publicCode(error), publicMessage(error));
4208
4624
  }
4209
4625
  };
4210
4626
  }
@@ -4364,15 +4780,15 @@ function checkEndpoint(value) {
4364
4780
  try {
4365
4781
  url = new URL(String(value));
4366
4782
  } catch {
4367
- throw new TtsError("config", "TTS address is not a valid URL");
4783
+ throw new TtsError("address", "TTS address is not a valid URL");
4368
4784
  }
4369
4785
  const local = [
4370
4786
  "localhost",
4371
4787
  "127.0.0.1",
4372
4788
  "[::1]"
4373
4789
  ].includes(url.hostname);
4374
- if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) throw new TtsError("config", "TTS address must use https (http only for localhost)");
4375
- if (url.username || url.password) throw new TtsError("config", "TTS address must not contain credentials");
4790
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) throw new TtsError("address", "TTS address must use https (http only for localhost)");
4791
+ if (url.username || url.password) throw new TtsError("address", "TTS address must not contain credentials");
4376
4792
  return url;
4377
4793
  }
4378
4794
  const SENTENCE_END = /([。!?!?;;…]+|\.(?=\s)|\n+)/u;
@@ -4475,7 +4891,7 @@ const synthesizers = {
4475
4891
  return readBounded(response);
4476
4892
  },
4477
4893
  async volcengine({ text, config, secret, rate, fetchImpl, signal }) {
4478
- if (!config.appId) throw new TtsError("config", "Volcengine App ID is required");
4894
+ if (!config.appId) throw new TtsError("app-id", "Volcengine App ID is required");
4479
4895
  const response = await boundedFetch(fetchImpl, "https://openspeech.bytedance.com/api/v1/tts", {
4480
4896
  method: "POST",
4481
4897
  headers: {
@@ -4556,9 +4972,9 @@ const synthesizers = {
4556
4972
  },
4557
4973
  async azure({ text, config, secret, rate, fetchImpl, signal }) {
4558
4974
  const region = String(config.region || "").trim().toLowerCase();
4559
- if (!/^[a-z0-9]{2,30}$/u.test(region)) throw new TtsError("config", "Azure region is invalid");
4975
+ if (!/^[a-z0-9]{2,30}$/u.test(region)) throw new TtsError("azure", "Azure region is invalid");
4560
4976
  const voice = String(config.voice || "zh-CN-XiaoxiaoNeural");
4561
- if (!/^[A-Za-z]{2,3}-[A-Za-z]{2,4}-[A-Za-z0-9:]+$/u.test(voice)) throw new TtsError("config", "Azure voice name is invalid");
4977
+ if (!/^[A-Za-z]{2,3}-[A-Za-z]{2,4}-[A-Za-z0-9:]+$/u.test(voice)) throw new TtsError("azure", "Azure voice name is invalid");
4562
4978
  const lang = voice.split("-").slice(0, 2).join("-");
4563
4979
  const percent = Math.round((rateNumber(rate) - 1) * 100);
4564
4980
  const ssml = `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="${lang}"><voice name="${voice}"><prosody rate="${percent >= 0 ? "+" : ""}${percent}%">${xmlEscape(text)}</prosody></voice></speak>`;
@@ -4598,7 +5014,7 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
4598
5014
  async function synthesize({ provider, config = {}, text, rate = 1, signal }) {
4599
5015
  if (!TTS_PROVIDERS.includes(provider)) throw new TtsError("config", "Unknown TTS provider");
4600
5016
  const content = String(text ?? "").trim().slice(0, 4e3);
4601
- if (!content) throw new TtsError("request", "Nothing to speak");
5017
+ if (!content) throw new TtsError("empty", "Nothing to speak");
4602
5018
  const merged = {
4603
5019
  ...providerDefaults(provider),
4604
5020
  ...config
@@ -4614,7 +5030,7 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
4614
5030
  };
4615
5031
  } catch {}
4616
5032
  const secret = await getSecret(provider);
4617
- if (!secret) throw new TtsError("auth", "No key saved for this TTS provider");
5033
+ if (!secret) throw new TtsError("no-key", "No key saved for this TTS provider");
4618
5034
  const parts = [];
4619
5035
  for (const chunk of splitForTts(content, CHUNK_UNITS[provider])) parts.push(await synthesizers[provider]({
4620
5036
  text: chunk,
@@ -4681,6 +5097,7 @@ const PREF_DEFAULTS = Object.freeze({
4681
5097
  voiceRate: 1,
4682
5098
  boardTheme: "auto",
4683
5099
  boardFont: "hand",
5100
+ boardView: "canvas",
4684
5101
  ttsProvider: "openai",
4685
5102
  tts: Object.fromEntries(TTS_PROVIDERS.map((provider) => [provider, providerDefaults(provider)]))
4686
5103
  });
@@ -4711,6 +5128,7 @@ function cleanPrefs(input = {}, base = PREF_DEFAULTS) {
4711
5128
  "dark"
4712
5129
  ].includes(input.boardTheme)) next.boardTheme = input.boardTheme;
4713
5130
  if (["hand", "standard"].includes(input.boardFont)) next.boardFont = input.boardFont;
5131
+ if (["canvas", "pages"].includes(input.boardView)) next.boardView = input.boardView;
4714
5132
  if (TTS_PROVIDERS.includes(input.ttsProvider)) next.ttsProvider = input.ttsProvider;
4715
5133
  for (const provider of TTS_PROVIDERS) {
4716
5134
  const patch = input.tts?.[provider];
@@ -4747,7 +5165,7 @@ function createKeyStore({ getCredentials, credentialKey }) {
4747
5165
  };
4748
5166
  const service = () => {
4749
5167
  const credentials = getCredentials();
4750
- if (!credentials) throw new TutorError("unavailable", "DSH credentials are not available in this profile");
5168
+ if (!credentials) throw new TutorError("no-credentials", "DSH credentials are not available in this profile");
4751
5169
  return credentials;
4752
5170
  };
4753
5171
  return {
@@ -4766,7 +5184,7 @@ function createKeyStore({ getCredentials, credentialKey }) {
4766
5184
  },
4767
5185
  async write(provider, value) {
4768
5186
  const key = String(value ?? "").trim();
4769
- if (!/^[\x21-\x7E]{8,1024}$/u.test(key)) throw new TutorError("invalid", "The key looks wrong (8–1024 printable characters, no spaces)");
5187
+ if (!/^[\x21-\x7E]{8,1024}$/u.test(key)) throw new TutorError("bad-key", "The key looks wrong (8–1024 printable characters, no spaces)");
4770
5188
  await service().modifyRecord(keyOf(provider), async () => ({
4771
5189
  kind: "api-key",
4772
5190
  key
@@ -4928,7 +5346,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
4928
5346
  function digest({ id }) {
4929
5347
  return jobs.start("material/digest", async (api) => {
4930
5348
  const [material] = await readMaterials(store, [assertId(id, "mat")]);
4931
- if (!material?.chunks.length) throw new TutorError("invalid", "This material has no text to digest");
5349
+ if (!material?.chunks.length) throw new TutorError("material-no-text", "This material has no text to digest");
4932
5350
  const title = material.meta.title;
4933
5351
  const language = detectLanguage(material.chunks[0].text, uiLanguage());
4934
5352
  const size = material.chunks.reduce((sum, chunk) => sum + chunk.text.length, 0);
@@ -5011,9 +5429,9 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5011
5429
  }
5012
5430
  async function scopeOf(materialIds) {
5013
5431
  const ids = (Array.isArray(materialIds) ? materialIds : []).filter((id) => /^mat_[a-z0-9]+$/u.test(String(id))).slice(0, 8);
5014
- if (!ids.length) throw new TutorError("invalid", "Pick at least one material");
5432
+ if (!ids.length) throw new TutorError("no-material", "Pick at least one material");
5015
5433
  const materials = await readMaterials(store, ids);
5016
- if (!materials.some((item) => item.chunks.length)) throw new TutorError("invalid", "These materials have no text");
5434
+ if (!materials.some((item) => item.chunks.length)) throw new TutorError("material-no-text", "These materials have no text");
5017
5435
  const title = materials.map((item) => item.meta.title).join(" + ");
5018
5436
  return {
5019
5437
  ids,
@@ -5228,8 +5646,9 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5228
5646
  }
5229
5647
  function assist({ mode = "explain", request, materialIds = [], threadId, newThreadId }) {
5230
5648
  const ask = text(request, 4e3);
5231
- if (!ask) throw new TutorError("invalid", "Type your question");
5649
+ if (!ask) throw new TutorError("empty-question", "Type your question");
5232
5650
  const chosen = ASSIST_MODES.includes(mode) ? mode : "explain";
5651
+ const fresh = /^thd_[a-z0-9]{6,40}$/u.test(String(newThreadId)) ? newThreadId : null;
5233
5652
  return jobs.start("assist/turn", async (api) => {
5234
5653
  let thread = threadId ? await store.readJson(store.paths.item("assist", assertId(threadId, "thd")), null) : null;
5235
5654
  const ids = [.../* @__PURE__ */ new Set([...thread?.materialIds ?? [], ...Array.isArray(materialIds) ? materialIds : []])].filter((id) => /^mat_[a-z0-9]+$/u.test(String(id))).slice(0, 8);
@@ -5240,7 +5659,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5240
5659
  const images = await assistImages(ids);
5241
5660
  const board = await generateSteps(llm, {
5242
5661
  purpose: "teach",
5243
- effort: "low",
5662
+ effort: "lowest",
5244
5663
  prompt: assistPrompt({
5245
5664
  mode: chosen,
5246
5665
  request: ask,
@@ -5261,7 +5680,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5261
5680
  board,
5262
5681
  at: stamp()
5263
5682
  };
5264
- const id = thread?.id ?? (/^thd_[a-z0-9]{6,40}$/u.test(String(newThreadId)) ? newThreadId : newId("thd"));
5683
+ const id = thread?.id ?? fresh ?? newId("thd");
5265
5684
  const saved = await store.updateJson(store.paths.item("assist", id), null, (current) => {
5266
5685
  const next = current ?? {
5267
5686
  id,
@@ -5281,14 +5700,18 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5281
5700
  turn: saved.turns.length - 1,
5282
5701
  board
5283
5702
  };
5284
- }, { meta: { threadId: threadId ?? null } });
5703
+ }, { meta: {
5704
+ threadId: threadId ?? fresh,
5705
+ title: ask.slice(0, 60)
5706
+ } });
5285
5707
  }
5286
5708
  async function assistImages(ids) {
5287
5709
  return llm.supportsImages?.("teach") ? materialImages(store, await readMaterials(store, ids)) : [];
5288
5710
  }
5289
5711
  function makeShort({ topic, url, newId: presetId }) {
5290
5712
  const subject = text(topic, 300);
5291
- if (!subject && !url) throw new TutorError("invalid", "Give a topic or a web address");
5713
+ if (!subject && !url) throw new TutorError("empty-topic", "Give a topic or a web address");
5714
+ const id = /^sht_[a-z0-9]{6,40}$/u.test(String(presetId)) ? presetId : newId("sht");
5292
5715
  return jobs.start("short/make", async (api) => {
5293
5716
  let passages = "";
5294
5717
  let sourceTitle = "";
@@ -5302,7 +5725,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5302
5725
  const language = detectLanguage(subject || passages, uiLanguage());
5303
5726
  const board = await generateSteps(llm, {
5304
5727
  purpose: "teach",
5305
- effort: "low",
5728
+ effort: "lowest",
5306
5729
  prompt: shortPrompt({
5307
5730
  topic: subject || sourceTitle,
5308
5731
  passages,
@@ -5314,7 +5737,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5314
5737
  minSteps: 3
5315
5738
  });
5316
5739
  const item = {
5317
- id: /^sht_[a-z0-9]{6,40}$/u.test(String(presetId)) ? presetId : newId("sht"),
5740
+ id,
5318
5741
  title: board.title || subject || sourceTitle,
5319
5742
  topic: subject,
5320
5743
  sourceUrl: url ? String(url) : null,
@@ -5324,7 +5747,10 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5324
5747
  };
5325
5748
  await store.writeJson(store.paths.item("shorts", item.id), item);
5326
5749
  return { item };
5327
- });
5750
+ }, { meta: {
5751
+ id,
5752
+ title: subject
5753
+ } });
5328
5754
  }
5329
5755
  return Object.freeze({
5330
5756
  upload,
@@ -5437,7 +5863,12 @@ function apply(ctx) {
5437
5863
  const courses = createCourses({
5438
5864
  store,
5439
5865
  llm,
5440
- jobs
5866
+ jobs,
5867
+ research: createResearch({
5868
+ getWeb: () => ctx.get?.("web"),
5869
+ llm,
5870
+ logger
5871
+ })
5441
5872
  });
5442
5873
  const lessons = createLessons({
5443
5874
  store,
@@ -5472,6 +5903,10 @@ function apply(ctx) {
5472
5903
  keys,
5473
5904
  llmPort: llm
5474
5905
  });
5906
+ const speechInput = createSpeechInput({
5907
+ getSpeech: () => ctx.get?.("speechToText"),
5908
+ logger
5909
+ });
5475
5910
  async function speak({ text, provider, signal }) {
5476
5911
  await ready;
5477
5912
  const current = prefs.read();
@@ -5508,7 +5943,8 @@ function apply(ctx) {
5508
5943
  settings,
5509
5944
  tts: { speak },
5510
5945
  plugin,
5511
- info
5946
+ info,
5947
+ speechInput
5512
5948
  }),
5513
5949
  "app/info": (payload) => info(payload),
5514
5950
  "samples/list": () => samples.list(),
@@ -5523,6 +5959,7 @@ function apply(ctx) {
5523
5959
  if (typeof fetchRoutes?.register !== "function") return;
5524
5960
  connectionCtx.effect(() => fetchRoutes.register(uploadRoute({ studio })), "aha-tutor: upload route");
5525
5961
  connectionCtx.effect(() => fetchRoutes.register(ttsRoute({ speak })), "aha-tutor: tts route");
5962
+ connectionCtx.effect(() => fetchRoutes.register(speechRoute({ speechInput })), "aha-tutor: speech route");
5526
5963
  connectionCtx.effect(() => fetchRoutes.register(fontRoute()), "aha-tutor: font route");
5527
5964
  });
5528
5965
  ctx.effect(() => publishRuntime({