dsh-aha-tutor 1.3.0 → 1.4.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,7 +1637,7 @@ 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
  }
1461
1643
  //#endregion
@@ -1510,7 +1692,7 @@ function uploadRoute({ studio }) {
1510
1692
  }) }
1511
1693
  });
1512
1694
  } catch (error) {
1513
- return failure$1(error?.code ?? "failed", error?.name === "TutorError" ? error.message : "Upload failed", 400);
1695
+ return failure$1(publicCode(error), error?.name === "TutorError" ? error.message : "Upload failed", 400);
1514
1696
  }
1515
1697
  }
1516
1698
  };
@@ -1541,7 +1723,7 @@ function ttsRoute({ speak }) {
1541
1723
  });
1542
1724
  } catch (error) {
1543
1725
  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);
1726
+ return failure$1(known ? publicCode(error) : "tts-failed", known ? error.message : "Speech synthesis failed", ["auth", "no-key"].includes(error?.code) ? 401 : 502);
1545
1727
  }
1546
1728
  }
1547
1729
  };
@@ -1575,144 +1757,6 @@ function fontRoute({ dir = fontsDir() } = {}) {
1575
1757
  };
1576
1758
  }
1577
1759
  //#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
1760
  //#region src/core/planner.js
1717
1761
  const DAY_MS$1 = 1440 * 60 * 1e3;
1718
1762
  const HORIZON_DAYS = 400;
@@ -1756,7 +1800,7 @@ function clampNumber(value, min, max, fallback) {
1756
1800
  if (!Number.isFinite(number)) return fallback;
1757
1801
  return Math.min(max, Math.max(min, number));
1758
1802
  }
1759
- function buildPlan({ today, settings, courses = [], reviews = [], exams = [] }) {
1803
+ function buildPlan({ today, settings, courses = [], reviews = [], exams = [], spentToday = 0 }) {
1760
1804
  const start = today ?? toDay(/* @__PURE__ */ new Date());
1761
1805
  const { studyDays, weeklyMinutes, dailyMinutes } = normalizeSettings(settings);
1762
1806
  const queues = courses.map((course) => ({
@@ -1790,8 +1834,9 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
1790
1834
  minutes: 0
1791
1835
  }));
1792
1836
  if (studyDays.includes(weekday)) {
1793
- let budget = dailyMinutes;
1794
- const reviewBudget = Math.round(dailyMinutes * REVIEW_SHARE);
1837
+ const spent = offset === 0 ? Math.max(0, Number(spentToday) || 0) : 0;
1838
+ let budget = dailyMinutes - spent;
1839
+ const reviewBudget = Math.min(Math.max(0, budget), Math.round(dailyMinutes * REVIEW_SHARE));
1795
1840
  let reviewUsed = 0;
1796
1841
  while (pendingReviews.length && pendingReviews[0].dueDay <= day && reviewUsed + pendingReviews[0].minutes <= reviewBudget) {
1797
1842
  const review = pendingReviews.shift();
@@ -1801,6 +1846,8 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
1801
1846
  refId: review.id,
1802
1847
  courseId: review.courseId ?? null,
1803
1848
  title: review.title,
1849
+ courseTitle: review.courseTitle ?? "",
1850
+ courseEmoji: review.courseEmoji ?? "",
1804
1851
  minutes: review.minutes
1805
1852
  });
1806
1853
  }
@@ -1813,7 +1860,7 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
1813
1860
  for (const queue of ordered) {
1814
1861
  const lesson = queue.remaining[0];
1815
1862
  if (!lesson) continue;
1816
- const dayIsEmpty = !items.some((item) => item.kind === "lesson");
1863
+ const dayIsEmpty = !spent && !items.some((item) => item.kind === "lesson");
1817
1864
  if (lesson.minutes <= budget || dayIsEmpty) {
1818
1865
  queue.remaining.shift();
1819
1866
  budget -= lesson.minutes;
@@ -1899,9 +1946,61 @@ function neededWeeklyMinutes(course, start, deadline, studyDays) {
1899
1946
  const perDay = remaining / studyDayCount / (1 - REVIEW_SHARE);
1900
1947
  return Math.ceil(perDay * studyDays.length / 10) * 10;
1901
1948
  }
1949
+ const itemKey = (item) => `${item.kind}:${item.refId}`;
1950
+ function mergeAgenda(saved = [], planned = []) {
1951
+ const seen = new Set(saved.map(itemKey));
1952
+ return [...saved, ...planned.filter((item) => !seen.has(itemKey(item)))];
1953
+ }
1954
+ function planCalendar({ today, agenda = {}, plan, done = {}, checks = [], ahead = 120 }) {
1955
+ const lessonsDone = done.lessons ?? /* @__PURE__ */ new Map();
1956
+ const reviewsDone = done.reviews ?? /* @__PURE__ */ new Map();
1957
+ const last = addDays(today, ahead);
1958
+ const days = /* @__PURE__ */ new Map();
1959
+ const put = (day, item) => {
1960
+ const list = days.get(day) ?? [];
1961
+ if (!list.some((entry) => itemKey(entry) === itemKey(item))) list.push(item);
1962
+ days.set(day, list);
1963
+ };
1964
+ for (const [day, items] of Object.entries(agenda)) if (day <= today) for (const item of items) put(day, item);
1965
+ for (const entry of plan?.days ?? []) if (entry.day >= today && entry.day <= last) for (const item of entry.items) put(entry.day, item);
1966
+ for (const { day, item } of lessonsDone.values()) if (day <= today) put(day, item);
1967
+ for (const check of checks) if (check.day <= today && check.item) put(check.day, check.item);
1968
+ const status = (item, day) => {
1969
+ if (day > today || item.kind === "exam") return false;
1970
+ if (checks.some((check) => check.refId === item.refId && check.day === day)) return "manual";
1971
+ if (item.kind === "lesson" && lessonsDone.get(item.refId)?.day === day) return "auto";
1972
+ if (item.kind === "review" && reviewsDone.get(item.refId) === day) return "auto";
1973
+ return false;
1974
+ };
1975
+ const out = [...days.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, items]) => ({
1976
+ day,
1977
+ items: items.map((item) => ({
1978
+ ...item,
1979
+ checked: status(item, day)
1980
+ }))
1981
+ }));
1982
+ return {
1983
+ today: out.find((entry) => entry.day === today)?.items ?? [],
1984
+ days: out
1985
+ };
1986
+ }
1902
1987
  //#endregion
1903
1988
  //#region src/host/learner.js
1904
1989
  const PROFILE_REFRESH_MS = 10 * 6e4;
1990
+ function agendaItem(raw) {
1991
+ const kind = raw?.kind === "lesson" || raw?.kind === "review" ? raw.kind : null;
1992
+ const refId = text(raw?.refId, 80);
1993
+ if (!kind || !refId) return null;
1994
+ return {
1995
+ kind,
1996
+ refId,
1997
+ courseId: /^crs_[a-z0-9]{6,40}$/u.test(String(raw.courseId)) ? raw.courseId : null,
1998
+ title: text(raw.title, 160),
1999
+ courseTitle: text(raw.courseTitle, 160),
2000
+ courseEmoji: text(raw.courseEmoji, 8),
2001
+ minutes: Math.min(240, Math.max(0, Math.round(Number(raw.minutes) || 0)))
2002
+ };
2003
+ }
1905
2004
  function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), logger }) {
1906
2005
  let lastRefresh = 0;
1907
2006
  let refreshing = null;
@@ -1991,9 +2090,12 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
1991
2090
  0
1992
2091
  ]
