dsh-aha-tutor 1.2.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 intakeSummary, A as emptyProgress, B as passageRange, C as summaryPrompt, D as generateText, E as generateSteps, F as readMaterials, G as recommendNext, H as applyEvidence, I as readProfile, J as conceptIndex, K as boardToPlainText, L as readProgress, M as materialImages, N as materialsSummary, O as streamCounter, P as passagesFor, Q as insertLesson, R as documentParts, S as shortPrompt, T as generateJson, U as createConceptState, V as spreadPassages, W as masteryPercent, X as findLesson, Y as courseProgress, Z as flattenLessons, _ as projectsPrompt, a as blueprintPrompt, at as normalizePractice, b as reexplainPrompt, c as digestPartPrompt, ct as outlineForPrompt, d as gradePrompt, dt as assertId, et as lessonAfter, f as lessonPrompt, ft as newId, g as projectReviewPrompt, h as profilePrompt, i as assistPrompt, it as normalizeCheatsheet, j as masteryFor, k as EMPTY_PROFILE, l as digestPrompt, lt as safeRelativePath, m as practicePrompt, n as publishRuntime, nt as normalizeBlueprint, o as cheatsheetPrompt, ot as normalizeProject, p as materialBriefPrompt, q as normalizeBoard, r as askPrompt, rt as normalizeCards, s as detectLanguage, st as normalizeQuestionnaire, tt as nextLesson, u as flashcardsPrompt, ut as text, v as questionnairePrompt, w as TutorError, x as revisePrompt, y as quizPrompt, z as formatPassages } from "./runtime-ywaTyu56.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 });
@@ -217,7 +220,26 @@ const TIMELINES = [
217
220
  "balanced",
218
221
  "relaxed"
219
222
  ];
220
- function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date() }) {
223
+ const SIZES = [
224
+ "auto",
225
+ "short",
226
+ "standard",
227
+ "full"
228
+ ];
229
+ function outlineStream(api) {
230
+ let soFar = "";
231
+ const counter = streamCounter("\"estMinutes\"", (count) => {
232
+ api.progress(count, 0, "blueprint");
233
+ try {
234
+ api.preview(blueprintOutline(extractJson(soFar)));
235
+ } catch {}
236
+ });
237
+ return (delta) => {
238
+ soFar += String(delta ?? "");
239
+ counter(delta);
240
+ };
241
+ }
242
+ function createCourses({ store, llm, jobs, research, now = () => /* @__PURE__ */ new Date() }) {
221
243
  const stamp = () => now().toISOString();
222
244
  async function readCourse(courseId) {
223
245
  const course = await store.readJson(store.paths.course(assertId(courseId, "crs")), null);
@@ -265,7 +287,11 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
265
287
  }
266
288
  async function get({ courseId }) {
267
289
  const course = await readCourse(courseId);
268
- const [progress, intake] = await Promise.all([readProgress(store, courseId), store.readJson(store.paths.intake(courseId), null)]);
290
+ const [progress, intake, proposal] = await Promise.all([
291
+ readProgress(store, courseId),
292
+ store.readJson(store.paths.intake(courseId), null),
293
+ store.readJson(store.paths.proposal(courseId), null)
294
+ ]);
269
295
  const boards = new Set(await store.listIds(store.paths.boardsDir(courseId), "les"));
270
296
  const practice = new Set(await store.listIds(store.paths.practiceDir(courseId), "les"));
271
297
  const sheets = new Set(await store.listIds(store.paths.sheetsDir(courseId), "unt"));
@@ -273,6 +299,7 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
273
299
  course,
274
300
  progress,
275
301
  intake,
302
+ proposal,
276
303
  jobs: {
277
304
  questions: jobs.running(`questions:${courseId}`),
278
305
  blueprint: jobs.running(`blueprint:${courseId}`),
@@ -285,9 +312,9 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
285
312
  }
286
313
  };
287
314
  }
288
- function startCraft({ goal, materialIds = [], quick = false, language }) {
315
+ function startCraft({ goal, materialIds = [], quick = false, language, web = false }) {
289
316
  const cleanGoal = text(goal, 2e3);
290
- 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");
291
318
  const ids = materialIds.filter((id) => /^mat_[a-z0-9]+$/u.test(id)).slice(0, 8);
292
319
  const courseId = newId("crs");
293
320
  return jobs.start("craft/start", async (api) => {
@@ -303,10 +330,29 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
303
330
  createdAt: stamp()
304
331
  };
305
332
  await saveCourse(draft);
306
- if (quick) return {
307
- courseId,
308
- questionnaire: null
309
- };
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
+ }
310
356
  const profile = await readProfile(store);
311
357
  const questionnaire = await generateJson(llm, {
312
358
  purpose: "teach",
@@ -314,7 +360,8 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
314
360
  goal: draft.goal,
315
361
  language: draft.language,
316
362
  materialsSummary: materialsSummary(materials),
317
- profile
363
+ profile,
364
+ sources: found?.sources
318
365
  }),
319
366
  maxTokens: 3e3,
320
367
  signal: api.signal,
@@ -327,19 +374,21 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
327
374
  await store.writeJson(store.paths.intake(courseId), {
328
375
  questionnaire,
329
376
  answers: {},
377
+ ...found ? { research: found } : {},
330
378
  at: stamp()
331
379
  });
332
380
  api.progress(1, 2, "questions");
333
381
  return {
334
382
  courseId,
335
- questionnaire
383
+ questionnaire,
384
+ research: found
336
385
  };
337
386
  }, {
338
387
  key: `questions:${courseId}`,
339
388
  meta: { courseId }
340
389
  });
341
390
  }
342
- function submitIntake({ courseId, answers = {}, timeline = "balanced", weeklyMinutes, deadline }) {
391
+ function submitIntake({ courseId, answers = {}, timeline = "balanced", size = "auto", weeklyMinutes, deadline }) {
343
392
  assertId(courseId, "crs");
344
393
  return jobs.start("craft/blueprint", async (api) => {
345
394
  const course = await readCourse(courseId);
@@ -353,10 +402,12 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
353
402
  answers: cleanAnswers,
354
403
  at: stamp()
355
404
  });
405
+ if (course.materialIds?.length) api.progress(0, 0, "materials");
356
406
  const materials = await readMaterials(store, course.materialIds ?? []);
357
407
  const profile = await readProfile(store);
358
408
  const settings = {
359
409
  timeline: TIMELINES.includes(timeline) ? timeline : "balanced",
410
+ size: SIZES.includes(size) ? size : "auto",
360
411
  weeklyMinutes: clampMinutes(weeklyMinutes),
361
412
  deadline: /^\d{4}-\d{2}-\d{2}$/u.test(String(deadline ?? "")) ? deadline : null
362
413
  };
@@ -370,20 +421,24 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
370
421
  materialsSummary: materialsSummary(materials),
371
422
  profile,
372
423
  timeline: settings.timeline,
373
- weeklyMinutes: settings.weeklyMinutes
424
+ size: settings.size,
425
+ weeklyMinutes: settings.weeklyMinutes,
426
+ sources: intake.research?.sources
374
427
  }),
375
428
  images: llm.supportsImages?.("teach") ? await materialImages(store, materials) : void 0,
376
429
  maxTokens: 16e3,
377
430
  signal: api.signal,
378
- onText: streamCounter("\"estMinutes\"", (count) => api.progress(count, 0, "blueprint")),
431
+ onText: outlineStream(api),
379
432
  normalize: (raw) => attachProjectUnits(raw, normalizeBlueprint(raw, { language: course.language }))
380
433
  });
434
+ const sources = intake.research?.sources ?? [];
381
435
  const next = {
382
436
  ...course,
383
437
  ...blueprint,
384
438
  language: course.language,
385
439
  status: "blueprint",
386
- settings
440
+ settings,
441
+ ...sources.length ? { sources } : {}
387
442
  };
388
443
  await saveCourse(next);
389
444
  return { course: next };
@@ -394,10 +449,10 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
394
449
  }
