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/AGENTS.md +3 -1
- package/CHANGELOG.md +31 -0
- package/README.en.md +4 -4
- package/README.md +4 -4
- package/docs/guide.zh-CN.md +22 -6
- package/lib/agent.js +1 -1
- package/lib/client.js +96 -38
- package/lib/index.js +559 -229
- package/lib/{runtime-BCGFUDlg.js → runtime-DqCjJ0yL.js} +65 -6
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
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
|
|
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
|
|
53
|
+
throw packError("bad-pack", "course pack is not valid JSON");
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
-
if (pack?.format !== "aha-tutor.coursepack") throw
|
|
57
|
-
if (!Number.isInteger(pack.version) || pack.version > 1) throw new
|
|
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("
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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
|
|
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
|
|
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
|
-
|
|
1794
|
-
|
|
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 =
|
|
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
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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:
|
|
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
|
|
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: "
|
|
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
|
|
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
|
|
2387
|
-
const
|
|
2388
|
-
|
|
2389
|
-
|
|
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
|
|
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("
|
|
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("
|
|
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: "
|
|
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:
|
|
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")
|
|
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-
|
|
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-
|
|
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 === "
|
|
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("
|
|
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
|
-
|
|
4519
|
+
if (![
|
|
4202
4520
|
"TutorError",
|
|
4203
4521
|
"MaterialError",
|
|
4204
4522
|
"TtsError"
|
|
4205
|
-
].includes(error?.name);
|
|
4206
|
-
|
|
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("
|
|
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("
|
|
4375
|
-
if (url.username || url.password) throw new TtsError("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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: "
|
|
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 ??
|
|
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: {
|
|
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("
|
|
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: "
|
|
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
|
|
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,
|