1993
2092
  },
1994
- exams: Array.isArray(plan.exams) ? plan.exams : []
2093
+ exams: Array.isArray(plan.exams) ? plan.exams : [],
2094
+ checks: Array.isArray(plan.checks) ? plan.checks : [],
2095
+ agenda: plan.agenda && typeof plan.agenda === "object" ? plan.agenda : {}
1995
2096
  };
1996
2097
  }
2098
+ const validDay = (value) => /^\d{4}-\d{2}-\d{2}$/u.test(String(value ?? "")) ? value : null;
1997
2099
  async function activeCourses() {
1998
2100
  const ids = await store.listIds(store.paths.courses(), "crs");
1999
2101
  const result = [];
@@ -2009,25 +2111,56 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2009
2111
  }
2010
2112
  async function plan({ today } = {}) {
2011
2113
  const file = await readPlanFile();
2012
- const day = /^\d{4}-\d{2}-\d{2}$/u.test(String(today ?? "")) ? today : toDay(now());
2114
+ const day = validDay(today) ?? toDay(now());
2013
2115
  const active = await activeCourses();
2116
+ const tickedLessons = new Set(file.checks.filter((check) => check.kind === "lesson" && check.day <= day).map((check) => check.refId));
2117
+ const tickedToday = new Set(file.checks.filter((check) => check.day === day).map((check) => check.refId));
2014
2118
  const reviews = [];
2119
+ const done = {
2120
+ lessons: /* @__PURE__ */ new Map(),
2121
+ reviews: /* @__PURE__ */ new Map()
2122
+ };
2015
2123
  for (const { course, progress } of active) {
2124
+ const label = {
2125
+ courseId: course.id,
2126
+ courseTitle: course.title,
2127
+ courseEmoji: course.emoji ?? ""
2128
+ };
2016
2129
  const index = conceptIndex(course);
2017
2130
  for (const [conceptId, card] of Object.entries(progress.reviews ?? {})) {
2018
2131
  const concept = index.get(conceptId);
2019
2132
  if (!concept) continue;
2020
- reviews.push({
2021
- id: `${course.id}:${conceptId}`,
2022
- courseId: course.id,
2133
+ const id = `${course.id}:${conceptId}`;
2134
+ if (card.lastReviewedAt) done.reviews.set(id, toDay(new Date(card.lastReviewedAt)));
2135
+ if (!tickedToday.has(id)) reviews.push({
2136
+ id,
2137
+ ...label,
2023
2138
  title: concept.name,
2024
2139
  dueDay: toDay(new Date(card.dueAt)),
2025
2140
  minutes: 5
2026
2141
  });
2027
2142
  }
2143
+ for (const { lesson } of flattenLessons(course)) {
2144
+ const record = progress.lessons?.[lesson.id];
2145
+ if (record?.status === "done" && record.completedAt) done.lessons.set(lesson.id, {
2146
+ day: toDay(new Date(record.completedAt)),
2147
+ item: {
2148
+ kind: "lesson",
2149
+ refId: lesson.id,
2150
+ title: lesson.title,
2151
+ minutes: lesson.estMinutes,
2152
+ ...label
2153
+ }
2154
+ });
2155
+ }
2028
2156
  }
2157
+ const spent = /* @__PURE__ */ new Map();
2158
+ for (const { day: doneDay, item } of done.lessons.values()) if (doneDay === day) spent.set(item.refId, item.minutes);
2159
+ 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);
2160
+ for (const check of file.checks) if (check.day === day && check.item) spent.set(check.refId, check.item.minutes);
2029
2161
  const built = buildPlan({
2030
2162
  today: day,
2163
+ spentToday: [...spent.values()].reduce((sum, minutes) => sum + (Number(minutes) || 0), 0),
2031
2164
  settings: file.settings,
2032
2165
  courses: active.map(({ course, progress }) => ({
2033
2166
  id: course.id,
@@ -2038,7 +2171,7 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2038
2171
  id: lesson.id,
2039
2172
  title: lesson.title,
2040
2173
  minutes: lesson.estMinutes,
2041
- done: progress.lessons?.[lesson.id]?.status === "done"
2174
+ done: progress.lessons?.[lesson.id]?.status === "done" || tickedLessons.has(lesson.id)
2042
2175
  }))
2043
2176
  })),
2044
2177
  reviews,
@@ -2049,13 +2182,57 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2049
2182
  courseId: exam.courseId ?? null
2050
2183
  }))