395
450
  function revise({ courseId, instruction }) {
396
451
  const request = text(instruction, 1500);
397
- if (!request) throw new TutorError("invalid", "Describe the change you want");
452
+ if (!request) throw new TutorError("empty-change", "Describe the change you want");
398
453
  return jobs.start("craft/revise", async (api) => {
399
454
  const course = await readCourse(courseId);
400
- 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");
401
456
  const current = {
402
457
  title: course.title,
403
458
  subtitle: course.subtitle,
@@ -420,19 +475,78 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
420
475
  onText: streamCounter("\"estMinutes\"", (count) => api.progress(count, 0, "blueprint")),
421
476
  normalize: (raw) => attachProjectUnits(raw, normalizeBlueprint(raw, { language: course.language }))
422
477
  });
423
- const next = {
424
- ...course,
425
- ...revised,
426
- language: course.language,
427
- status: course.status === "ready" ? "ready" : "blueprint"
478
+ const proposal = {
479
+ instruction: request,
480
+ at: stamp(),
481
+ blueprint: revised
482
+ };
483
+ await store.writeJson(store.paths.proposal(courseId), proposal);
484
+ return {
485
+ course,
486
+ proposal
428
487
  };
429
- await saveCourse(next);
430
- return { course: next };
431
488
  }, {
432
489
  key: `blueprint:${courseId}`,
433
490
  meta: { courseId }
434
491
  });
435
492
  }
493
+ async function applyProposal({ courseId }) {
494
+ const course = await readCourse(courseId);
495
+ const proposal = await store.readJson(store.paths.proposal(courseId), null);
496
+ if (!proposal?.blueprint?.units) throw new TutorError("no-proposal", "There is no proposed change");
497
+ const next = {
498
+ ...course,
499
+ ...proposal.blueprint,
500
+ language: course.language,
501
+ status: course.status === "ready" ? "ready" : "blueprint"
502
+ };
503
+ await saveCourse(next);
504
+ await store.removeDir(store.paths.proposal(courseId));
505
+ return { course: next };
506
+ }
507
+ async function discardProposal({ courseId }) {
508
+ assertId(courseId, "crs");
509
+ await store.removeDir(store.paths.proposal(courseId));
510
+ return { ok: true };
511
+ }
512
+ function reworkLesson({ courseId, lesson: raw, unitTitle, lectureTitle, mode }) {
513
+ const split = mode === "split";
514
+ const lesson = normalizeLesson(raw);
515
+ if (!lesson) throw new TutorError("invalid", "Unknown lesson");
516
+ return jobs.start("craft/rework", async (api) => {
517
+ const course = await readCourse(courseId);
518
+ return { lessons: await generateJson(llm, {
519
+ purpose: "teach",
520
+ prompt: reworkPrompt({
521
+ outline: outlineForPrompt(course, { maxChars: 4e3 }),
522
+ unitTitle: text(unitTitle, 120),
523
+ lectureTitle: text(lectureTitle, 120),
524
+ lesson: {
525
+ title: lesson.title,
526
+ summary: lesson.summary,
527
+ objectives: lesson.objectives,
528
+ concepts: lesson.concepts.map((concept) => concept.name),
529
+ estMinutes: lesson.estMinutes
530
+ },
531
+ mode: split ? "split" : "deepen",
532
+ language: course.language
533
+ }),
534
+ maxTokens: 3e3,
535
+ signal: api.signal,
536
+ normalize: (value) => {
537
+ const list = (Array.isArray(value?.lessons) ? value.lessons : [value]).map((entry) => normalizeLesson({
538
+ ...entry,
539
+ id: void 0
540
+ })).filter(Boolean).slice(0, split ? 3 : 1);
541
+ if (split ? list.length < 2 : !list.length) throw new Error("no usable lessons");
542
+ return list;
543
+ }
544
+ }) };
545
+ }, {
546
+ key: `rework:${courseId}:${lesson.id}`,
547
+ meta: { courseId }
548
+ });
549
+ }
436
550
  async function saveBlueprint({ courseId, blueprint }) {
437
551
  const course = await readCourse(courseId);
438
552
  const normalized = normalizeBlueprint({
@@ -463,7 +577,7 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
463
577
  language: course.language,
464
578
  passages: passagesFor(sources, query, 5e3)
465
579
  }),
466
- maxTokens: Math.min(16e3, 3e3 + lessons.length * 2200),
580
+ maxTokens: Math.min(16e3, 3e3 + lessons.length * 2600),
467
581
  signal,
468
582
  normalize: (raw) => raw?.lessons ?? raw
469
583
  });
@@ -483,7 +597,7 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
483
597
  function confirm({ courseId }) {
484
598
  return jobs.start("craft/confirm", async (api) => {
485
599
  const course = await readCourse(courseId);
486
- if (!course.units) throw new TutorError("invalid", "Generate a blueprint first");
600
+ if (!course.units) throw new TutorError("no-blueprint", "Generate a blueprint first");
487
601
  const lectures = course.units.slice(0, 1).flatMap((unit) => unit.lectures.map((lecture) => ({
488
602
  unit,
489
603
  lecture
@@ -613,7 +727,17 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
613
727
  };
614
728
  }
615
729
  async function importPack({ json }) {
616
- 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
+ }
617
741
  const { course } = imported;
618
742
  await saveCourse(course);
619
743
  for (const [lessonId, board] of Object.entries(imported.boards)) await store.writeJson(store.paths.board(course.id, lessonId), board);
@@ -642,6 +766,9 @@ function createCourses({ store, llm, jobs, now = () => /* @__PURE__ */ new Date(
642
766
  startCraft,
643
767
  submitIntake,
644
768
  revise,
769
+ applyProposal,
770
+ discardProposal,
771
+ reworkLesson,
645
772
  saveBlueprint,
646
773
  writeLecturePractice,
647
774
  confirm,
@@ -741,6 +868,150 @@ const BOARD_FONT_FILES = Object.freeze([
741
868
  })
742
869
  ]);
743
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
744
1015
  //#region src/host/web-fetch.js
745
1016
  const LOCAL_ADDRESS_MESSAGE = "Local network addresses are not supported";
746
1017
  var AddressError = class extends Error {
@@ -1007,7 +1278,7 @@ function detectKind(name, bytes) {
1007
1278
  if (head[0] === 137 && head[1] === 80) return "image";
1008
1279
  if (head[0] === 255 && head[1] === 216) return "image";
1009
1280
  if (head.subarray(0, 2).toString("latin1") === "PK") {
1010
- 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");
1011
1282
  }
1012
1283
  throw new MaterialError("unsupported", "This file type is not supported");
1013
1284
  }
@@ -1261,7 +1532,7 @@ function extractPlain(bytes, kind) {
1261
1532
  chunks.push(...chunkText(part, heading ? { section: heading.slice(0, 80) } : {}));
1262
1533
  }
1263
1534
  } else chunks.push(...chunkText(value, {}));
1264
- if (!chunks.length) throw new MaterialError("no-text", "This file is empty");
1535
+ if (!chunks.length) throw new MaterialError("empty", "This file is empty");
1265
1536
  return {
1266
1537
  title: kind === "markdown" ? /^#\s+(.+)$/mu.exec(value)?.[1]?.trim() ?? "" : "",
1267
1538
  chunks
@@ -1273,7 +1544,7 @@ async function ingestFile({ name, bytes, signal }) {
1273
1544
  const kind = detectKind(name, bytes);
1274
1545
  const baseTitle = String(name ?? "").replace(/\.[^.]+$/u, "").slice(0, 120) || "Untitled";
1275
1546
  if (kind === "image") {
1276
- 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");
1277
1548
  return {
1278
1549
  kind,
1279
1550
  title: baseTitle,
@@ -1328,7 +1599,7 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
1328
1599
  } catch (error) {
1329
1600
  if (signal?.aborted) throw signal.reason ?? error;
1330
1601
  if (error?.name === "AddressError") throw new MaterialError("bad-url", error.message);
1331
- 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");
1332
1603
  throw new MaterialError("network", timeout.aborted ? "The page took too long to load" : "The page could not be loaded");
1333
1604
  }
1334
1605
  if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
@@ -1340,7 +1611,7 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
1340
1611
  if (!response?.ok) throw new MaterialError("http", `The page returned an error (${response?.status ?? "redirect loop"})`);
1341
1612
  const type = String(response.headers.get("content-type") ?? "").toLowerCase();
1342
1613
  const bytes = Buffer.from(await response.arrayBuffer());
1343
- 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");
1344
1615
  if (type.includes("application/pdf") || bytes.subarray(0, 5).toString("latin1") === "%PDF-") return {
1345
1616
  ...await ingestFile({
1346
1617
  name: `${url.pathname.split("/").pop() || "page"}.pdf`,
@@ -1366,7 +1637,7 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
1366
1637
  }
1367
1638
  const title = String(article?.title || document.querySelector("title")?.textContent || url.hostname).trim().slice(0, 160);
1368
1639
  const chunks = chunkText(article?.textContent || document.body?.textContent || "", { section: url.hostname });
1369
- 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");
1370
1641
  return finish("url", title, chunks, { url: url.href });
1371
1642
  }
1372
1643
  //#endregion
@@ -1421,7 +1692,7 @@ function uploadRoute({ studio }) {
1421
1692
  }) }
1422
1693
  });
1423
1694
  } catch (error) {
1424
- 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);
1425
1696
  }
1426
1697
  }
1427
1698
  };
@@ -1452,7 +1723,7 @@ function ttsRoute({ speak }) {
1452
1723
  });
1453
1724
  } catch (error) {
1454
1725
  const known = error?.name === "TtsError" || error?.name === "TutorError";
1455
- 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);
1456
1727
  }
1457
1728
  }
1458
1729
  };
@@ -1486,139 +1757,6 @@ function fontRoute({ dir = fontsDir() } = {}) {
1486
1757
  };
1487
1758
  }
1488
1759
  //#endregion
1489
- //#region src/host/jobs.js
1490
- const KEEP_FINISHED_MS = 10 * 6e4;
1491
- const MAX_JOBS = 200;
1492
- function createJobs({ logger, now = Date.now } = {}) {
1493
- const jobs = /* @__PURE__ */ new Map();
1494
- const byKey = /* @__PURE__ */ new Map();
1495
- function sweep() {
1496
- const cutoff = now() - KEEP_FINISHED_MS;
1497
- for (const [id, job] of jobs) if (job.finishedAt && job.finishedAt < cutoff) {
1498
- jobs.delete(id);
1499
- if (job.key && byKey.get(job.key) === id) byKey.delete(job.key);
1500
- }
1501
- if (jobs.size > MAX_JOBS) {
1502
- const finished = [...jobs.values()].filter((job) => job.finishedAt).sort((a, b) => a.finishedAt - b.finishedAt);
1503
- for (const job of finished.slice(0, jobs.size - MAX_JOBS)) jobs.delete(job.id);
1504
- }
1505
- }
1506
- function start(kind, run, { key, meta } = {}) {
1507
- sweep();
1508
- if (key && byKey.has(key)) {
1509
- const existing = jobs.get(byKey.get(key));
1510
- if (existing && !existing.finishedAt && !existing.controller.signal.aborted) return snapshot(existing);
1511
- }
1512
- const controller = new AbortController();
1513
- const job = {
1514
- id: newId("job"),
1515
- kind,
1516
- key: key ?? null,
1517
- meta: meta ?? {},
1518
- status: "running",
1519
- progress: {
1520
- done: 0,
1521
- total: 0,
1522
- label: ""
1523
- },
1524
- partial: [],
1525
- result: void 0,
1526
- error: void 0,
1527
- startedAt: now(),
1528
- finishedAt: null,
1529
- controller
1530
- };
1531
- jobs.set(job.id, job);
1532
- if (key) byKey.set(key, job.id);
1533
- const api = {
1534
- signal: controller.signal,
1535
- progress(done, total, label = job.progress.label) {
1536
- job.progress = {
1537
- done,
1538
- total,
1539
- label
1540
- };
1541
- },
1542
- push(item) {
1543
- job.partial.push(item);
1544
- }
1545
- };
1546
- const aborted = new Promise((_, reject) => {
1547
- controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? /* @__PURE__ */ new Error("cancelled")), { once: true });
1548
- });
1549
- Promise.race([Promise.resolve().then(() => run(api)), aborted]).then((result) => {
1550
- job.status = "done";
1551
- job.result = result;
1552
- }).catch((error) => {
1553
- job.status = controller.signal.aborted ? "cancelled" : "failed";
1554
- job.error = controller.signal.aborted ? {
1555
- code: "cancelled",
1556
- message: "Cancelled"
1557
- } : {
1558
- code: error?.code ?? "failed",
1559
- message: publicMessage(error)
1560
- };
1561
- if (job.status === "failed") logger?.warn?.("aha-tutor job %s failed: %s", kind, error?.message);
1562
- }).finally(() => {
1563
- job.finishedAt = now();
1564
- });
1565
- return snapshot(job);
1566
- }
1567
- function snapshot(job, { since = 0 } = {}) {
1568
- return {
1569
- id: job.id,
1570
- kind: job.kind,
1571
- meta: job.meta,
1572
- status: job.status,
1573
- progress: job.progress,
1574
- partial: job.partial.slice(since),
1575
- partialCount: job.partial.length,
1576
- result: job.status === "done" ? job.result : void 0,
1577
- error: job.error
1578
- };
1579
- }
1580
- function get(id, options) {
1581
- const job = jobs.get(id);
1582
- return job ? snapshot(job, options) : null;
1583
- }
1584
- function cancel(id) {
1585
- const job = jobs.get(id);
1586
- if (job && !job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("cancelled"));
1587
- return job ? snapshot(job) : null;
1588
- }
1589
- function running(key) {
1590
- const id = byKey.get(key);
1591
- const job = id ? jobs.get(id) : void 0;
1592
- return job && !job.finishedAt && !job.controller.signal.aborted ? snapshot(job) : null;
1593
- }
1594
- function cancelKey(key) {
1595
- const job = running(key);
1596
- return job ? cancel(job.id) : null;
1597
- }
1598
- function dispose() {
1599
- for (const job of jobs.values()) if (!job.finishedAt) job.controller.abort(/* @__PURE__ */ new Error("plugin stopped"));
1600
- jobs.clear();
1601
- byKey.clear();
1602
- }
1603
- return Object.freeze({
1604
- start,
1605
- get,
1606
- cancel,
1607
- cancelKey,
1608
- running,
1609
- dispose
1610
- });
1611
- }
1612
- function publicMessage(error) {
1613
- if (error?.userMessage) return String(error.userMessage);
1614
- if ([
1615
- "MaterialError",
1616
- "TtsError",
1617
- "TutorError"
1618
- ].includes(error?.name)) return String(error.message);
1619
- return "Generation failed";
1620
- }
1621
- //#endregion
1622
1760
  //#region src/core/planner.js
1623
1761
  const DAY_MS$1 = 1440 * 60 * 1e3;
1624
1762
  const HORIZON_DAYS = 400;
@@ -1662,12 +1800,13 @@ function clampNumber(value, min, max, fallback) {
1662
1800
  if (!Number.isFinite(number)) return fallback;
1663
1801
  return Math.min(max, Math.max(min, number));
1664
1802
  }