2051
2184
  });
2185
+ const planned = (built.days.find((entry) => entry.day === day)?.items ?? []).filter((item) => item.kind !== "exam").map(agendaItem);
2186
+ const oldest = addDays(day, -180);
2187
+ const merged = mergeAgenda(file.agenda[day] ?? [], planned).slice(0, 40);
2188
+ let agenda = file.agenda;
2189
+ 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) => {
2190
+ const kept = Object.fromEntries(Object.entries(current.agenda ?? {}).filter(([key]) => validDay(key) && key >= oldest));
2191
+ kept[day] = mergeAgenda(kept[day] ?? [], planned).slice(0, 40);
2192
+ return {
2193
+ ...current,
2194
+ agenda: kept
2195
+ };
2196
+ })).agenda;
2197
+ const calendar = planCalendar({
2198
+ today: day,
2199
+ agenda,
2200
+ plan: built,
2201
+ done,
2202
+ checks: file.checks
2203
+ });
2052
2204
  return {
2053
2205
  settings: file.settings,
2054
2206
  exams: file.exams,
2055
2207
  plan: built,
2056
- today: built.days.find((entry) => entry.day === day)?.items ?? []
2208
+ today: calendar.today,
2209
+ calendar: calendar.days
2057
2210
  };
2058
2211
  }
2212
+ async function check({ today, day, item }) {
2213
+ const current = validDay(today) ?? toDay(now());
2214
+ const when = validDay(day);
2215
+ const entry = agendaItem(item ?? {});
2216
+ if (!when || !entry || when > current) throw new TutorError("cannot-tick", "Only today or an earlier day can be ticked");
2217
+ await store.updateJson(store.paths.plan(), {}, (current) => ({
2218
+ ...current,
2219
+ checks: [...(current.checks ?? []).filter((existing) => !(existing.refId === entry.refId && existing.day === when)), {
2220
+ refId: entry.refId,
2221
+ kind: entry.kind,
2222
+ day: when,
2223
+ item: entry,
2224
+ at: now().toISOString()
2225
+ }].slice(-1e3)
2226
+ }));
2227
+ return plan({ today });
2228
+ }
2229
+ async function uncheck({ today, day, refId }) {
2230
+ await store.updateJson(store.paths.plan(), {}, (current) => ({
2231
+ ...current,
2232
+ checks: (current.checks ?? []).filter((existing) => !(existing.refId === refId && existing.day === day))
2233
+ }));
2234
+ return plan({ today });
2235
+ }
2059
2236
  async function savePlanSettings({ weeklyMinutes, studyDays }) {
2060
2237
  await store.updateJson(store.paths.plan(), {}, (current) => ({
2061
2238
  ...current,
@@ -2068,7 +2245,7 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2068
2245
  }
2069
2246
  async function addExam({ title, day, courseId }) {
2070
2247
  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");
2248
+ 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
2249
  await store.updateJson(store.paths.plan(), {}, (current) => ({
2073
2250
  ...current,
2074
2251
  exams: [...current.exams ?? [], {
@@ -2122,6 +2299,8 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2122
2299
  removeNote,
2123
2300
  noteLesson,
2124
2301
  plan,
2302
+ check,
2303
+ uncheck,
2125
2304
  savePlanSettings,
2126
2305
  addExam,
2127
2306
  removeExam,
@@ -2220,6 +2399,7 @@ function round2(value) {
2220
2399
  //#region src/host/lessons.js
2221
2400
  const EVENTS_KEPT = 200;
2222
2401
  const STEP_ID = /^s\d{1,4}$/u;
2402
+ const BOARD_JOBS = ["lesson/board", "lesson/prefetch"];
2223
2403
  function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__PURE__ */ new Date() }) {
2224
2404
  const stamp = () => now().toISOString();
2225
2405
  async function context(courseId, lessonId) {
@@ -2263,7 +2443,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2263
2443
  mastery: Object.fromEntries(row.lesson.concepts.map((concept) => [concept.id, masteryPercent(progress.concepts?.[concept.id] ?? createConceptState(concept.id, concept.name))]))
2264
2444
  };
2265
2445
  }
2266
- async function buildBoard({ course, row, signal, onStep, directive, style }) {
2446
+ async function buildBoard({ course, row, signal, onStep, onProgress, directive, style }) {
2267
2447
  const progress = await readProgress(store, course.id);
2268
2448
  const profile = await readProfile(store);
2269
2449
  const materials = await readMaterials(store, course.materialIds ?? []);
@@ -2275,7 +2455,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2275
2455
  const kindDirective = row.lesson.kind === "remedial" ? "remediate" : row.lesson.kind === "review" ? "review" : row.lesson.kind === "challenge" ? "challenge" : void 0;
2276
2456
  return generateSteps(llm, {
2277
2457
  purpose: "teach",
2278
- effort: "low",
2458
+ effort: "lowest",
2279
2459
  prompt: lessonPrompt({
2280
2460
  course,
2281
2461
  unit: row.unit,
@@ -2295,6 +2475,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2295
2475
  signal,
2296
2476
  conceptIds: row.lesson.concepts.map((concept) => concept.id),
2297
2477
  onStep,
2478
+ onProgress,
2298
2479
  minSteps: 3
2299
2480
  });
2300
2481
  }
@@ -2319,6 +2500,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2319
2500
  row,
2320
2501
  signal: api.signal,
2321
2502
  onStep: (step) => api.push(step),
2503
+ onProgress: (info) => api.preview(info),
2322
2504
  style: text(style, 300) || void 0
2323
2505
  });
2324
2506
  const saved = {
@@ -2351,7 +2533,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2351
2533
  course,
2352
2534
  row: next,
2353
2535
  signal: api.signal,
2354
- onStep: (step) => api.push(step)
2536
+ onStep: (step) => api.push(step),
2537
+ onProgress: (info) => api.preview(info)
2355
2538
  });
2356
2539
  const saved = {
2357
2540
  ...board,
@@ -2371,10 +2554,18 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2371
2554
  });
2372
2555
  }).catch(() => void 0);
2373
2556
  }
2557
+ function followedJob(scope, kinds, meta) {
2558
+ const job = scope?.jobId ? jobs.get(scope.jobId) : null;
2559
+ return job && kinds.includes(job.kind) && Object.entries(meta).every(([key, value]) => job.meta?.[key] === value) ? job : null;
2560
+ }
2374
2561
  async function scopeBoard(scope) {
2375
2562
  if (scope?.kind === "lesson") {
2376
2563
  const { course, row } = await context(scope.courseId, scope.lessonId);
2377
- const board = await store.readJson(store.paths.board(course.id, row.lesson.id), null);
2564
+ const writing = followedJob(scope, BOARD_JOBS, {
2565
+ courseId: course.id,
2566
+ lessonId: row.lesson.id
2567
+ });
2568
+ const board = writing ? { steps: writing.partial } : await store.readJson(store.paths.board(course.id, row.lesson.id), null);
2378
2569
  return {
2379
2570
  course,
2380
2571
  lesson: row.lesson,
@@ -2383,18 +2574,20 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2383
2574
  };
2384
2575
  }
2385
2576
  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;
2577
+ const thread = scope.kind === "thread";
2578
+ const writing = thread ? followedJob(scope, ["assist/turn"], { threadId: scope.id }) : followedJob(scope, ["short/make"], { id: scope.id });
2579
+ const item = await store.readJson(store.paths.item(thread ? "assist" : "shorts", scope.id), null);
2580
+ if (!item && !writing) throw new TutorError("not-found", "Not found");
2581
+ 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;
2582
+ const language = item?.language ?? detectLanguage((board?.steps ?? []).map((step) => step.say).join(" "));
2390
2583
  return {
2391
2584
  course: null,
2392
2585
  lesson: {
2393
- title: item.title,
2586
+ title: item?.title ?? writing.meta?.title ?? "",
2394
2587
  objectives: []
2395
2588
  },
2396
2589
  board,
2397
- language: item.language
2590
+ language
2398
2591
  };
2399
2592
  }
2400
2593
  throw new TutorError("invalid", "Unknown board");
@@ -2412,7 +2605,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2412
2605
  const anchorFor = (board, stepIndex, anchorStepId) => STEP_ID.test(String(anchorStepId)) ? anchorStepId : board?.steps?.[Math.max(0, Number(stepIndex) || 0)]?.id ?? "s1";
2413
2606
  function ask({ scope, stepIndex = 0, question, anchorStepId }) {
2414
2607
  const request = text(question, 1500);
2415
- if (!request) throw new TutorError("invalid", "Type your question");
2608
+ if (!request) throw new TutorError("empty-question", "Type your question");
2416
2609
  return jobs.start("lesson/ask", async (api) => {
2417
2610
  const { course, lesson, board, language } = await scopeBoard(scope);
2418
2611
  const profile = await readProfile(store);
@@ -2454,11 +2647,11 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2454
2647
  function reexplain({ scope, stepIndex = 0, style = "example", anchorStepId }) {
2455
2648
  return jobs.start("lesson/reexplain", async (api) => {
2456
2649
  const { course, lesson, board, language } = await scopeBoard(scope);
2457
- if (!board?.steps?.length) throw new TutorError("invalid", "Nothing to re-explain yet");
2650
+ if (!board?.steps?.length) throw new TutorError("no-board", "The board is not written yet. Try again once its first step is up.");
2458
2651
  const index = Math.min(board.steps.length - 1, Math.max(0, Number(stepIndex) || 0));
2459
2652
  const answer = await generateSteps(llm, {
2460
2653
  purpose: "teach",
2461
- effort: "low",
2654
+ effort: "lowest",
2462
2655
  prompt: reexplainPrompt({
2463
2656
  lesson,
2464
2657
  language,
@@ -2943,12 +3136,22 @@ const REASONING_ROOM = Object.freeze({
2943
3136
  off: 0,
2944
3137
  none: 0,
2945
3138
  minimal: 4e3,
2946
- low: 8e3,
3139
+ low: 16e3,
2947
3140
  medium: 16e3,
2948
3141
  high: 24e3,
2949
3142
  xhigh: 48e3,
2950
3143
  max: 48e3
2951
3144
  });
3145
+ const EFFORT_ORDER = [
3146
+ "off",
3147
+ "none",
3148
+ "minimal",
3149
+ "low",
3150
+ "medium",
3151
+ "high",
3152
+ "xhigh",
3153
+ "max"
3154
+ ];
2952
3155
  const STOPPED_AT_LIMIT = /* @__PURE__ */ new Set(["max-tokens", "length"]);
2953
3156
  const CALL_TIMEOUT_MS = 6 * 6e4;
2954
3157
  const MAX_RETRIES = 2;
@@ -2973,7 +3176,10 @@ function toTutorError(code, fallback) {
2973
3176
  }
2974
3177
  function pickEffort(efforts, wanted) {
2975
3178
  if (!efforts?.length || !wanted) return void 0;
2976
- if (wanted === "lowest") return efforts[0].id;
3179
+ if (wanted === "lowest") {
3180
+ const rank = (effort) => EFFORT_ORDER.includes(effort.id) ? EFFORT_ORDER.indexOf(effort.id) : EFFORT_ORDER.length;
3181
+ return efforts.reduce((lightest, effort) => rank(effort) < rank(lightest) ? effort : lightest).id;
3182
+ }
2977
3183
  return (efforts.find((effort) => effort.id === wanted) ?? efforts.find((effort) => effort.id !== "off" && effort.id !== "none") ?? efforts[0]).id;
2978
3184
  }
2979
3185
  function outputBudget(maxTokens, effort, model) {
@@ -3007,7 +3213,7 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
3007
3213
  provider: selection.provider,
3008
3214
  model: selection.model
3009
3215
  };
3010
- throw new TutorError("llm-unavailable", "No model is configured in DSH yet.");
3216
+ throw new TutorError("llm-missing", "No model is configured in DSH yet.");
3011
3217
  }
3012
3218
  async function modalities(purpose, signal) {
3013
3219
  const llm = getLlm();
@@ -3041,9 +3247,9 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
3041
3247
  }
3042
3248
  return blocks;
3043
3249
  }
3044
- async function attempt({ purpose, effort, system, user, images, maxTokens, signal, onText }) {
3250
+ async function attempt({ purpose, effort, system, user, images, maxTokens, signal, onText, onReasoning }) {
3045
3251
  const llm = getLlm();
3046
- if (!llm) throw new TutorError("llm-unavailable", "DSH has no model service in this profile.");
3252
+ if (!llm) throw new TutorError("llm-missing", "DSH has no model service in this profile.");
3047
3253
  const target = route(purpose);
3048
3254
  const timeout = AbortSignal.timeout(CALL_TIMEOUT_MS);
3049
3255
  const fused = signal ? AbortSignal.any([signal, timeout]) : timeout;
@@ -3079,7 +3285,8 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
3079
3285
  })) if (chunk.type === "text-delta" && typeof chunk.text === "string") {
3080
3286
  textValue += chunk.text;
3081
3287
  onText?.(chunk.text);
3082
- } else if (chunk.type === "finish") finish = chunk.reason;
3288
+ } else if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") onReasoning?.(chunk.text);
3289
+ else if (chunk.type === "finish") finish = chunk.reason;
3083
3290
  } catch (error) {
3084
3291
  if (signal?.aborted) throw signal.reason ?? error;
3085
3292
  throw Object.assign(toTutorError(timeout.aborted ? "TIMEOUT" : error?.code, error?.message), {
@@ -3122,7 +3329,8 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
3122
3329
  return Object.freeze({
3123
3330
  complete: (options) => call({
3124
3331
  ...options,
3125
- onText: void 0
3332
+ onText: void 0,
3333
+ onReasoning: void 0
3126
3334
  }),
3127
3335
  stream: (options) => call(options),
3128
3336
  route,
@@ -3782,7 +3990,7 @@ function createProjects({ store, llm, jobs, courses, projectsRoot = resolveProje
3782
3990
  const content = await readWorkspace(folderOf(course, project));
3783
3991
  if (content) parts.push(`Files in the project folder:\n${content}`);
3784
3992
  }
3785
- if (!parts.length) throw new TutorError("invalid", "Nothing to review yet: write a note or work in the project folder first");
3993
+ if (!parts.length) throw new TutorError("nothing-to-review", "Nothing to review yet: write a note or work in the project folder first");
3786
3994
  const result = await generateJson(llm, {
3787
3995
  purpose: "teach",
3788
3996
  prompt: projectReviewPrompt({
@@ -3844,6 +4052,100 @@ function createProjects({ store, llm, jobs, courses, projectsRoot = resolveProje
3844
4052
  });
3845
4053
  }
3846
4054
  //#endregion
4055
+ //#region src/host/research.js
4056
+ const MAX_QUERIES = 3;
4057
+ const RESULTS_PER_QUERY = 6;
4058
+ const MAX_SOURCES = 10;
4059
+ const UNAVAILABLE = /* @__PURE__ */ new Set([
4060
+ "WEB_PROVIDER_UNAVAILABLE",
4061
+ "WEB_PROVIDER_CONFIGURED_MISSING",
4062
+ "WEB_PROVIDER_CONFIGURED_UNAVAILABLE",
4063
+ "WEB_PROVIDER_AMBIGUOUS"
4064
+ ]);
4065
+ function cleanQueries(raw, goal) {
4066
+ const queries = [...new Set((Array.isArray(raw) ? raw : []).map((query) => text(query, 120)).filter(Boolean))].slice(0, MAX_QUERIES);
4067
+ return queries.length ? queries : [text(goal, 120)].filter(Boolean);
4068
+ }
4069
+ function mergeSources(lists) {
4070
+ const seen = /* @__PURE__ */ new Set();
4071
+ const out = [];
4072
+ const longest = Math.max(0, ...lists.map((list) => list.length));
4073
+ for (let rank = 0; rank < longest && out.length < MAX_SOURCES; rank += 1) for (const list of lists) {
4074
+ const source = list[rank];
4075
+ const url = safeUrl(source?.url);
4076
+ if (!url || seen.has(url) || out.length >= MAX_SOURCES) continue;
4077
+ seen.add(url);
4078
+ out.push({
4079
+ url,
4080
+ title: text(source.title, 200) || new URL(url).hostname,
4081
+ snippet: text(source.snippet ?? source.text ?? source.excerpt, 400),
4082
+ publishedAt: text(source.publishedAt ?? source.date, 40)
4083
+ });
4084
+ }
4085
+ return out;
4086
+ }
4087
+ function safeUrl(value) {
4088
+ const raw = text(value, 500);
4089
+ if (!raw) return "";
4090
+ try {
4091
+ const url = new URL(raw);
4092
+ return url.protocol === "https:" || url.protocol === "http:" ? url.href : "";
4093
+ } catch {
4094
+ return "";
4095
+ }
4096
+ }
4097
+ function skipReason(error) {
4098
+ const code = String(error?.code ?? "");
4099
+ if (code === "WEB_PROVIDER_CREDENTIAL_MISSING") return "no-credential";
4100
+ if (UNAVAILABLE.has(code)) return "unavailable";
4101
+ return error ? "failed" : "empty";
4102
+ }
4103
+ function createResearch({ getWeb, llm, logger }) {
4104
+ async function run({ goal, language, signal, onSearching }) {
4105
+ const web = getWeb?.();
4106
+ if (typeof web?.search !== "function") return {
4107
+ skipped: "unavailable",
4108
+ sources: []
4109
+ };
4110
+ let queries;
4111
+ try {
4112
+ queries = await generateJson(llm, {
4113
+ purpose: "quick",
4114
+ prompt: researchQueriesPrompt({
4115
+ goal,
4116
+ language
4117
+ }),
4118
+ maxTokens: 400,
4119
+ signal,
4120
+ normalize: (raw) => cleanQueries(raw?.queries, goal)
4121
+ });
4122
+ } catch (error) {
4123
+ if (signal?.aborted) throw error;
4124
+ queries = cleanQueries([], goal);
4125
+ }
4126
+ onSearching?.(queries);
4127
+ const settled = await Promise.allSettled(queries.map((query) => web.search({
4128
+ query,
4129
+ maxResults: RESULTS_PER_QUERY
4130
+ }, signal)));
4131
+ signal?.throwIfAborted?.();
4132
+ const failure = settled.find((result) => result.status === "rejected")?.reason;
4133
+ if (failure) logger?.debug?.("aha-tutor web research: %s", failure?.code ?? failure?.message);
4134
+ const sources = mergeSources(settled.filter((result) => result.status === "fulfilled").map((result) => result.value?.sources ?? []));
4135
+ if (!sources.length) return {
4136
+ skipped: skipReason(failure),
4137
+ queries,
4138
+ sources: []
4139
+ };
4140
+ return {
4141
+ queries,
4142
+ sources,
4143
+ at: (/* @__PURE__ */ new Date()).toISOString()
4144
+ };
4145
+ }
4146
+ return Object.freeze({ run });
4147
+ }
4148
+ //#endregion
3847
4149
  //#region src/host/rpc.js
3848
4150
  const ID = (prefix) => new RegExp(`^${prefix}_[a-z0-9]{6,40}$`, "u");
3849
4151
  const IDS = {
@@ -3873,19 +4175,24 @@ function idList(value, prefix, max = 8) {
3873
4175
  }
3874
4176
  function scopeOf(payload) {
3875
4177
  const scope = payload?.scope ?? {};
4178
+ const jobId = optional(scope, "jobId", "job");
4179
+ const writing = jobId ? { jobId } : {};
3876
4180
  if (scope.kind === "lesson") return {
3877
4181
  kind: "lesson",
3878
4182
  courseId: need(scope, "courseId", "crs"),
3879
- lessonId: need(scope, "lessonId", "les")
4183
+ lessonId: need(scope, "lessonId", "les"),
4184
+ ...writing
3880
4185
  };
3881
4186
  if (scope.kind === "thread") return {
3882
4187
  kind: "thread",
3883
4188
  id: need(scope, "id", "thd"),
3884
- turn: Number(scope.turn) || 0
4189
+ turn: Number(scope.turn) || 0,
4190
+ ...writing
3885
4191
  };
3886
4192
  if (scope.kind === "short") return {
3887
4193
  kind: "short",
3888
- id: need(scope, "id", "sht")
4194
+ id: need(scope, "id", "sht"),
4195
+ ...writing
3889
4196
  };
3890
4197
  throw new TutorError("invalid", "Invalid board scope");
3891
4198
  }
@@ -3919,6 +4226,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
3919
4226
  goal: payload.goal,
3920
4227
  materialIds: idList(payload.materialIds, "mat"),
3921
4228
  quick: payload.quick === true,
4229
+ web: payload.web === true,
3922
4230
  language: typeof payload.language === "string" ? payload.language.slice(0, 12) : void 0
3923
4231
  }) }),
3924
4232
  "course/proposal-apply": (payload) => courses.applyProposal({ courseId: need(payload, "courseId", "crs") }),
@@ -4066,6 +4374,16 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
4066
4374
  "profile/clear": () => learner.clearProfile(),
4067
4375
  "profile/remove-note": (payload) => learner.removeNote({ id: need(payload, "id", "nte") }),
4068
4376
  "plan/get": (payload) => learner.plan({ today: payload.today }),
4377
+ "plan/check": (payload) => learner.check({
4378
+ today: payload.today,
4379
+ day: payload.day,
4380
+ item: payload.item
4381
+ }),
4382
+ "plan/uncheck": (payload) => learner.uncheck({
4383
+ today: payload.today,
4384
+ day: String(payload.day ?? "").slice(0, 10),
4385
+ refId: String(payload.refId ?? "").slice(0, 80)
4386
+ }),
4069
4387
  "plan/settings": (payload) => learner.savePlanSettings({
4070
4388
  weeklyMinutes: payload.weeklyMinutes,
4071
4389
  studyDays: payload.studyDays
@@ -4198,13 +4516,12 @@ function createRpcHandler(routes, { logger, checkPeer = isOwner } = {}) {
4198
4516
  };
4199
4517
  } catch (error) {
4200
4518
  if (signal?.aborted) throw error;
4201
- const known = [
4519
+ if (![
4202
4520
  "TutorError",
4203
4521
  "MaterialError",
4204
4522
  "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));
4523
+ ].includes(error?.name)) logger?.warn?.("aha-tutor %s failed: %s", endpoint, error?.message);
4524
+ return failure(publicCode(error), publicMessage(error));
4208
4525
  }
4209
4526
  };
4210
4527
  }
@@ -4364,15 +4681,15 @@ function checkEndpoint(value) {
4364
4681
  try {
4365
4682
  url = new URL(String(value));
4366
4683
  } catch {
4367
- throw new TtsError("config", "TTS address is not a valid URL");
4684
+ throw new TtsError("address", "TTS address is not a valid URL");
4368
4685
  }
4369
4686
  const local = [
4370
4687
  "localhost",
4371
4688
  "127.0.0.1",
4372
4689
  "[::1]"
4373
4690
  ].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");
4691
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) throw new TtsError("address", "TTS address must use https (http only for localhost)");
4692
+ if (url.username || url.password) throw new TtsError("address", "TTS address must not contain credentials");
4376
4693
  return url;
4377
4694
  }
4378
4695
  const SENTENCE_END = /([。!?!?;;…]+|\.(?=\s)|\n+)/u;
@@ -4475,7 +4792,7 @@ const synthesizers = {
4475
4792
  return readBounded(response);
4476
4793
  },
4477
4794
  async volcengine({ text, config, secret, rate, fetchImpl, signal }) {
4478
- if (!config.appId) throw new TtsError("config", "Volcengine App ID is required");
4795
+ if (!config.appId) throw new TtsError("app-id", "Volcengine App ID is required");
4479
4796
  const response = await boundedFetch(fetchImpl, "https://openspeech.bytedance.com/api/v1/tts", {
4480
4797
  method: "POST",
4481
4798
  headers: {
@@ -4556,9 +4873,9 @@ const synthesizers = {
4556
4873
  },
4557
4874
  async azure({ text, config, secret, rate, fetchImpl, signal }) {
4558
4875
  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");
4876
+ if (!/^[a-z0-9]{2,30}$/u.test(region)) throw new TtsError("azure", "Azure region is invalid");
4560
4877
  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");
4878
+ 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
4879
  const lang = voice.split("-").slice(0, 2).join("-");
4563
4880
  const percent = Math.round((rateNumber(rate) - 1) * 100);
4564
4881
  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 +4915,7 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
4598
4915
  async function synthesize({ provider, config = {}, text, rate = 1, signal }) {
4599
4916
  if (!TTS_PROVIDERS.includes(provider)) throw new TtsError("config", "Unknown TTS provider");
4600
4917
  const content = String(text ?? "").trim().slice(0, 4e3);
4601
- if (!content) throw new TtsError("request", "Nothing to speak");
4918
+ if (!content) throw new TtsError("empty", "Nothing to speak");
4602
4919
  const merged = {
4603
4920
  ...providerDefaults(provider),
4604
4921
  ...config
@@ -4614,7 +4931,7 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
4614
4931
  };
4615
4932
  } catch {}
4616
4933
  const secret = await getSecret(provider);
4617
- if (!secret) throw new TtsError("auth", "No key saved for this TTS provider");
4934
+ if (!secret) throw new TtsError("no-key", "No key saved for this TTS provider");
4618
4935
  const parts = [];
4619
4936
  for (const chunk of splitForTts(content, CHUNK_UNITS[provider])) parts.push(await synthesizers[provider]({
4620
4937
  text: chunk,
@@ -4747,7 +5064,7 @@ function createKeyStore({ getCredentials, credentialKey }) {
4747
5064
  };
4748
5065
  const service = () => {
4749
5066
  const credentials = getCredentials();
4750
- if (!credentials) throw new TutorError("unavailable", "DSH credentials are not available in this profile");
5067
+ if (!credentials) throw new TutorError("no-credentials", "DSH credentials are not available in this profile");
4751
5068
  return credentials;
4752
5069
  };
4753
5070
  return {
@@ -4766,7 +5083,7 @@ function createKeyStore({ getCredentials, credentialKey }) {
4766
5083
  },
4767
5084
  async write(provider, value) {
4768
5085
  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)");
5086
+ if (!/^[\x21-\x7E]{8,1024}$/u.test(key)) throw new TutorError("bad-key", "The key looks wrong (8–1024 printable characters, no spaces)");
4770
5087
  await service().modifyRecord(keyOf(provider), async () => ({
4771
5088
  kind: "api-key",
4772
5089
  key
@@ -4928,7 +5245,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
4928
5245
  function digest({ id }) {
4929
5246
  return jobs.start("material/digest", async (api) => {
4930
5247
  const [material] = await readMaterials(store, [assertId(id, "mat")]);
4931
- if (!material?.chunks.length) throw new TutorError("invalid", "This material has no text to digest");
5248
+ if (!material?.chunks.length) throw new TutorError("material-no-text", "This material has no text to digest");
4932
5249
  const title = material.meta.title;
4933
5250
  const language = detectLanguage(material.chunks[0].text, uiLanguage());
4934
5251
  const size = material.chunks.reduce((sum, chunk) => sum + chunk.text.length, 0);
@@ -5011,9 +5328,9 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5011
5328
  }
5012
5329
  async function scopeOf(materialIds) {
5013
5330
  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");
5331
+ if (!ids.length) throw new TutorError("no-material", "Pick at least one material");
5015
5332
  const materials = await readMaterials(store, ids);
5016
- if (!materials.some((item) => item.chunks.length)) throw new TutorError("invalid", "These materials have no text");
5333
+ if (!materials.some((item) => item.chunks.length)) throw new TutorError("material-no-text", "These materials have no text");
5017
5334
  const title = materials.map((item) => item.meta.title).join(" + ");
5018
5335
  return {
5019
5336
  ids,
@@ -5228,8 +5545,9 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5228
5545
  }
5229
5546
  function assist({ mode = "explain", request, materialIds = [], threadId, newThreadId }) {
5230
5547
  const ask = text(request, 4e3);
5231
- if (!ask) throw new TutorError("invalid", "Type your question");
5548
+ if (!ask) throw new TutorError("empty-question", "Type your question");
5232
5549
  const chosen = ASSIST_MODES.includes(mode) ? mode : "explain";
5550
+ const fresh = /^thd_[a-z0-9]{6,40}$/u.test(String(newThreadId)) ? newThreadId : null;
5233
5551
  return jobs.start("assist/turn", async (api) => {
5234
5552
  let thread = threadId ? await store.readJson(store.paths.item("assist", assertId(threadId, "thd")), null) : null;
5235
5553
  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 +5558,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5240
5558
  const images = await assistImages(ids);
5241
5559
  const board = await generateSteps(llm, {
5242
5560
  purpose: "teach",
5243
- effort: "low",
5561
+ effort: "lowest",
5244
5562
  prompt: assistPrompt({
5245
5563
  mode: chosen,
5246
5564
  request: ask,
@@ -5261,7 +5579,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5261
5579
  board,
5262
5580
  at: stamp()
5263
5581
  };
5264
- const id = thread?.id ?? (/^thd_[a-z0-9]{6,40}$/u.test(String(newThreadId)) ? newThreadId : newId("thd"));
5582
+ const id = thread?.id ?? fresh ?? newId("thd");
5265
5583
  const saved = await store.updateJson(store.paths.item("assist", id), null, (current) => {
5266
5584
  const next = current ?? {
5267
5585
  id,
@@ -5281,14 +5599,18 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5281
5599
  turn: saved.turns.length - 1,
5282
5600
  board
5283
5601
  };
5284
- }, { meta: { threadId: threadId ?? null } });
5602
+ }, { meta: {
5603
+ threadId: threadId ?? fresh,
5604
+ title: ask.slice(0, 60)
5605
+ } });
5285
5606
  }
5286
5607
  async function assistImages(ids) {
5287
5608
  return llm.supportsImages?.("teach") ? materialImages(store, await readMaterials(store, ids)) : [];
5288
5609
  }
5289
5610
  function makeShort({ topic, url, newId: presetId }) {
5290
5611
  const subject = text(topic, 300);
5291
- if (!subject && !url) throw new TutorError("invalid", "Give a topic or a web address");
5612
+ if (!subject && !url) throw new TutorError("empty-topic", "Give a topic or a web address");
5613
+ const id = /^sht_[a-z0-9]{6,40}$/u.test(String(presetId)) ? presetId : newId("sht");
5292
5614
  return jobs.start("short/make", async (api) => {
5293
5615
  let passages = "";
5294
5616
  let sourceTitle = "";
@@ -5302,7 +5624,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5302
5624
  const language = detectLanguage(subject || passages, uiLanguage());
5303
5625
  const board = await generateSteps(llm, {
5304
5626
  purpose: "teach",
5305
- effort: "low",
5627
+ effort: "lowest",
5306
5628
  prompt: shortPrompt({
5307
5629
  topic: subject || sourceTitle,
5308
5630
  passages,
@@ -5314,7 +5636,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5314
5636
  minSteps: 3
5315
5637
  });
5316
5638
  const item = {
5317
- id: /^sht_[a-z0-9]{6,40}$/u.test(String(presetId)) ? presetId : newId("sht"),
5639
+ id,
5318
5640
  title: board.title || subject || sourceTitle,
5319
5641
  topic: subject,
5320
5642
  sourceUrl: url ? String(url) : null,
@@ -5324,7 +5646,10 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5324
5646
  };
5325
5647
  await store.writeJson(store.paths.item("shorts", item.id), item);
5326
5648
  return { item };
5327
- });
5649
+ }, { meta: {
5650
+ id,
5651
+ title: subject
5652
+ } });
5328
5653
  }
5329
5654
  return Object.freeze({
5330
5655
  upload,
@@ -5437,7 +5762,12 @@ function apply(ctx) {
5437
5762
  const courses = createCourses({
5438
5763
  store,
5439
5764
  llm,
5440
- jobs
5765
+ jobs,
5766
+ research: createResearch({
5767
+ getWeb: () => ctx.get?.("web"),
5768
+ llm,
5769
+ logger
5770
+ })
5441
5771
  });
5442
5772
  const lessons = createLessons({
5443
5773
  store,