1665
- function buildPlan({ today, settings, courses = [], reviews = [], exams = [] }) {
1803
+ function buildPlan({ today, settings, courses = [], reviews = [], exams = [], spentToday = 0 }) {
1666
1804
  const start = today ?? toDay(/* @__PURE__ */ new Date());
1667
1805
  const { studyDays, weeklyMinutes, dailyMinutes } = normalizeSettings(settings);
1668
1806
  const queues = courses.map((course) => ({
1669
1807
  id: course.id,
1670
1808
  title: course.title,
1809
+ emoji: course.emoji || "",
1671
1810
  deadline: course.deadline || null,
1672
1811
  priority: Number(course.priority) || 0,
1673
1812
  remaining: (course.lessons || []).filter((lesson) => !lesson.done).map((lesson) => ({
@@ -1695,8 +1834,9 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
1695
1834
  minutes: 0
1696
1835
  }));
1697
1836
  if (studyDays.includes(weekday)) {
1698
- let budget = dailyMinutes;
1699
- 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));
1700
1840
  let reviewUsed = 0;
1701
1841
  while (pendingReviews.length && pendingReviews[0].dueDay <= day && reviewUsed + pendingReviews[0].minutes <= reviewBudget) {
1702
1842
  const review = pendingReviews.shift();
@@ -1706,6 +1846,8 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
1706
1846
  refId: review.id,
1707
1847
  courseId: review.courseId ?? null,
1708
1848
  title: review.title,
1849
+ courseTitle: review.courseTitle ?? "",
1850
+ courseEmoji: review.courseEmoji ?? "",
1709
1851
  minutes: review.minutes
1710
1852
  });
1711
1853
  }
@@ -1718,7 +1860,7 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
1718
1860
  for (const queue of ordered) {
1719
1861
  const lesson = queue.remaining[0];
1720
1862
  if (!lesson) continue;
1721
- const dayIsEmpty = !items.some((item) => item.kind === "lesson");
1863
+ const dayIsEmpty = !spent && !items.some((item) => item.kind === "lesson");
1722
1864
  if (lesson.minutes <= budget || dayIsEmpty) {
1723
1865
  queue.remaining.shift();
1724
1866
  budget -= lesson.minutes;
@@ -1728,6 +1870,7 @@ function buildPlan({ today, settings, courses = [], reviews = [], exams = [] })
1728
1870
  courseId: queue.id,
1729
1871
  title: lesson.title,
1730
1872
  courseTitle: queue.title,
1873
+ courseEmoji: queue.emoji,
1731
1874
  minutes: lesson.minutes
1732
1875
  });
1733
1876
  if (queue.remaining.length === 0) queue.finishDay = day;
@@ -1803,9 +1946,61 @@ function neededWeeklyMinutes(course, start, deadline, studyDays) {
1803
1946
  const perDay = remaining / studyDayCount / (1 - REVIEW_SHARE);
1804
1947
  return Math.ceil(perDay * studyDays.length / 10) * 10;
1805
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
+ }
1806
1987
  //#endregion
1807
1988
  //#region src/host/learner.js
1808
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
+ }
1809
2004
  function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), logger }) {
1810
2005
  let lastRefresh = 0;
1811
2006
  let refreshing = null;
@@ -1895,9 +2090,12 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
1895
2090
  0
1896
2091
  ]
1897
2092
  },
1898
- 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 : {}
1899
2096
  };
1900
2097
  }
2098
+ const validDay = (value) => /^\d{4}-\d{2}-\d{2}$/u.test(String(value ?? "")) ? value : null;
1901
2099
  async function activeCourses() {
1902
2100
  const ids = await store.listIds(store.paths.courses(), "crs");
1903
2101
  const result = [];
@@ -1913,35 +2111,67 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
1913
2111
  }
1914
2112
  async function plan({ today } = {}) {
1915
2113
  const file = await readPlanFile();
1916
- const day = /^\d{4}-\d{2}-\d{2}$/u.test(String(today ?? "")) ? today : toDay(now());
2114
+ const day = validDay(today) ?? toDay(now());
1917
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));
1918
2118
  const reviews = [];
2119
+ const done = {
2120
+ lessons: /* @__PURE__ */ new Map(),
2121
+ reviews: /* @__PURE__ */ new Map()
2122
+ };
1919
2123
  for (const { course, progress } of active) {
2124
+ const label = {
2125
+ courseId: course.id,
2126
+ courseTitle: course.title,
2127
+ courseEmoji: course.emoji ?? ""
2128
+ };
1920
2129
  const index = conceptIndex(course);
1921
2130
  for (const [conceptId, card] of Object.entries(progress.reviews ?? {})) {
1922
2131
  const concept = index.get(conceptId);
1923
2132
  if (!concept) continue;
1924
- reviews.push({
1925
- id: `${course.id}:${conceptId}`,
1926
- 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,
1927
2138
  title: concept.name,
1928
2139
  dueDay: toDay(new Date(card.dueAt)),
1929
2140
  minutes: 5
1930
2141
  });
1931
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
+ }
1932
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);
1933
2161
  const built = buildPlan({
1934
2162
  today: day,
2163
+ spentToday: [...spent.values()].reduce((sum, minutes) => sum + (Number(minutes) || 0), 0),
1935
2164
  settings: file.settings,
1936
2165
  courses: active.map(({ course, progress }) => ({
1937
2166
  id: course.id,
1938
2167
  title: course.title,
2168
+ emoji: course.emoji ?? "",
1939
2169
  deadline: course.settings?.deadline ?? null,
1940
2170
  lessons: flattenLessons(course).map(({ lesson }) => ({
1941
2171
  id: lesson.id,
1942
2172
  title: lesson.title,
1943
2173
  minutes: lesson.estMinutes,
1944
- done: progress.lessons?.[lesson.id]?.status === "done"
2174
+ done: progress.lessons?.[lesson.id]?.status === "done" || tickedLessons.has(lesson.id)
1945
2175
  }))
1946
2176
  })),
1947
2177
  reviews,
@@ -1952,13 +2182,57 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
1952
2182
  courseId: exam.courseId ?? null
1953
2183
  }))
1954
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
+ });
1955
2204
  return {
1956
2205
  settings: file.settings,
1957
2206
  exams: file.exams,
1958
2207
  plan: built,
1959
- today: built.days.find((entry) => entry.day === day)?.items ?? []
2208
+ today: calendar.today,
2209
+ calendar: calendar.days
1960
2210
  };
1961
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
+ }
1962
2236
  async function savePlanSettings({ weeklyMinutes, studyDays }) {
1963
2237
  await store.updateJson(store.paths.plan(), {}, (current) => ({
1964
2238
  ...current,
@@ -1971,7 +2245,7 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
1971
2245
  }
1972
2246
  async function addExam({ title, day, courseId }) {
1973
2247
  const name = text(title, 120);
1974
- 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");
1975
2249
  await store.updateJson(store.paths.plan(), {}, (current) => ({
1976
2250
  ...current,
1977
2251
  exams: [...current.exams ?? [], {
@@ -1990,10 +2264,11 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
1990
2264
  }));
1991
2265
  return plan();
1992
2266
  }
1993
- async function reviewSession({ limit = 8 } = {}) {
2267
+ async function reviewSession({ limit = 8, courseId } = {}) {
1994
2268
  const at = now().getTime();
1995
2269
  const items = [];
1996
2270
  for (const { course, progress } of await activeCourses()) {
2271
+ if (courseId && course.id !== courseId) continue;
1997
2272
  const index = conceptIndex(course);
1998
2273
  const due = Object.entries(progress.reviews ?? {}).filter(([id, card]) => index.has(id) && Date.parse(card.dueAt) <= at).sort((a, b) => Date.parse(a[1].dueAt) - Date.parse(b[1].dueAt));
1999
2274
  for (const [conceptId] of due) {
@@ -2002,7 +2277,7 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2002
2277
  const lesson = findLesson(course, concept.lessonId)?.lesson;
2003
2278
  const set = await store.readJson(store.paths.practice(course.id, concept.lessonId), null);
2004
2279
  const board = await store.readJson(store.paths.board(course.id, concept.lessonId), null);
2005
- const candidates = [...set?.items ?? [], ...(board?.steps ?? []).map((step) => step.check).filter(Boolean)].filter((item) => item.conceptIds?.includes(conceptId) && item.kind !== "short");
2280
+ const candidates = [...set?.items ?? [], ...(board?.steps ?? []).map((step) => step.check).filter(Boolean)].filter((item) => item.conceptIds?.includes(conceptId) && item.kind !== "short" && item.kind !== "work");
2006
2281
  const item = candidates[Math.floor(Math.random() * candidates.length)];
2007
2282
  if (item) items.push({
2008
2283
  courseId: course.id,
@@ -2024,6 +2299,8 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2024
2299
  removeNote,
2025
2300
  noteLesson,
2026
2301
  plan,
2302
+ check,
2303
+ uncheck,
2027
2304
  savePlanSettings,
2028
2305
  addExam,
2029
2306
  removeExam,
@@ -2122,6 +2399,7 @@ function round2(value) {
2122
2399
  //#region src/host/lessons.js
2123
2400
  const EVENTS_KEPT = 200;
2124
2401
  const STEP_ID = /^s\d{1,4}$/u;
2402
+ const BOARD_JOBS = ["lesson/board", "lesson/prefetch"];
2125
2403
  function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__PURE__ */ new Date() }) {
2126
2404
  const stamp = () => now().toISOString();
2127
2405
  async function context(courseId, lessonId) {
@@ -2165,7 +2443,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2165
2443
  mastery: Object.fromEntries(row.lesson.concepts.map((concept) => [concept.id, masteryPercent(progress.concepts?.[concept.id] ?? createConceptState(concept.id, concept.name))]))
2166
2444
  };
2167
2445
  }
2168
- async function buildBoard({ course, row, signal, onStep, directive, style }) {
2446
+ async function buildBoard({ course, row, signal, onStep, onProgress, directive, style }) {
2169
2447
  const progress = await readProgress(store, course.id);
2170
2448
  const profile = await readProfile(store);
2171
2449
  const materials = await readMaterials(store, course.materialIds ?? []);
@@ -2177,6 +2455,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2177
2455
  const kindDirective = row.lesson.kind === "remedial" ? "remediate" : row.lesson.kind === "review" ? "review" : row.lesson.kind === "challenge" ? "challenge" : void 0;
2178
2456
  return generateSteps(llm, {
2179
2457
  purpose: "teach",
2458
+ effort: "lowest",
2180
2459
  prompt: lessonPrompt({
2181
2460
  course,
2182
2461
  unit: row.unit,
@@ -2196,6 +2475,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2196
2475
  signal,
2197
2476
  conceptIds: row.lesson.concepts.map((concept) => concept.id),
2198
2477
  onStep,
2478
+ onProgress,
2199
2479
  minSteps: 3
2200
2480
  });
2201
2481
  }
@@ -2220,6 +2500,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2220
2500
  row,
2221
2501
  signal: api.signal,
2222
2502
  onStep: (step) => api.push(step),
2503
+ onProgress: (info) => api.preview(info),
2223
2504
  style: text(style, 300) || void 0
2224
2505
  });
2225
2506
  const saved = {
@@ -2252,7 +2533,8 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2252
2533
  course,
2253
2534
  row: next,
2254
2535
  signal: api.signal,
2255
- onStep: (step) => api.push(step)
2536
+ onStep: (step) => api.push(step),
2537
+ onProgress: (info) => api.preview(info)
2256
2538
  });
2257
2539
  const saved = {
2258
2540
  ...board,
@@ -2272,10 +2554,18 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2272
2554
  });
2273
2555
  }).catch(() => void 0);
2274
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
+ }
2275
2561
  async function scopeBoard(scope) {
2276
2562
  if (scope?.kind === "lesson") {
2277
2563
  const { course, row } = await context(scope.courseId, scope.lessonId);
2278
- 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);
2279
2569
  return {
2280
2570
  course,
2281
2571
  lesson: row.lesson,
@@ -2284,18 +2574,20 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2284
2574
  };
2285
2575
  }
2286
2576
  if (scope?.kind === "thread" || scope?.kind === "short") {
2287
- const kind = scope.kind === "thread" ? "assist" : "shorts";
2288
- const item = await store.readJson(store.paths.item(kind, scope.id), null);
2289
- if (!item) throw new TutorError("not-found", "Not found");
2290
- 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(" "));
2291
2583
  return {
2292
2584
  course: null,
2293
2585
  lesson: {
2294
- title: item.title,
2586
+ title: item?.title ?? writing.meta?.title ?? "",
2295
2587
  objectives: []
2296
2588
  },
2297
2589
  board,
2298
- language: item.language
2590
+ language
2299
2591
  };
2300
2592
  }
2301
2593
  throw new TutorError("invalid", "Unknown board");
@@ -2313,7 +2605,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2313
2605
  const anchorFor = (board, stepIndex, anchorStepId) => STEP_ID.test(String(anchorStepId)) ? anchorStepId : board?.steps?.[Math.max(0, Number(stepIndex) || 0)]?.id ?? "s1";
2314
2606
  function ask({ scope, stepIndex = 0, question, anchorStepId }) {
2315
2607
  const request = text(question, 1500);
2316
- if (!request) throw new TutorError("invalid", "Type your question");
2608
+ if (!request) throw new TutorError("empty-question", "Type your question");
2317
2609
  return jobs.start("lesson/ask", async (api) => {
2318
2610
  const { course, lesson, board, language } = await scopeBoard(scope);
2319
2611
  const profile = await readProfile(store);
@@ -2355,10 +2647,11 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2355
2647
  function reexplain({ scope, stepIndex = 0, style = "example", anchorStepId }) {
2356
2648
  return jobs.start("lesson/reexplain", async (api) => {
2357
2649
  const { course, lesson, board, language } = await scopeBoard(scope);
2358
- 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.");
2359
2651
  const index = Math.min(board.steps.length - 1, Math.max(0, Number(stepIndex) || 0));
2360
2652
  const answer = await generateSteps(llm, {
2361
2653
  purpose: "teach",
2654
+ effort: "lowest",
2362
2655
  prompt: reexplainPrompt({
2363
2656
  lesson,
2364
2657
  language,
@@ -2439,18 +2732,25 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2439
2732
  }
2440
2733
  async function grade({ item, response, language = "zh-CN" }) {
2441
2734
  if (!item?.prompt) throw new TutorError("invalid", "Missing question");
2735
+ const work = item.kind === "work";
2442
2736
  const answer = text(response, 4e3);
2443
2737
  if (!answer) return {
2444
2738
  score: 0,
2445
2739
  verdict: "incorrect",
2446
2740
  feedback: "",
2447
2741
  errorTag: "",
2448
- misconception: false
2742
+ misconception: false,
2743
+ ...work ? {
2744
+ wrongStep: "",
2745
+ why: "",
2746
+ fix: ""
2747
+ } : {}
2449
2748
  };
2450
2749
  return await generateJson(llm, {
2451
2750
  purpose: "quick",
2452
2751
  prompt: gradePrompt({
2453
2752
  item: {
2753
+ kind: work ? "work" : "short",
2454
2754
  prompt: text(item.prompt, 2e3),
2455
2755
  answer: text(item.answer, 3e3),
2456
2756
  rubric: Array.isArray(item.rubric) ? item.rubric.map((entry) => text(entry, 300)) : []
@@ -2458,7 +2758,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2458
2758
  response: answer,
2459
2759
  language
2460
2760
  }),
2461
- maxTokens: 1200,
2761
+ maxTokens: work ? 1800 : 1200,
2462
2762
  normalize: (raw) => ({
2463
2763
  score: Math.min(1, Math.max(0, Number(raw?.score) || 0)),
2464
2764
  verdict: [
@@ -2468,10 +2768,60 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2468
2768
  ].includes(raw?.verdict) ? raw.verdict : Number(raw?.score) >= .8 ? "correct" : Number(raw?.score) >= .4 ? "partial" : "incorrect",
2469
2769
  feedback: text(raw?.feedback, 1200),
2470
2770
  errorTag: text(raw?.errorTag, 80),
2471
- misconception: raw?.misconception === true
2771
+ misconception: raw?.misconception === true,
2772
+ ...work ? {
2773
+ wrongStep: text(raw?.wrongStep, 400),
2774
+ why: text(raw?.why, 400),
2775
+ fix: text(raw?.fix, 400)
2776
+ } : {}
2472
2777
  })
2473
2778
  });
2474
2779
  }
2780
+ function correct({ scope, stepIndex = 0, anchorStepId, item, response, grade: graded }) {
2781
+ const working = text(response, 4e3);
2782
+ if (!item?.prompt || !working) throw new TutorError("invalid", "Nothing to correct");
2783
+ const notes = graded && typeof graded === "object" ? graded : {};
2784
+ return jobs.start("lesson/correct", async (api) => {
2785
+ const { course, lesson, board, language } = await scopeBoard(scope);
2786
+ const answer = await generateSteps(llm, {
2787
+ purpose: "quick",
2788
+ prompt: correctionPrompt({
2789
+ course,
2790
+ lesson,
2791
+ language,
2792
+ boardSoFar: board ? boardToPlainText(board, {
2793
+ uptoStep: Number(stepIndex) || 0,
2794
+ maxChars: 5e3
2795
+ }) : "",
2796
+ item: {
2797
+ prompt: text(item.prompt, 2e3),
2798
+ answer: text(item.answer, 3e3)
2799
+ },
2800
+ response: working,
2801
+ grade: {
2802
+ feedback: text(notes.feedback, 1200),
2803
+ wrongStep: text(notes.wrongStep, 400),
2804
+ why: text(notes.why, 400),
2805
+ fix: text(notes.fix, 400)
2806
+ }
2807
+ }),
2808
+ maxTokens: 4e3,
2809
+ signal: api.signal,
2810
+ conceptIds: lesson?.concepts?.map((concept) => concept.id) ?? [],
2811
+ onStep: (step) => api.push(step)
2812
+ });
2813
+ const aside = await keepAside(scope, {
2814
+ kind: "correction",
2815
+ errorTag: text(notes.errorTag, 80),
2816
+ anchorStepId: anchorFor(board, stepIndex, anchorStepId),
2817
+ steps: answer.steps
2818
+ });
2819
+ return {
2820
+ steps: answer.steps,
2821
+ aside
2822
+ };
2823
+ });
2824
+ }
2475
2825
  async function savePosition({ courseId, lessonId, stepIndex = 0 }) {
2476
2826
  assertId(lessonId, "les");
2477
2827
  await store.updateJson(store.paths.progress(assertId(courseId, "crs")), null, (progress) => {
@@ -2744,6 +3094,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
2744
3094
  reexplain,
2745
3095
  record,
2746
3096
  grade,
3097
+ correct,
2747
3098
  savePosition,
2748
3099
  complete,
2749
3100
  practice,
@@ -2781,6 +3132,27 @@ const RETRYABLE$1 = /* @__PURE__ */ new Set([
2781
3132
  "TIMEOUT",
2782
3133
  "TRANSPORT"
2783
3134
  ]);
3135
+ const REASONING_ROOM = Object.freeze({
3136
+ off: 0,
3137
+ none: 0,
3138
+ minimal: 4e3,
3139
+ low: 16e3,
3140
+ medium: 16e3,
3141
+ high: 24e3,
3142
+ xhigh: 48e3,
3143
+ max: 48e3
3144
+ });
3145
+ const EFFORT_ORDER = [
3146
+ "off",
3147
+ "none",
3148
+ "minimal",
3149
+ "low",
3150
+ "medium",
3151
+ "high",
3152
+ "xhigh",
3153
+ "max"
3154
+ ];
3155
+ const STOPPED_AT_LIMIT = /* @__PURE__ */ new Set(["max-tokens", "length"]);
2784
3156
  const CALL_TIMEOUT_MS = 6 * 6e4;
2785
3157
  const MAX_RETRIES = 2;
2786
3158
  const USER_ERRORS = Object.freeze({
@@ -2794,6 +3166,7 @@ const USER_ERRORS = Object.freeze({
2794
3166
  CONTEXT_WINDOW_EXCEEDED: ["llm-context", "The material is too long for this model. Pick fewer materials or a model with a larger context."],
2795
3167
  NO_ADAPTER: ["llm-unavailable", "The selected model is not available. Pick another model in Aha settings."],
2796
3168
  TIMEOUT: ["llm-timeout", "The model took too long to respond. Try again."],
3169
+ EMPTY_RESPONSE: ["llm-empty", "The model returned nothing this time. Try again."],
2797
3170
  ABORTED: ["cancelled", "Cancelled"]
2798
3171
  });
2799
3172
  function toTutorError(code, fallback) {
@@ -2801,6 +3174,20 @@ function toTutorError(code, fallback) {
2801
3174
  if (known) return new TutorError(known[0], known[1]);
2802
3175
  return new TutorError("llm-failed", `The model call failed${code ? ` (${code})` : ""}${fallback ? `: ${fallback}` : ""}`);
2803
3176
  }
3177
+ function pickEffort(efforts, wanted) {
3178
+ if (!efforts?.length || !wanted) return void 0;
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
+ }
3183
+ return (efforts.find((effort) => effort.id === wanted) ?? efforts.find((effort) => effort.id !== "off" && effort.id !== "none") ?? efforts[0]).id;
3184
+ }
3185
+ function outputBudget(maxTokens, effort, model) {
3186
+ const cap = Number(model?.defaultMaxTokens) || 0;
3187
+ if (!cap || !model?.reasoning) return maxTokens;
3188
+ const room = REASONING_ROOM[effort ?? model.reasoning.defaultEffort] ?? REASONING_ROOM.high;
3189
+ return Math.max(Math.min(maxTokens, cap), Math.min(maxTokens + room, cap));
3190
+ }
2804
3191
  const sleep$1 = (ms, signal) => new Promise((resolve, reject) => {
2805
3192
  const timer = setTimeout(resolve, ms);
2806
3193
  signal?.addEventListener("abort", () => {
@@ -2826,7 +3213,7 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
2826
3213
  provider: selection.provider,
2827
3214
  model: selection.model
2828
3215
  };
2829
- 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.");
2830
3217
  }
2831
3218
  async function modalities(purpose, signal) {
2832
3219
  const llm = getLlm();
@@ -2860,9 +3247,9 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
2860
3247
  }
2861
3248
  return blocks;
2862
3249
  }
2863
- async function attempt({ purpose, system, user, images, maxTokens, signal, onText }) {
3250
+ async function attempt({ purpose, effort, system, user, images, maxTokens, signal, onText, onReasoning }) {
2864
3251
  const llm = getLlm();
2865
- 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.");
2866
3253
  const target = route(purpose);
2867
3254
  const timeout = AbortSignal.timeout(CALL_TIMEOUT_MS);
2868
3255
  const fused = signal ? AbortSignal.any([signal, timeout]) : timeout;
@@ -2871,13 +3258,14 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
2871
3258
  prepared = await llm.prepareCall({
2872
3259
  ...target,
2873
3260
  maxTokens
2874
- }, fused, purpose === "quick" ? (controls, model) => {
2875
- const lowest = model?.reasoning?.efforts?.[0];
2876
- return lowest ? {
3261
+ }, fused, (controls, model) => {
3262
+ const chosen = pickEffort(model?.reasoning?.efforts, purpose === "quick" ? "lowest" : effort);
3263
+ return {
2877
3264
  ...controls,
2878
- reasoningEffort: lowest.id
2879
- } : controls;
2880
- } : void 0);
3265
+ ...chosen ? { reasoningEffort: chosen } : {},
3266
+ maxTokens: outputBudget(maxTokens, chosen, model)
3267
+ };
3268
+ });
2881
3269
  } catch (error) {
2882
3270
  if (signal?.aborted) throw signal.reason ?? error;
2883
3271
  throw Object.assign(toTutorError(error?.code, error?.message), { retryable: false });
@@ -2897,7 +3285,8 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
2897
3285
  })) if (chunk.type === "text-delta" && typeof chunk.text === "string") {
2898
3286
  textValue += chunk.text;
2899
3287
  onText?.(chunk.text);
2900
- } 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;
2901
3290
  } catch (error) {
2902
3291
  if (signal?.aborted) throw signal.reason ?? error;
2903
3292
  throw Object.assign(toTutorError(timeout.aborted ? "TIMEOUT" : error?.code, error?.message), {
@@ -2916,25 +3305,32 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
2916
3305
  }
2917
3306
  if (!textValue.trim()) throw Object.assign(toTutorError("EMPTY_RESPONSE"), {
2918
3307
  retryable: true,
2919
- streamed: false
3308
+ streamed: false,
3309
+ grow: STOPPED_AT_LIMIT.has(finish?.kind)
2920
3310
  });
2921
3311
  return textValue;
2922
3312
  }
2923
3313
  async function call(options) {
2924
3314
  let lastError;
3315
+ let current = options;
2925
3316
  for (let tries = 0; tries <= MAX_RETRIES; tries += 1) try {
2926
- return await attempt(options);
3317
+ return await attempt(current);
2927
3318
  } catch (error) {
2928
3319
  lastError = error;
2929
3320
  if (options.signal?.aborted || !error?.retryable || error?.streamed || tries === MAX_RETRIES) throw error;
2930
- await sleep$1(Math.min(2e4, Number(error.retryAfterMs) || 1500 * (tries + 1)), options.signal);
3321
+ if (error.grow) current = {
3322
+ ...current,
3323
+ maxTokens: current.maxTokens * 2
3324
+ };
3325
+ else await sleep$1(Math.min(2e4, Number(error.retryAfterMs) || 1500 * (tries + 1)), options.signal);
2931
3326
  }
2932
3327
  throw lastError;
2933
3328
  }
2934
3329
  return Object.freeze({
2935
3330
  complete: (options) => call({
2936
3331
  ...options,
2937
- onText: void 0
3332
+ onText: void 0,
3333
+ onReasoning: void 0
2938
3334
  }),
2939
3335
  stream: (options) => call(options),
2940
3336
  route,
@@ -3029,6 +3425,7 @@ function createStore({ root }) {
3029
3425
  course: (id) => join(paths.courseDir(id), "course.json"),
3030
3426
  progress: (id) => join(paths.courseDir(id), "progress.json"),
3031
3427
  intake: (id) => join(paths.courseDir(id), "intake.json"),
3428
+ proposal: (id) => join(paths.courseDir(id), "proposal.json"),
3032
3429
  cards: (id) => join(paths.courseDir(id), "cards.json"),
3033
3430
  boardsDir: (id) => join(paths.courseDir(id), "boards"),
3034
3431
  practiceDir: (id) => join(paths.courseDir(id), "practice"),
@@ -3593,7 +3990,7 @@ function createProjects({ store, llm, jobs, courses, projectsRoot = resolveProje
3593
3990
  const content = await readWorkspace(folderOf(course, project));
3594
3991
  if (content) parts.push(`Files in the project folder:\n${content}`);
3595
3992
  }
3596
- 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");
3597
3994
  const result = await generateJson(llm, {
3598
3995
  purpose: "teach",
3599
3996
  prompt: projectReviewPrompt({
@@ -3655,6 +4052,100 @@ function createProjects({ store, llm, jobs, courses, projectsRoot = resolveProje
3655
4052
  });
3656
4053
  }
3657
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
3658
4149
  //#region src/host/rpc.js
3659
4150
  const ID = (prefix) => new RegExp(`^${prefix}_[a-z0-9]{6,40}$`, "u");
3660
4151
  const IDS = {
@@ -3684,19 +4175,24 @@ function idList(value, prefix, max = 8) {
3684
4175
  }
3685
4176
  function scopeOf(payload) {
3686
4177
  const scope = payload?.scope ?? {};
4178
+ const jobId = optional(scope, "jobId", "job");
4179
+ const writing = jobId ? { jobId } : {};
3687
4180
  if (scope.kind === "lesson") return {
3688
4181
  kind: "lesson",
3689
4182
  courseId: need(scope, "courseId", "crs"),
3690
- lessonId: need(scope, "lessonId", "les")
4183
+ lessonId: need(scope, "lessonId", "les"),
4184
+ ...writing
3691
4185
  };
3692
4186
  if (scope.kind === "thread") return {
3693
4187
  kind: "thread",
3694
4188
  id: need(scope, "id", "thd"),
3695
- turn: Number(scope.turn) || 0
4189
+ turn: Number(scope.turn) || 0,
4190
+ ...writing
3696
4191
  };
3697
4192
  if (scope.kind === "short") return {
3698
4193
  kind: "short",
3699
- id: need(scope, "id", "sht")
4194
+ id: need(scope, "id", "sht"),
4195
+ ...writing
3700
4196
  };
3701
4197
  throw new TutorError("invalid", "Invalid board scope");
3702
4198
  }
@@ -3730,12 +4226,23 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
3730
4226
  goal: payload.goal,
3731
4227
  materialIds: idList(payload.materialIds, "mat"),
3732
4228
  quick: payload.quick === true,
4229
+ web: payload.web === true,
3733
4230
  language: typeof payload.language === "string" ? payload.language.slice(0, 12) : void 0
3734
4231
  }) }),
4232
+ "course/proposal-apply": (payload) => courses.applyProposal({ courseId: need(payload, "courseId", "crs") }),
4233
+ "course/proposal-discard": (payload) => courses.discardProposal({ courseId: need(payload, "courseId", "crs") }),
4234
+ "course/rework-lesson": (payload) => ({ job: courses.reworkLesson({
4235
+ courseId: need(payload, "courseId", "crs"),
4236
+ lesson: payload.lesson,
4237
+ unitTitle: payload.unitTitle,
4238
+ lectureTitle: payload.lectureTitle,
4239
+ mode: payload.mode === "split" ? "split" : "deepen"
4240
+ }) }),
3735
4241
  "course/intake": (payload) => ({ job: courses.submitIntake({
3736
4242
  courseId: need(payload, "courseId", "crs"),
3737
4243
  answers: payload.answers,
3738
4244
  timeline: payload.timeline,
4245
+ size: payload.size,
3739
4246
  weeklyMinutes: payload.weeklyMinutes,
3740
4247
  deadline: payload.deadline
3741
4248
  }) }),
@@ -3818,6 +4325,14 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
3818
4325
  response: payload.response,
3819
4326
  language: typeof payload.language === "string" ? payload.language.slice(0, 12) : void 0
3820
4327
  }),
4328
+ "lesson/correct": (payload) => ({ job: lessons.correct({
4329
+ scope: scopeOf(payload),
4330
+ stepIndex: payload.stepIndex,
4331
+ anchorStepId: payload.anchorStepId,
4332
+ item: payload.item,
4333
+ response: payload.response,
4334
+ grade: payload.grade
4335
+ }) }),
3821
4336
  "lesson/position": (payload) => lessons.savePosition({
3822
4337
  courseId: need(payload, "courseId", "crs"),
3823
4338
  lessonId: need(payload, "lessonId", "les"),
@@ -3859,6 +4374,16 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
3859
4374
  "profile/clear": () => learner.clearProfile(),
3860
4375
  "profile/remove-note": (payload) => learner.removeNote({ id: need(payload, "id", "nte") }),
3861
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
+ }),
3862
4387
  "plan/settings": (payload) => learner.savePlanSettings({
3863
4388
  weeklyMinutes: payload.weeklyMinutes,
3864
4389
  studyDays: payload.studyDays
@@ -3869,7 +4394,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
3869
4394
  courseId: payload.courseId
3870
4395
  }),
3871
4396
  "plan/remove-exam": (payload) => learner.removeExam({ id: need(payload, "id", "exm") }),
3872
- "review/session": () => learner.reviewSession(),
4397
+ "review/session": (payload) => learner.reviewSession({ courseId: optional(payload, "courseId", "crs") }),
3873
4398
  "material/upload": (payload) => ({ job: studio.upload({
3874
4399
  name: payload.name,
3875
4400
  base64: payload.base64
@@ -3991,13 +4516,12 @@ function createRpcHandler(routes, { logger, checkPeer = isOwner } = {}) {
3991
4516
  };
3992
4517
  } catch (error) {
3993
4518
  if (signal?.aborted) throw error;
3994
- const known = [
4519
+ if (![
3995
4520
  "TutorError",
3996
4521
  "MaterialError",
3997
4522
  "TtsError"
3998
- ].includes(error?.name);
3999
- if (!known) logger?.warn?.("aha-tutor %s failed: %s", endpoint, error?.message);
4000
- 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));
4001
4525
  }
4002
4526
  };
4003
4527
  }
@@ -4157,15 +4681,15 @@ function checkEndpoint(value) {
4157
4681
  try {
4158
4682
  url = new URL(String(value));
4159
4683
  } catch {
4160
- throw new TtsError("config", "TTS address is not a valid URL");
4684
+ throw new TtsError("address", "TTS address is not a valid URL");
4161
4685
  }
4162
4686
  const local = [
4163
4687
  "localhost",
4164
4688
  "127.0.0.1",
4165
4689
  "[::1]"
4166
4690
  ].includes(url.hostname);
4167
- if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) throw new TtsError("config", "TTS address must use https (http only for localhost)");
4168
- 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");
4169
4693
  return url;
4170
4694
  }
4171
4695
  const SENTENCE_END = /([。!?!?;;…]+|\.(?=\s)|\n+)/u;
@@ -4268,7 +4792,7 @@ const synthesizers = {
4268
4792
  return readBounded(response);
4269
4793
  },
4270
4794
  async volcengine({ text, config, secret, rate, fetchImpl, signal }) {
4271
- 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");
4272
4796
  const response = await boundedFetch(fetchImpl, "https://openspeech.bytedance.com/api/v1/tts", {
4273
4797
  method: "POST",
4274
4798
  headers: {
@@ -4349,9 +4873,9 @@ const synthesizers = {
4349
4873
  },
4350
4874
  async azure({ text, config, secret, rate, fetchImpl, signal }) {
4351
4875
  const region = String(config.region || "").trim().toLowerCase();
4352
- 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");
4353
4877
  const voice = String(config.voice || "zh-CN-XiaoxiaoNeural");
4354
- 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");
4355
4879
  const lang = voice.split("-").slice(0, 2).join("-");
4356
4880
  const percent = Math.round((rateNumber(rate) - 1) * 100);
4357
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>`;
@@ -4391,7 +4915,7 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
4391
4915
  async function synthesize({ provider, config = {}, text, rate = 1, signal }) {
4392
4916
  if (!TTS_PROVIDERS.includes(provider)) throw new TtsError("config", "Unknown TTS provider");
4393
4917
  const content = String(text ?? "").trim().slice(0, 4e3);
4394
- if (!content) throw new TtsError("request", "Nothing to speak");
4918
+ if (!content) throw new TtsError("empty", "Nothing to speak");
4395
4919
  const merged = {
4396
4920
  ...providerDefaults(provider),
4397
4921
  ...config
@@ -4407,7 +4931,7 @@ function createTts({ cacheDir, fetchImpl = globalThis.fetch, getSecret }) {
4407
4931
  };
4408
4932
  } catch {}
4409
4933
  const secret = await getSecret(provider);
4410
- 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");
4411
4935
  const parts = [];
4412
4936
  for (const chunk of splitForTts(content, CHUNK_UNITS[provider])) parts.push(await synthesizers[provider]({
4413
4937
  text: chunk,
@@ -4540,7 +5064,7 @@ function createKeyStore({ getCredentials, credentialKey }) {
4540
5064
  };
4541
5065
  const service = () => {
4542
5066
  const credentials = getCredentials();
4543
- 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");
4544
5068
  return credentials;
4545
5069
  };
4546
5070
  return {
@@ -4559,7 +5083,7 @@ function createKeyStore({ getCredentials, credentialKey }) {
4559
5083
  },
4560
5084
  async write(provider, value) {
4561
5085
  const key = String(value ?? "").trim();
4562
- 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)");
4563
5087
  await service().modifyRecord(keyOf(provider), async () => ({
4564
5088
  kind: "api-key",
4565
5089
  key
@@ -4721,7 +5245,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
4721
5245
  function digest({ id }) {
4722
5246
  return jobs.start("material/digest", async (api) => {
4723
5247
  const [material] = await readMaterials(store, [assertId(id, "mat")]);
4724
- 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");
4725
5249
  const title = material.meta.title;
4726
5250
  const language = detectLanguage(material.chunks[0].text, uiLanguage());
4727
5251
  const size = material.chunks.reduce((sum, chunk) => sum + chunk.text.length, 0);
@@ -4804,9 +5328,9 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
4804
5328
  }
4805
5329
  async function scopeOf(materialIds) {
4806
5330
  const ids = (Array.isArray(materialIds) ? materialIds : []).filter((id) => /^mat_[a-z0-9]+$/u.test(String(id))).slice(0, 8);
4807
- 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");
4808
5332
  const materials = await readMaterials(store, ids);
4809
- 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");
4810
5334
  const title = materials.map((item) => item.meta.title).join(" + ");
4811
5335
  return {
4812
5336
  ids,
@@ -5021,8 +5545,9 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5021
5545
  }
5022
5546
  function assist({ mode = "explain", request, materialIds = [], threadId, newThreadId }) {
5023
5547
  const ask = text(request, 4e3);
5024
- if (!ask) throw new TutorError("invalid", "Type your question");
5548
+ if (!ask) throw new TutorError("empty-question", "Type your question");
5025
5549
  const chosen = ASSIST_MODES.includes(mode) ? mode : "explain";
5550
+ const fresh = /^thd_[a-z0-9]{6,40}$/u.test(String(newThreadId)) ? newThreadId : null;
5026
5551
  return jobs.start("assist/turn", async (api) => {
5027
5552
  let thread = threadId ? await store.readJson(store.paths.item("assist", assertId(threadId, "thd")), null) : null;
5028
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);
@@ -5033,6 +5558,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5033
5558
  const images = await assistImages(ids);
5034
5559
  const board = await generateSteps(llm, {
5035
5560
  purpose: "teach",
5561
+ effort: "lowest",
5036
5562
  prompt: assistPrompt({
5037
5563
  mode: chosen,
5038
5564
  request: ask,
@@ -5053,7 +5579,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5053
5579
  board,
5054
5580
  at: stamp()
5055
5581
  };
5056
- 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");
5057
5583
  const saved = await store.updateJson(store.paths.item("assist", id), null, (current) => {
5058
5584
  const next = current ?? {
5059
5585
  id,
@@ -5073,14 +5599,18 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5073
5599
  turn: saved.turns.length - 1,
5074
5600
  board
5075
5601
  };
5076
- }, { meta: { threadId: threadId ?? null } });
5602
+ }, { meta: {
5603
+ threadId: threadId ?? fresh,
5604
+ title: ask.slice(0, 60)
5605
+ } });
5077
5606
  }
5078
5607
  async function assistImages(ids) {
5079
5608
  return llm.supportsImages?.("teach") ? materialImages(store, await readMaterials(store, ids)) : [];
5080
5609
  }
5081
5610
  function makeShort({ topic, url, newId: presetId }) {
5082
5611
  const subject = text(topic, 300);
5083
- 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");
5084
5614
  return jobs.start("short/make", async (api) => {
5085
5615
  let passages = "";
5086
5616
  let sourceTitle = "";
@@ -5094,6 +5624,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5094
5624
  const language = detectLanguage(subject || passages, uiLanguage());
5095
5625
  const board = await generateSteps(llm, {
5096
5626
  purpose: "teach",
5627
+ effort: "lowest",
5097
5628
  prompt: shortPrompt({
5098
5629
  topic: subject || sourceTitle,
5099
5630
  passages,
@@ -5105,7 +5636,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5105
5636
  minSteps: 3
5106
5637
  });
5107
5638
  const item = {
5108
- id: /^sht_[a-z0-9]{6,40}$/u.test(String(presetId)) ? presetId : newId("sht"),
5639
+ id,
5109
5640
  title: board.title || subject || sourceTitle,
5110
5641
  topic: subject,
5111
5642
  sourceUrl: url ? String(url) : null,
@@ -5115,7 +5646,10 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
5115
5646
  };
5116
5647
  await store.writeJson(store.paths.item("shorts", item.id), item);
5117
5648
  return { item };
5118
- });
5649
+ }, { meta: {
5650
+ id,
5651
+ title: subject
5652
+ } });
5119
5653
  }
5120
5654
  return Object.freeze({
5121
5655
  upload,
@@ -5228,7 +5762,12 @@ function apply(ctx) {
5228
5762
  const courses = createCourses({
5229
5763
  store,
5230
5764
  llm,
5231
- jobs
5765
+ jobs,
5766
+ research: createResearch({
5767
+ getWeb: () => ctx.get?.("web"),
5768
+ llm,
5769
+ logger
5770
+ })
5232
5771
  });
5233
5772
  const lessons = createLessons({
5234
5773
  store,