dsh-aha-tutor 1.6.0 → 1.8.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,14 +1,14 @@
1
- import { $ as BOARD_LIMITS, A as TutorError, B as passagesFor, C as researchQueriesPrompt, Ct as text, D as summaryPrompt, E as shortPrompt, Et as newId, F as EMPTY_PROFILE, G as formatPassages, H as readProfile, I as emptyProgress, J as applyEvidence, K as passageRange, L as masteryFor, M as generateSteps, N as generateText, O as verifyItemPrompt, P as streamCounter, Q as extractJson, R as materialImages, S as reexplainPrompt, St as safeRelativePath, T as reworkPrompt, Tt as assertId, U as readProgress, V as readMaterials, W as documentParts, X as masteryPercent, Y as createConceptState, Z as recommendNext, _ as profilePrompt, _t as normalizeLesson, a as blueprintPrompt, at as conceptIndex, b as questionnairePrompt, bt as normalizeQuestionnaire, c as correctionPrompt, ct as flattenLessons, d as digestPrompt, dt as lessonAfter, et as boardToPlainText, f as flashcardsPrompt, ft as nextLesson, g as practicePrompt, gt as normalizeItem, h as materialBriefPrompt, ht as normalizeCheatsheet, i as assistPrompt, it as blueprintOutline, j as generateJson, k as verifyStepPrompt, l as detectLanguage, lt as insertLesson, m as lessonPrompt, mt as normalizeCards, n as publishRuntime, nt as normalizeBoard, o as challengePrompt, ot as courseProgress, p as gradePrompt, pt as normalizeBlueprint, q as spreadPassages, r as askPrompt, rt as normalizeOp, s as cheatsheetPrompt, st as findLesson, tt as estimateSpeechMs, u as digestPartPrompt, ut as intakeSummary, v as projectReviewPrompt, vt as normalizePractice, w as revisePrompt, wt as plotToText, x as quizPrompt, xt as outlineForPrompt, y as projectsPrompt, yt as normalizeProject, z as materialsSummary } from "./runtime-B6zUpJjA.js";
1
+ import { $ as documentParts, A as shortPrompt, At as safeRelativePath, B as emptyProgress, C as questionnairePrompt, Ct as normalizeHandout, D as researchQueriesPrompt, Dt as normalizeProject, E as reexplainPrompt, Et as normalizePractice, F as generateJson, G as readMaterials, H as materialImages, I as generateSteps, J as applyEvidence, K as readProfile, L as generateText, M as verifyItemPrompt, Mt as plotToText, N as verifyStepPrompt, Nt as assertId, O as revisePrompt, Ot as normalizeQuestionnaire, P as TutorError, Pt as newId, Q as buildIndex, R as streamCounter, S as projectsPrompt, St as normalizeCheatsheet, T as recognizePrompt, Tt as normalizeLesson, U as materialsSummary, V as masteryFor, W as passagesFor, X as masteryPercent, Y as createConceptState, Z as recommendNext, _ as materialBriefPrompt, _t as intakeSummary, a as assistPrompt, at as extractJson, b as profilePrompt, bt as normalizeBlueprint, c as cheatsheetPrompt, ct as estimateSpeechMs, d as digestPartPrompt, dt as blueprintOutline, et as formatPassages, f as digestPrompt, ft as conceptIndex, g as lessonPrompt, gt as insertLesson, h as handoutPrompt, ht as flattenLessons, i as askPrompt, it as spreadPassages, j as summaryPrompt, jt as text, k as reworkPrompt, kt as outlineForPrompt, l as correctionPrompt, lt as normalizeBoard, m as gradePrompt, mt as findLesson, n as publishRuntime, nt as search, o as blueprintPrompt, ot as BOARD_LIMITS, p as flashcardsPrompt, pt as courseProgress, q as readProgress, r as RECOGNIZE_EMPTY, rt as snippetOf, s as challengePrompt, st as boardToPlainText, tt as passageRange, u as detectLanguage, ut as normalizeOp, v as materialsRevisePrompt, vt as lessonAfter, w as quizPrompt, wt as normalizeItem, x as projectReviewPrompt, xt as normalizeCards, y as practicePrompt, yt as nextLesson, z as EMPTY_PROFILE } from "./runtime-C3yJfJTT.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";
5
5
  import { lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
6
+ import zlib, { deflateSync } from "node:zlib";
6
7
  import { lookup } from "node:dns";
7
8
  import http from "node:http";
8
9
  import https from "node:https";
9
10
  import { isIP } from "node:net";
10
11
  import { pipeline } from "node:stream";
11
- import zlib from "node:zlib";
12
12
  import { spawn } from "node:child_process";
13
13
  import { existsSync } from "node:fs";
14
14
  import { fileURLToPath } from "node:url";
@@ -490,15 +490,76 @@ function createCourses({ store, llm, jobs, research, now = () => /* @__PURE__ */
490
490
  meta: { courseId }
491
491
  });
492
492
  }
493
+ function addMaterials({ courseId, materialIds = [], instruction }) {
494
+ const wanted = [...new Set(materialIds)].filter((id) => /^mat_[a-z0-9]+$/u.test(id)).slice(0, 12);
495
+ if (!wanted.length) throw new TutorError("no-materials", "Choose at least one material");
496
+ const request = text(instruction, 600);
497
+ return jobs.start("craft/revise", async (api) => {
498
+ const course = await readCourse(courseId);
499
+ if (!course.units) throw new TutorError("no-blueprint", "There is no blueprint to revise yet");
500
+ const known = course.materialIds ?? [];
501
+ const added = (await readMaterials(store, wanted.filter((id) => !known.includes(id)))).slice(0, Math.max(0, 12 - known.length));
502
+ if (!added.length) throw new TutorError(wanted.every((id) => known.includes(id)) ? "materials-known" : known.length >= 12 ? "materials-full" : "not-found", "These materials cannot be added");
503
+ const current = {
504
+ title: course.title,
505
+ subtitle: course.subtitle,
506
+ emoji: course.emoji,
507
+ summary: course.summary,
508
+ level: course.level,
509
+ units: course.units,
510
+ projects: course.projects
511
+ };
512
+ const share = Math.floor(9e3 / added.length);
513
+ const passages = added.filter((item) => item.chunks.length).map((item) => formatPassages(spreadPassages(item.chunks, {
514
+ maxChars: share,
515
+ samples: 8
516
+ }), item.meta.title)).join("\n\n");
517
+ api.progress(0, 0, "blueprint");
518
+ const revised = await generateJson(llm, {
519
+ purpose: "teach",
520
+ prompt: materialsRevisePrompt({
521
+ blueprint: current,
522
+ materials: materialsSummary(added),
523
+ passages,
524
+ instruction: request,
525
+ language: course.language
526
+ }),
527
+ images: llm.supportsImages?.("teach") ? await materialImages(store, added) : void 0,
528
+ maxTokens: 16e3,
529
+ signal: api.signal,
530
+ onText: streamCounter("\"estMinutes\"", (count) => api.progress(count, 0, "blueprint")),
531
+ normalize: (raw) => attachProjectUnits(raw, normalizeBlueprint(raw, { language: course.language }))
532
+ });
533
+ const proposal = {
534
+ instruction: request,
535
+ materials: added.map((item) => ({
536
+ id: item.meta.id,
537
+ title: item.meta.title
538
+ })),
539
+ at: stamp(),
540
+ blueprint: revised
541
+ };
542
+ await store.writeJson(store.paths.proposal(courseId), proposal);
543
+ return {
544
+ course,
545
+ proposal
546
+ };
547
+ }, {
548
+ key: `blueprint:${courseId}`,
549
+ meta: { courseId }
550
+ });
551
+ }
493
552
  async function applyProposal({ courseId }) {
494
553
  const course = await readCourse(courseId);
495
554
  const proposal = await store.readJson(store.paths.proposal(courseId), null);
496
555
  if (!proposal?.blueprint?.units) throw new TutorError("no-proposal", "There is no proposed change");
556
+ const materialIds = [.../* @__PURE__ */ new Set([...course.materialIds ?? [], ...(proposal.materials ?? []).map((item) => item.id)])].slice(0, 12);
497
557
  const next = {
498
558
  ...course,
499
559
  ...proposal.blueprint,
500
560
  language: course.language,
501
- status: course.status === "ready" ? "ready" : "blueprint"
561
+ status: course.status === "ready" ? "ready" : "blueprint",
562
+ ...materialIds.length ? { materialIds } : {}
502
563
  };
503
564
  await saveCourse(next);
504
565
  await store.removeDir(store.paths.proposal(courseId));
@@ -766,6 +827,7 @@ function createCourses({ store, llm, jobs, research, now = () => /* @__PURE__ */
766
827
  startCraft,
767
828
  submitIntake,
768
829
  revise,
830
+ addMaterials,
769
831
  applyProposal,
770
832
  discardProposal,
771
833
  reworkLesson,
@@ -1019,6 +1081,484 @@ const USER_ERRORS$1 = [
1019
1081
  "TutorError"
1020
1082
  ];
1021
1083
  //#endregion
1084
+ //#region src/host/page-image.js
1085
+ const MAX_PIXELS = 4e6;
1086
+ const MAX_EDGE = 2400;
1087
+ const OBJECT_WAIT_MS = 2e4;
1088
+ const IDENTITY = [
1089
+ 1,
1090
+ 0,
1091
+ 0,
1092
+ 1,
1093
+ 0,
1094
+ 0
1095
+ ];
1096
+ function multiply(m, n) {
1097
+ return [
1098
+ m[0] * n[0] + m[2] * n[1],
1099
+ m[1] * n[0] + m[3] * n[1],
1100
+ m[0] * n[2] + m[2] * n[3],
1101
+ m[1] * n[2] + m[3] * n[3],
1102
+ m[0] * n[4] + m[2] * n[5] + m[4],
1103
+ m[1] * n[4] + m[3] * n[5] + m[5]
1104
+ ];
1105
+ }
1106
+ function invert(m) {
1107
+ const det = m[0] * m[3] - m[1] * m[2];
1108
+ if (!det) return null;
1109
+ return [
1110
+ m[3] / det,
1111
+ -m[1] / det,
1112
+ -m[2] / det,
1113
+ m[0] / det,
1114
+ (m[2] * m[5] - m[3] * m[4]) / det,
1115
+ (m[1] * m[4] - m[0] * m[5]) / det
1116
+ ];
1117
+ }
1118
+ const apply$1 = (m, x, y) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];
1119
+ function grayOf(color) {
1120
+ const hex = /^#([0-9a-f]{6})$/iu.exec(String(color ?? ""))?.[1];
1121
+ if (!hex) return 0;
1122
+ const value = Number.parseInt(hex, 16);
1123
+ return Math.round(((value >> 16) * 299 + (value >> 8 & 255) * 587 + (value & 255) * 114) / 1e3);
1124
+ }
1125
+ function placements(OPS, operatorList) {
1126
+ const draws = [];
1127
+ let ctm = IDENTITY;
1128
+ let fill = 0;
1129
+ const stack = [];
1130
+ const push = () => stack.push({
1131
+ ctm,
1132
+ fill
1133
+ });
1134
+ const pop = () => {
1135
+ const saved = stack.pop();
1136
+ if (saved) ({ctm, fill} = saved);
1137
+ };
1138
+ const { fnArray, argsArray } = operatorList;
1139
+ for (let index = 0; index < fnArray.length; index += 1) {
1140
+ const args = argsArray[index] ?? [];
1141
+ switch (fnArray[index]) {
1142
+ case OPS.save:
1143
+ push();
1144
+ break;
1145
+ case OPS.restore:
1146
+ case OPS.paintFormXObjectEnd:
1147
+ case OPS.endGroup:
1148
+ pop();
1149
+ break;
1150
+ case OPS.transform:
1151
+ ctm = multiply(ctm, args);
1152
+ break;
1153
+ case OPS.paintFormXObjectBegin:
1154
+ push();
1155
+ if (Array.isArray(args[0])) ctm = multiply(ctm, args[0]);
1156
+ break;
1157
+ case OPS.beginGroup:
1158
+ push();
1159
+ if (Array.isArray(args[0]?.matrix)) ctm = multiply(ctm, args[0].matrix);
1160
+ break;
1161
+ case OPS.setFillRGBColor:
1162
+ fill = grayOf(args[0]);
1163
+ break;
1164
+ case OPS.paintImageXObject:
1165
+ draws.push({
1166
+ matrix: ctm,
1167
+ image: args[0]
1168
+ });
1169
+ break;
1170
+ case OPS.paintInlineImageXObject:
1171
+ draws.push({
1172
+ matrix: ctm,
1173
+ image: args[0]
1174
+ });
1175
+ break;
1176
+ case OPS.paintImageXObjectRepeat: {
1177
+ const [image, scaleX, scaleY, positions = []] = args;
1178
+ for (let at = 0; at + 1 < positions.length; at += 2) draws.push({
1179
+ matrix: multiply(ctm, [
1180
+ scaleX,
1181
+ 0,
1182
+ 0,
1183
+ scaleY,
1184
+ positions[at],
1185
+ positions[at + 1]
1186
+ ]),
1187
+ image
1188
+ });
1189
+ break;
1190
+ }
1191
+ case OPS.paintInlineImageXObjectGroup: {
1192
+ const [image, map = []] = args;
1193
+ for (const entry of map) draws.push({
1194
+ matrix: multiply(ctm, entry.transform),
1195
+ image,
1196
+ region: {
1197
+ x: entry.x,
1198
+ y: entry.y,
1199
+ w: entry.w,
1200
+ h: entry.h
1201
+ }
1202
+ });
1203
+ break;
1204
+ }
1205
+ case OPS.paintImageMaskXObject:
1206
+ draws.push({
1207
+ matrix: ctm,
1208
+ mask: args[0],
1209
+ fill
1210
+ });
1211
+ break;
1212
+ case OPS.paintImageMaskXObjectGroup:
1213
+ for (const mask of args[0] ?? []) draws.push({
1214
+ matrix: multiply(ctm, mask.transform),
1215
+ mask,
1216
+ fill
1217
+ });
1218
+ break;
1219
+ case OPS.paintImageMaskXObjectRepeat: {
1220
+ const [mask, scaleX, skewX = 0, skewY = 0, scaleY, positions = []] = args;
1221
+ for (let at = 0; at + 1 < positions.length; at += 2) draws.push({
1222
+ matrix: multiply(ctm, [
1223
+ scaleX,
1224
+ skewX,
1225
+ skewY,
1226
+ scaleY,
1227
+ positions[at],
1228
+ positions[at + 1]
1229
+ ]),
1230
+ mask,
1231
+ fill
1232
+ });
1233
+ break;
1234
+ }
1235
+ case OPS.paintSolidColorImageMask:
1236
+ draws.push({
1237
+ matrix: ctm,
1238
+ solid: fill
1239
+ });
1240
+ break;
1241
+ default: break;
1242
+ }
1243
+ }
1244
+ return draws;
1245
+ }
1246
+ async function resolve(page, value) {
1247
+ if (typeof value !== "string") return value ?? null;
1248
+ const objects = value.startsWith("g_") ? page.commonObjs : page.objs;
1249
+ let timer;
1250
+ try {
1251
+ return await Promise.race([new Promise((done) => {
1252
+ objects.get(value, done);
1253
+ }), new Promise((done) => {
1254
+ timer = setTimeout(() => done(null), OBJECT_WAIT_MS);
1255
+ })]);
1256
+ } finally {
1257
+ clearTimeout(timer);
1258
+ }
1259
+ }
1260
+ function shrink(image, { factor = 1, mask = false, region } = {}) {
1261
+ const { width, height, kind, data } = image;
1262
+ const x0 = region?.x ?? 0;
1263
+ const y0 = region?.y ?? 0;
1264
+ const w = Math.max(1, Math.min(region?.w ?? width, width - x0));
1265
+ const h = Math.max(1, Math.min(region?.h ?? height, height - y0));
1266
+ const outW = Math.max(1, Math.ceil(w / factor));
1267
+ const outH = Math.max(1, Math.ceil(h / factor));
1268
+ const gray = new Uint8Array(outW * outH);
1269
+ const alpha = new Uint8Array(outW * outH);
1270
+ let opaque = true;
1271
+ const bits = mask || kind === 1;
1272
+ const stride = bits ? width + 7 >> 3 : width * (kind === 3 ? 4 : 3);
1273
+ for (let oy = 0; oy < outH; oy += 1) {
1274
+ const yStart = y0 + oy * factor;
1275
+ const yEnd = Math.min(y0 + h, yStart + factor);
1276
+ for (let ox = 0; ox < outW; ox += 1) {
1277
+ const xStart = x0 + ox * factor;
1278
+ const xEnd = Math.min(x0 + w, xStart + factor);
1279
+ let level = 0;
1280
+ let cover = 0;
1281
+ let count = 0;
1282
+ for (let y = yStart; y < yEnd; y += 1) {
1283
+ const row = y * stride;
1284
+ for (let x = xStart; x < xEnd; x += 1) {
1285
+ count += 1;
1286
+ if (bits) {
1287
+ const set = data[row + (x >> 3)] >> 7 - (x & 7) & 1;
1288
+ if (mask) cover += set ? 0 : 255;
1289
+ else {
1290
+ level += set ? 255 : 0;
1291
+ cover += 255;
1292
+ }
1293
+ } else {
1294
+ const at = row + x * (kind === 3 ? 4 : 3);
1295
+ const value = (data[at] * 299 + data[at + 1] * 587 + data[at + 2] * 114) / 1e3;
1296
+ const opacity = kind === 3 ? data[at + 3] : 255;
1297
+ level += value * opacity;
1298
+ cover += opacity;
1299
+ }
1300
+ }
1301
+ }
1302
+ const at = oy * outW + ox;
1303
+ alpha[at] = count ? Math.round(cover / count) : 0;
1304
+ if (alpha[at] < 255) opaque = false;
1305
+ gray[at] = bits ? mask ? 0 : Math.round(level / count) : cover ? Math.round(level / cover) : 255;
1306
+ }
1307
+ }
1308
+ return {
1309
+ width: outW,
1310
+ height: outH,
1311
+ gray,
1312
+ alpha,
1313
+ opaque
1314
+ };
1315
+ }
1316
+ function paint(page, full, picture, { fill = null } = {}) {
1317
+ const inverse = invert(full);
1318
+ if (!inverse) return;
1319
+ const corners = [
1320
+ [0, 0],
1321
+ [1, 0],
1322
+ [0, 1],
1323
+ [1, 1]
1324
+ ].map(([x, y]) => apply$1(full, x, y));
1325
+ const left = Math.max(0, Math.floor(Math.min(...corners.map((point) => point[0]))));
1326
+ const right = Math.min(page.width, Math.ceil(Math.max(...corners.map((point) => point[0]))));
1327
+ const top = Math.max(0, Math.floor(Math.min(...corners.map((point) => point[1]))));
1328
+ const bottom = Math.min(page.height, Math.ceil(Math.max(...corners.map((point) => point[1]))));
1329
+ const [ia, ib, ic, id, ie, iff] = inverse;
1330
+ if (picture && Math.abs(ib) < 1e-9 && Math.abs(ic) < 1e-9) {
1331
+ paintAligned(page, picture, {
1332
+ left,
1333
+ right,
1334
+ top,
1335
+ bottom,
1336
+ ia,
1337
+ id,
1338
+ ie,
1339
+ iff,
1340
+ fill
1341
+ });
1342
+ return;
1343
+ }
1344
+ const { width, height, gray, alpha } = picture ?? {
1345
+ width: 1,
1346
+ height: 1
1347
+ };
1348
+ for (let y = top; y < bottom; y += 1) {
1349
+ const py = y + .5;
1350
+ for (let x = left; x < right; x += 1) {
1351
+ const px = x + .5;
1352
+ const u = ia * px + ic * py + ie;
1353
+ const v = ib * px + id * py + iff;
1354
+ if (u < 0 || u >= 1 || v < 0 || v >= 1) continue;
1355
+ const at = y * page.width + x;
1356
+ if (!picture) {
1357
+ page.gray[at] = fill;
1358
+ continue;
1359
+ }
1360
+ const fx = Math.min(width - 1, Math.max(0, u * width - .5));
1361
+ const fy = Math.min(height - 1, Math.max(0, (1 - v) * height - .5));
1362
+ const x1 = Math.floor(fx);
1363
+ const y1 = Math.floor(fy);
1364
+ const x2 = Math.min(width - 1, x1 + 1);
1365
+ const y2 = Math.min(height - 1, y1 + 1);
1366
+ const tx = fx - x1;
1367
+ const ty = fy - y1;
1368
+ const w1 = (1 - tx) * (1 - ty);
1369
+ const w2 = tx * (1 - ty);
1370
+ const w3 = (1 - tx) * ty;
1371
+ const w4 = tx * ty;
1372
+ const p1 = y1 * width + x1;
1373
+ const p2 = y1 * width + x2;
1374
+ const p3 = y2 * width + x1;
1375
+ const p4 = y2 * width + x2;
1376
+ const cover = alpha[p1] * w1 + alpha[p2] * w2 + alpha[p3] * w3 + alpha[p4] * w4;
1377
+ if (!cover) continue;
1378
+ const ink = fill ?? (gray[p1] * alpha[p1] * w1 + gray[p2] * alpha[p2] * w2 + gray[p3] * alpha[p3] * w3 + gray[p4] * alpha[p4] * w4) / cover;
1379
+ const share = cover / 255;
1380
+ page.gray[at] = Math.round(page.gray[at] * (1 - share) + ink * share);
1381
+ }
1382
+ }
1383
+ }
1384
+ function axisSamples(from, to, scale, offset, size, flip) {
1385
+ const first = new Int32Array(to - from);
1386
+ const second = new Int32Array(to - from);
1387
+ const weight = new Float32Array(to - from);
1388
+ for (let index = 0; index < to - from; index += 1) {
1389
+ const unit = scale * (from + index + .5) + offset;
1390
+ if (unit < 0 || unit >= 1) {
1391
+ first[index] = -1;
1392
+ continue;
1393
+ }
1394
+ const position = Math.min(size - 1, Math.max(0, (flip ? 1 - unit : unit) * size - .5));
1395
+ first[index] = Math.floor(position);
1396
+ second[index] = Math.min(size - 1, first[index] + 1);
1397
+ weight[index] = position - first[index];
1398
+ }
1399
+ return {
1400
+ first,
1401
+ second,
1402
+ weight
1403
+ };
1404
+ }
1405
+ function paintAligned(page, picture, { left, right, top, bottom, ia, id, ie, iff, fill }) {
1406
+ const { width, height, gray, alpha, opaque } = picture;
1407
+ const columns = axisSamples(left, right, ia, ie, width, false);
1408
+ const rows = axisSamples(top, bottom, id, iff, height, true);
1409
+ for (let y = top; y < bottom; y += 1) {
1410
+ const row = y - top;
1411
+ if (rows.first[row] < 0) continue;
1412
+ const r1 = rows.first[row] * width;
1413
+ const r2 = rows.second[row] * width;
1414
+ const ty = rows.weight[row];
1415
+ const out = y * page.width;
1416
+ for (let x = left; x < right; x += 1) {
1417
+ const column = x - left;
1418
+ const c1 = columns.first[column];
1419
+ if (c1 < 0) continue;
1420
+ const c2 = columns.second[column];
1421
+ const tx = columns.weight[column];
1422
+ const p1 = r1 + c1;
1423
+ const p2 = r1 + c2;
1424
+ const p3 = r2 + c1;
1425
+ const p4 = r2 + c2;
1426
+ if (opaque && fill === null) {
1427
+ const above = gray[p1] + (gray[p2] - gray[p1]) * tx;
1428
+ const below = gray[p3] + (gray[p4] - gray[p3]) * tx;
1429
+ page.gray[out + x] = Math.round(above + (below - above) * ty);
1430
+ continue;
1431
+ }
1432
+ const w1 = (1 - tx) * (1 - ty);
1433
+ const w2 = tx * (1 - ty);
1434
+ const w3 = (1 - tx) * ty;
1435
+ const w4 = tx * ty;
1436
+ const cover = alpha[p1] * w1 + alpha[p2] * w2 + alpha[p3] * w3 + alpha[p4] * w4;
1437
+ if (!cover) continue;
1438
+ const ink = fill ?? (gray[p1] * alpha[p1] * w1 + gray[p2] * alpha[p2] * w2 + gray[p3] * alpha[p3] * w3 + gray[p4] * alpha[p4] * w4) / cover;
1439
+ const share = cover / 255;
1440
+ page.gray[out + x] = Math.round(page.gray[out + x] * (1 - share) + ink * share);
1441
+ }
1442
+ }
1443
+ }
1444
+ function extent(full) {
1445
+ const [x0, y0] = apply$1(full, 0, 0);
1446
+ const [x1, y1] = apply$1(full, 1, 0);
1447
+ const [x2, y2] = apply$1(full, 0, 1);
1448
+ return {
1449
+ across: Math.hypot(x1 - x0, y1 - y0),
1450
+ down: Math.hypot(x2 - x0, y2 - y0)
1451
+ };
1452
+ }
1453
+ async function pageImage(pdfjs, page) {
1454
+ const operatorList = await page.getOperatorList({ annotationMode: pdfjs.AnnotationMode?.DISABLE });
1455
+ const draws = placements(pdfjs.OPS, operatorList);
1456
+ if (!draws.length) return null;
1457
+ const base = page.getViewport({ scale: 1 });
1458
+ const pictures = await Promise.all(draws.map((draw) => draw.solid !== void 0 ? null : resolve(page, draw.image ?? (typeof draw.mask?.data === "string" ? draw.mask.data : draw.mask))));
1459
+ let density = 1;
1460
+ for (const [index, draw] of draws.entries()) {
1461
+ const decoded = pictures[index];
1462
+ if (!decoded?.width) continue;
1463
+ const size = extent(multiply(base.transform, draw.matrix));
1464
+ if (size.across * size.down < base.width * base.height * .05) continue;
1465
+ density = Math.max(density, Math.min((draw.region?.w ?? decoded.width) / Math.max(1, size.across), (draw.region?.h ?? decoded.height) / Math.max(1, size.down)));
1466
+ }
1467
+ const scale = Math.min(density, Math.sqrt(MAX_PIXELS / (base.width * base.height)), MAX_EDGE / Math.max(base.width, base.height));
1468
+ const viewport = page.getViewport({ scale });
1469
+ const bitmap = {
1470
+ width: Math.max(1, Math.ceil(viewport.width)),
1471
+ height: Math.max(1, Math.ceil(viewport.height))
1472
+ };
1473
+ bitmap.gray = new Uint8Array(bitmap.width * bitmap.height).fill(255);
1474
+ let painted = false;
1475
+ for (const [index, draw] of draws.entries()) {
1476
+ const full = multiply(viewport.transform, draw.matrix);
1477
+ if (draw.solid !== void 0) {
1478
+ paint(bitmap, full, null, { fill: draw.solid });
1479
+ painted = true;
1480
+ continue;
1481
+ }
1482
+ const decoded = pictures[index];
1483
+ const mask = Boolean(draw.mask);
1484
+ const image = mask ? {
1485
+ ...draw.mask,
1486
+ ...decoded ?? {},
1487
+ kind: 1
1488
+ } : decoded;
1489
+ if (!image?.data || !image.width || !image.height) continue;
1490
+ if (!mask && ![
1491
+ 1,
1492
+ 2,
1493
+ 3
1494
+ ].includes(image.kind)) continue;
1495
+ const size = extent(full);
1496
+ paint(bitmap, full, shrink(image, {
1497
+ factor: Math.max(1, Math.floor(Math.min((draw.region?.w ?? image.width) / Math.max(1, size.across), (draw.region?.h ?? image.height) / Math.max(1, size.down)))),
1498
+ mask,
1499
+ region: draw.region
1500
+ }), { fill: mask ? draw.fill : null });
1501
+ painted = true;
1502
+ }
1503
+ if (!painted) return null;
1504
+ return {
1505
+ mime: "image/png",
1506
+ data: encodeGrayPng(bitmap.width, bitmap.height, bitmap.gray),
1507
+ width: bitmap.width,
1508
+ height: bitmap.height
1509
+ };
1510
+ }
1511
+ const CRC_TABLE = (/* @__PURE__ */ new Uint32Array(256)).map((_, index) => {
1512
+ let value = index;
1513
+ for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 3988292384 ^ value >>> 1 : value >>> 1;
1514
+ return value >>> 0;
1515
+ });
1516
+ function crc32(bytes) {
1517
+ let value = 4294967295;
1518
+ for (const byte of bytes) value = CRC_TABLE[(value ^ byte) & 255] ^ value >>> 8;
1519
+ return (value ^ 4294967295) >>> 0;
1520
+ }
1521
+ function chunk(type, body) {
1522
+ const head = Buffer.alloc(8);
1523
+ head.writeUInt32BE(body.length, 0);
1524
+ head.write(type, 4, "latin1");
1525
+ const tail = Buffer.alloc(4);
1526
+ tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
1527
+ return Buffer.concat([
1528
+ head,
1529
+ body,
1530
+ tail
1531
+ ]);
1532
+ }
1533
+ function encodeGrayPng(width, height, gray) {
1534
+ const raw = Buffer.alloc((width + 1) * height);
1535
+ for (let y = 0; y < height; y += 1) {
1536
+ const out = y * (width + 1);
1537
+ raw[out] = 2;
1538
+ for (let x = 0; x < width; x += 1) raw[out + 1 + x] = gray[y * width + x] - (y ? gray[(y - 1) * width + x] : 0) & 255;
1539
+ }
1540
+ const header = Buffer.alloc(13);
1541
+ header.writeUInt32BE(width, 0);
1542
+ header.writeUInt32BE(height, 4);
1543
+ header[8] = 8;
1544
+ header[9] = 0;
1545
+ return Buffer.concat([
1546
+ Buffer.from([
1547
+ 137,
1548
+ 80,
1549
+ 78,
1550
+ 71,
1551
+ 13,
1552
+ 10,
1553
+ 26,
1554
+ 10
1555
+ ]),
1556
+ chunk("IHDR", header),
1557
+ chunk("IDAT", deflateSync(raw)),
1558
+ chunk("IEND", Buffer.alloc(0))
1559
+ ]);
1560
+ }
1561
+ //#endregion
1022
1562
  //#region src/host/web-fetch.js
1023
1563
  const LOCAL_ADDRESS_MESSAGE = "Local network addresses are not supported";
1024
1564
  var AddressError = class extends Error {
@@ -1257,6 +1797,16 @@ const MAX_TEXT_CHARS = 2e6;
1257
1797
  const CHUNK_CHARS = 1600;
1258
1798
  const FETCH_TIMEOUT_MS = 2e4;
1259
1799
  const MAX_PAGE_BYTES = 6 * 1024 * 1024;
1800
+ const MIN_PAGE_TEXT = 30;
1801
+ const TEXT_PER_PICTURE = 150;
1802
+ const MIN_PICTURE_BYTES = 2048;
1803
+ const PICTURE_TYPES = Object.freeze({
1804
+ png: "image/png",
1805
+ jpg: "image/jpeg",
1806
+ jpeg: "image/jpeg",
1807
+ gif: "image/gif",
1808
+ webp: "image/webp"
1809
+ });
1260
1810
  const pageFetch = createGuardedFetch({ maxBytes: MAX_UPLOAD_BYTES });
1261
1811
  var MaterialError = class extends Error {
1262
1812
  constructor(code, message) {
@@ -1397,6 +1947,71 @@ function finish(kind, title, chunks, extra = {}) {
1397
1947
  ...extra
1398
1948
  };
1399
1949
  }
1950
+ const visibleLength = (value) => String(value ?? "").replace(/\s+/gu, "").length;
1951
+ async function readPictures(recognize, sources) {
1952
+ if (!sources.length) return {
1953
+ texts: [],
1954
+ note: null,
1955
+ available: true,
1956
+ error: null
1957
+ };
1958
+ const tried = sources.slice(0, 200);
1959
+ const results = recognize ? await recognize(tried) : null;
1960
+ if (!results) return {
1961
+ texts: [],
1962
+ note: { unread: sources.length },
1963
+ available: false,
1964
+ error: null
1965
+ };
1966
+ const error = results.find((result) => result?.error)?.error ?? null;
1967
+ const failed = tried.filter((source, index) => results[index]?.error).map((source) => source.where);
1968
+ return {
1969
+ texts: tried.map((source, index) => results[index]?.text ?? ""),
1970
+ note: {
1971
+ read: results.filter((result) => result && !result.error).length,
1972
+ failed,
1973
+ reason: error?.code ?? null,
1974
+ left: sources.length - tried.length
1975
+ },
1976
+ available: true,
1977
+ error
1978
+ };
1979
+ }
1980
+ function resolvePart(base, target) {
1981
+ const parts = target.startsWith("/") ? [] : base.split("/").filter(Boolean);
1982
+ for (const piece of target.split("/")) if (piece === "..") parts.pop();
1983
+ else if (piece && piece !== ".") parts.push(piece);
1984
+ return parts.join("/");
1985
+ }
1986
+ async function partPictures(zip, part) {
1987
+ const slash = part.lastIndexOf("/");
1988
+ const folder = part.slice(0, slash);
1989
+ const xml = await zip.file(part)?.async("string") ?? "";
1990
+ const rels = await zip.file(`${folder}/_rels/${part.slice(slash + 1)}.rels`)?.async("string") ?? "";
1991
+ const targets = /* @__PURE__ */ new Map();
1992
+ for (const [, attributes] of rels.matchAll(/<Relationship\b([^>]*)>/gu)) {
1993
+ const id = /\bId="([^"]+)"/u.exec(attributes)?.[1];
1994
+ const target = /\bTarget="([^"]+)"/u.exec(attributes)?.[1];
1995
+ if (!id || !target || !/\bType="[^"]*\/image"/u.test(attributes) || /\bTargetMode="External"/u.test(attributes)) continue;
1996
+ targets.set(id, resolvePart(folder, decodeXml(target)));
1997
+ }
1998
+ const seen = /* @__PURE__ */ new Set();
1999
+ const pictures = [];
2000
+ for (const [, id] of xml.matchAll(/\br:(?:embed|id)="([^"]+)"/gu)) {
2001
+ const path = targets.get(id);
2002
+ if (!path || seen.has(path)) continue;
2003
+ seen.add(path);
2004
+ const mime = PICTURE_TYPES[path.split(".").pop().toLowerCase()];
2005
+ const file = zip.file(path);
2006
+ if (!mime || !file) continue;
2007
+ const data = await file.async("nodebuffer");
2008
+ if (data.length >= MIN_PICTURE_BYTES && data.length <= 10485760) pictures.push({
2009
+ mime,
2010
+ data
2011
+ });
2012
+ }
2013
+ return pictures;
2014
+ }
1400
2015
  let pdfjsModule;
1401
2016
  async function loadPdfjs() {
1402
2017
  pdfjsModule ??= import("pdfjs-dist/legacy/build/pdf.mjs");
@@ -1405,20 +2020,23 @@ async function loadPdfjs() {
1405
2020
  function pdfjsAssetUrls(root, { join: joinPath = join } = {}) {
1406
2021
  return {
1407
2022
  cMapUrl: `${joinPath(root, "cmaps")}/`,
1408
- standardFontDataUrl: `${joinPath(root, "standard_fonts")}/`
2023
+ standardFontDataUrl: `${joinPath(root, "standard_fonts")}/`,
2024
+ wasmUrl: `${joinPath(root, "wasm")}/`
1409
2025
  };
1410
2026
  }
1411
2027
  function pdfjsAssets() {
1412
2028
  return pdfjsAssetUrls(dirname(createRequire(import.meta.url).resolve("pdfjs-dist/package.json")));
1413
2029
  }
1414
- async function extractPdf(bytes, { signal } = {}) {
2030
+ async function extractPdf(bytes, { signal, recognize } = {}) {
1415
2031
  const pdfjs = await loadPdfjs();
1416
- const { cMapUrl, standardFontDataUrl } = pdfjsAssets();
2032
+ const { cMapUrl, standardFontDataUrl, wasmUrl } = pdfjsAssets();
1417
2033
  const task = pdfjs.getDocument({
1418
2034
  data: new Uint8Array(bytes),
1419
2035
  cMapUrl,
1420
2036
  cMapPacked: true,
1421
2037
  standardFontDataUrl,
2038
+ wasmUrl,
2039
+ maxImageSize: 1e8,
1422
2040
  isEvalSupported: false,
1423
2041
  disableFontFace: true,
1424
2042
  useSystemFonts: false,
@@ -1432,12 +2050,12 @@ async function extractPdf(bytes, { signal } = {}) {
1432
2050
  throw new MaterialError("unreadable", "This PDF could not be read");
1433
2051
  }
1434
2052
  try {
1435
- const chunks = [];
1436
2053
  let title = "";
1437
2054
  try {
1438
2055
  const meta = await doc.getMetadata();
1439
2056
  title = String(meta?.info?.Title ?? "").trim();
1440
2057
  } catch {}
2058
+ const texts = [];
1441
2059
  for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber += 1) {
1442
2060
  signal?.throwIfAborted();
1443
2061
  const page = await doc.getPage(pageNumber);
@@ -1449,19 +2067,39 @@ async function extractPdf(bytes, { signal } = {}) {
1449
2067
  pageText += item.hasEOL ? "\n" : "";
1450
2068
  }
1451
2069
  page.cleanup();
1452
- chunks.push(...chunkText(pageText, { page: pageNumber }));
2070
+ texts.push(pageText);
1453
2071
  }
1454
- if (!chunks.length) throw new MaterialError("no-text", "No text found in this PDF (it may be a scan)");
2072
+ const pictured = texts.flatMap((pageText, index) => visibleLength(pageText) < MIN_PAGE_TEXT ? [index + 1] : []);
2073
+ const read = await readPictures(recognize, pictured.map((pageNumber) => ({
2074
+ where: { page: pageNumber },
2075
+ image: async () => {
2076
+ signal?.throwIfAborted();
2077
+ const page = await doc.getPage(pageNumber);
2078
+ try {
2079
+ return await pageImage(pdfjs, page);
2080
+ } finally {
2081
+ page.cleanup();
2082
+ }
2083
+ }
2084
+ })));
2085
+ if (pictured.length === doc.numPages && !read.note?.read) {
2086
+ if (!read.available) throw new MaterialError("scan-needs-vision", "This PDF is a scan; reading it needs a model that can see images");
2087
+ throw read.error ?? new MaterialError("no-text", "No text found in this PDF");
2088
+ }
2089
+ const recognized = new Map(pictured.slice(0, read.texts.length).map((pageNumber, index) => [pageNumber, read.texts[index]]));
2090
+ const chunks = texts.flatMap((pageText, index) => chunkText([pageText, recognized.get(index + 1)].filter(Boolean).join("\n\n"), { page: index + 1 }));
2091
+ if (!chunks.length) throw read.error ?? new MaterialError("no-text", "No text found in this PDF");
1455
2092
  return {
1456
2093
  title,
1457
2094
  pages: doc.numPages,
1458
- chunks
2095
+ chunks,
2096
+ ocr: read.note
1459
2097
  };
1460
2098
  } finally {
1461
2099
  await Promise.resolve(task.destroy?.()).catch(() => void 0);
1462
2100
  }
1463
2101
  }
1464
- async function extractDocx(bytes) {
2102
+ async function extractDocx(bytes, { recognize } = {}) {
1465
2103
  const mammoth = await import("mammoth");
1466
2104
  const extract = mammoth.extractRawText ?? mammoth.default?.extractRawText;
1467
2105
  let result;
@@ -1470,7 +2108,8 @@ async function extractDocx(bytes) {
1470
2108
  } catch {
1471
2109
  throw new MaterialError("unreadable", "This Word document could not be read");
1472
2110
  }
1473
- const sections = String(result?.value ?? "").split(/\n{2,}/u);
2111
+ const own = String(result?.value ?? "");
2112
+ const sections = own.split(/\n{2,}/u);
1474
2113
  const chunks = [];
1475
2114
  let buffer = "";
1476
2115
  let section = 1;
@@ -1482,9 +2121,31 @@ async function extractDocx(bytes) {
1482
2121
  }
1483
2122
  buffer += `${paragraph}\n\n`;
1484
2123
  }
1485
- if (buffer.trim()) chunks.push(...chunkText(buffer, { section: `§${section}` }));
2124
+ if (buffer.trim()) {
2125
+ chunks.push(...chunkText(buffer, { section: `§${section}` }));
2126
+ section += 1;
2127
+ }
2128
+ const JSZip = (await import("jszip")).default;
2129
+ const pictures = await partPictures(await JSZip.loadAsync(bytes), "word/document.xml");
2130
+ let note = null;
2131
+ if (pictures.length && visibleLength(own) < TEXT_PER_PICTURE * pictures.length) {
2132
+ const read = await readPictures(recognize, pictures.map((picture, index) => ({
2133
+ where: { picture: index + 1 },
2134
+ image: async () => picture
2135
+ })));
2136
+ if (!read.note?.read && visibleLength(own) < MIN_PAGE_TEXT) {
2137
+ if (!read.available) throw new MaterialError("pictures-need-vision", "This document is pictures; reading them needs a model that can see images");
2138
+ throw read.error ?? new MaterialError("no-text", "No text found in this document");
2139
+ }
2140
+ for (const [index, pictureText] of read.texts.entries()) chunks.push(...chunkText(pictureText, { section: `§${section + index}` }));
2141
+ if (!chunks.length) throw read.error ?? new MaterialError("no-text", "No text found in this document");
2142
+ note = read.note;
2143
+ }
1486
2144
  if (!chunks.length) throw new MaterialError("no-text", "No text found in this document");
1487
- return { chunks };
2145
+ return {
2146
+ chunks,
2147
+ ocr: note
2148
+ };
1488
2149
  }
1489
2150
  const XML_ENTITIES = {
1490
2151
  amp: "&",
@@ -1504,7 +2165,7 @@ function slideText(xml) {
1504
2165
  return [...paragraph.matchAll(/<a:t(?:\s[^>]*)?>([\s\S]*?)<\/a:t>/gu)].map((match) => decodeXml(match[1])).join("");
1505
2166
  }).filter((line) => line.trim()).join("\n");
1506
2167
  }
1507
- async function extractPptx(bytes) {
2168
+ async function extractPptx(bytes, { recognize } = {}) {
1508
2169
  const JSZip = (await import("jszip")).default;
1509
2170
  let zip;
1510
2171
  try {
@@ -1514,19 +2175,40 @@ async function extractPptx(bytes) {
1514
2175
  }
1515
2176
  const slideNumber = (name) => Number(/(\d+)\.xml$/u.exec(name)?.[1] ?? 0);
1516
2177
  const slides = Object.keys(zip.files).filter((name) => /^ppt\/slides\/slide\d+\.xml$/u.test(name)).sort((a, b) => slideNumber(a) - slideNumber(b));
1517
- const chunks = [];
2178
+ const texts = [];
2179
+ const sources = [];
2180
+ let written = 0;
1518
2181
  for (const name of slides) {
1519
2182
  const number = slideNumber(name);
1520
2183
  const body = slideText(await zip.file(name).async("string"));
1521
2184
  const notesFile = zip.file(`ppt/notesSlides/notesSlide${number}.xml`);
1522
2185
  const notes = notesFile ? slideText(await notesFile.async("string")) : "";
1523
- const combined = notes ? `${body}\n[notes] ${notes}` : body;
1524
- chunks.push(...chunkText(combined, { slide: number }));
2186
+ texts.push({
2187
+ number,
2188
+ text: notes ? `${body}\n[notes] ${notes}` : body
2189
+ });
2190
+ if (visibleLength(body) < MIN_PAGE_TEXT) for (const picture of await partPictures(zip, name)) sources.push({
2191
+ where: { slide: number },
2192
+ image: async () => picture
2193
+ });
2194
+ else written += 1;
2195
+ }
2196
+ const read = await readPictures(recognize, sources);
2197
+ if (sources.length && !written && !read.note?.read) {
2198
+ if (!read.available) throw new MaterialError("pictures-need-vision", "These slides are pictures; reading them needs a model that can see images");
2199
+ throw read.error ?? new MaterialError("no-text", "No text found in these slides");
2200
+ }
2201
+ const pictured = /* @__PURE__ */ new Map();
2202
+ for (const [index, pictureText] of read.texts.entries()) {
2203
+ const { slide } = sources[index].where;
2204
+ pictured.set(slide, [pictured.get(slide), pictureText].filter(Boolean).join("\n\n"));
1525
2205
  }
1526
- if (!chunks.length) throw new MaterialError("no-text", "No text found in these slides");
2206
+ const chunks = texts.flatMap(({ number, text }) => chunkText([text, pictured.get(number)].filter(Boolean).join("\n\n"), { slide: number }));
2207
+ if (!chunks.length) throw read.error ?? new MaterialError("no-text", "No text found in these slides");
1527
2208
  return {
1528
2209
  slides: slides.length,
1529
- chunks
2210
+ chunks,
2211
+ ocr: read.note
1530
2212
  };
1531
2213
  }
1532
2214
  function extractPlain(bytes, kind) {
@@ -1545,30 +2227,46 @@ function extractPlain(bytes, kind) {
1545
2227
  chunks
1546
2228
  };
1547
2229
  }
1548
- async function ingestFile({ name, bytes, signal }) {
2230
+ async function ingestFile({ name, bytes, signal, recognize }) {
1549
2231
  if (!bytes?.length) throw new MaterialError("empty", "The file is empty");
1550
2232
  if (bytes.length > 62914560) throw new MaterialError("too-large", "The file is larger than 60 MB");
1551
2233
  const kind = detectKind(name, bytes);
1552
2234
  const baseTitle = String(name ?? "").replace(/\.[^.]+$/u, "").slice(0, 120) || "Untitled";
1553
2235
  if (kind === "image") {
1554
2236
  if (bytes.length > 10485760) throw new MaterialError("image-too-large", "Images must be smaller than 10 MB");
1555
- return {
1556
- kind,
1557
- title: baseTitle,
1558
- chunks: [],
1559
- chars: 0,
1560
- truncated: false,
1561
- mime: imageMime(name, bytes)
1562
- };
2237
+ const mime = imageMime(name, bytes);
2238
+ const read = await readPictures(recognize, [{
2239
+ where: { picture: 1 },
2240
+ image: async () => ({
2241
+ mime,
2242
+ data: Buffer.from(bytes)
2243
+ })
2244
+ }]);
2245
+ return finish(kind, baseTitle, chunkText(read.texts[0] ?? "", {}), {
2246
+ mime,
2247
+ ...read.available ? { ocr: read.note } : {}
2248
+ });
1563
2249
  }
1564
2250
  if (kind === "pdf") {
1565
- const result = await extractPdf(bytes, { signal });
1566
- return finish(kind, result.title || baseTitle, result.chunks, { pages: result.pages });
2251
+ const result = await extractPdf(bytes, {
2252
+ signal,
2253
+ recognize
2254
+ });
2255
+ return finish(kind, result.title || baseTitle, result.chunks, {
2256
+ pages: result.pages,
2257
+ ocr: result.ocr
2258
+ });
2259
+ }
2260
+ if (kind === "docx") {
2261
+ const result = await extractDocx(bytes, { recognize });
2262
+ return finish(kind, baseTitle, result.chunks, { ocr: result.ocr });
1567
2263
  }
1568
- if (kind === "docx") return finish(kind, baseTitle, (await extractDocx(bytes)).chunks);
1569
2264
  if (kind === "pptx") {
1570
- const result = await extractPptx(bytes);
1571
- return finish(kind, baseTitle, result.chunks, { slides: result.slides });
2265
+ const result = await extractPptx(bytes, { recognize });
2266
+ return finish(kind, baseTitle, result.chunks, {
2267
+ slides: result.slides,
2268
+ ocr: result.ocr
2269
+ });
1572
2270
  }
1573
2271
  const result = extractPlain(bytes, kind);
1574
2272
  return finish(kind, result.title || baseTitle, result.chunks);
@@ -1589,7 +2287,7 @@ function checkPublicUrl(value) {
1589
2287
  }
1590
2288
  return url;
1591
2289
  }
1592
- async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
2290
+ async function ingestUrl(address, { fetchImpl = pageFetch, signal, recognize } = {}) {
1593
2291
  let url = checkPublicUrl(address);
1594
2292
  let response;
1595
2293
  for (let hop = 0; hop < 6; hop += 1) {
@@ -1623,7 +2321,8 @@ async function ingestUrl(address, { fetchImpl = pageFetch, signal } = {}) {
1623
2321
  ...await ingestFile({
1624
2322
  name: `${url.pathname.split("/").pop() || "page"}.pdf`,
1625
2323
  bytes,
1626
- signal
2324
+ signal,
2325
+ recognize
1627
2326
  }),
1628
2327
  kind: "url",
1629
2328
  url: url.href
@@ -2044,10 +2743,10 @@ function neededWeeklyMinutes(course, start, deadline, studyDays) {
2044
2743
  const perDay = remaining / studyDayCount / (1 - REVIEW_SHARE);
2045
2744
  return Math.ceil(perDay * studyDays.length / 10) * 10;
2046
2745
  }
2047
- const itemKey = (item) => `${item.kind}:${item.refId}`;
2746
+ const itemKey$1 = (item) => `${item.kind}:${item.refId}`;
2048
2747
  function mergeAgenda(saved = [], planned = []) {
2049
- const seen = new Set(saved.map(itemKey));
2050
- return [...saved, ...planned.filter((item) => !seen.has(itemKey(item)))];
2748
+ const seen = new Set(saved.map(itemKey$1));
2749
+ return [...saved, ...planned.filter((item) => !seen.has(itemKey$1(item)))];
2051
2750
  }
2052
2751
  function planCalendar({ today, agenda = {}, plan, done = {}, checks = [], ahead = 120 }) {
2053
2752
  const lessonsDone = done.lessons ?? /* @__PURE__ */ new Map();
@@ -2056,7 +2755,7 @@ function planCalendar({ today, agenda = {}, plan, done = {}, checks = [], ahead
2056
2755
  const days = /* @__PURE__ */ new Map();
2057
2756
  const put = (day, item) => {
2058
2757
  const list = days.get(day) ?? [];
2059
- if (!list.some((entry) => itemKey(entry) === itemKey(item))) list.push(item);
2758
+ if (!list.some((entry) => itemKey$1(entry) === itemKey$1(item))) list.push(item);
2060
2759
  days.set(day, list);
2061
2760
  };
2062
2761
  for (const [day, items] of Object.entries(agenda)) if (day <= today) for (const item of items) put(day, item);
@@ -2106,9 +2805,10 @@ function createLearner({ store, llm, now = () => /* @__PURE__ */ new Date(), log
2106
2805
  return readProfile(store);
2107
2806
  }
2108
2807
  async function saveProfile(patch = {}) {
2808
+ const given = Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== void 0));
2109
2809
  return store.updateJson(store.paths.profile(), { ...EMPTY_PROFILE }, (current) => cleanProfile({
2110
2810
  ...current,
2111
- ...patch,
2811
+ ...given,
2112
2812
  updatedAt: now().toISOString()
2113
2813
  }));
2114
2814
  }
@@ -2428,6 +3128,7 @@ function list(value) {
2428
3128
  }
2429
3129
  function cleanProfile(profile) {
2430
3130
  return {
3131
+ name: text(profile.name, 20).replace(/[\r\n]+/gu, " "),
2431
3132
  background: text(profile.background, 600),
2432
3133
  goals: list(profile.goals) ?? [],
2433
3134
  strengths: list(profile.strengths) ?? [],
@@ -2751,6 +3452,16 @@ function stateBefore(state, card) {
2751
3452
  function logEvidence(progress, entry) {
2752
3453
  progress.evidenceLog = [...Array.isArray(progress.evidenceLog) ? progress.evidenceLog : [], entry].slice(-50);
2753
3454
  }
3455
+ function itemKey(source) {
3456
+ if (source?.kind === "practice") return `practice:${source.lessonId}:${source.itemId}`;
3457
+ if (source?.kind === "quiz") return `quiz:${source.unitId}:${source.itemId}`;
3458
+ if (source?.kind === "check") return `check:${source.lessonId}:${source.stepId}`;
3459
+ return null;
3460
+ }
3461
+ function answersTo(progress, key, except = null) {
3462
+ if (!key) return [];
3463
+ return (Array.isArray(progress?.evidenceLog) ? progress.evidenceLog : []).filter((entry) => entry.item?.key === key && entry.id !== except && scored(entry.evidence));
3464
+ }
2754
3465
  function correctEvidence(progress, id, patch, { known = (conceptId) => conceptId, at = (/* @__PURE__ */ new Date()).toISOString() } = {}) {
2755
3466
  const log = Array.isArray(progress.evidenceLog) ? progress.evidenceLog : [];
2756
3467
  const index = log.findIndex((entry) => entry.id === id);
@@ -2917,6 +3628,11 @@ function gradeObjective(item, response) {
2917
3628
  gradable: true
2918
3629
  };
2919
3630
  }
3631
+ function wrongLabel(item, response) {
3632
+ if (item.kind === "choice") return `chose ${String(response).toUpperCase()}: ${(item.options?.find((option) => option.id === response)?.text ?? "").slice(0, 50)}`;
3633
+ if (item.kind === "number" || item.kind === "blank") return `answered ${String(response).slice(0, 40)}`;
3634
+ return "wrong";
3635
+ }
2920
3636
  function round2(value) {
2921
3637
  return Math.round(value * 100) / 100;
2922
3638
  }
@@ -2992,7 +3708,7 @@ function correctedItem(old, raw) {
2992
3708
  ...raw,
2993
3709
  kind: old.kind
2994
3710
  }, 0);
2995
- if (!next || (old.options?.length ?? 0) !== (next.options?.length ?? 0)) return null;
3711
+ if (!next || next.kind !== old.kind || (old.options?.length ?? 0) !== (next.options?.length ?? 0)) return null;
2996
3712
  const redrawn = [
2997
3713
  "svg",
2998
3714
  "figure",
@@ -3088,6 +3804,32 @@ function regrade(item, response, checked) {
3088
3804
  partial: verdict === "partial"
3089
3805
  };
3090
3806
  }
3807
+ function earlierGrade(item, response, { verdict, score, grade = null }) {
3808
+ if (verdict !== "wrong" && verdict !== "unclear") return null;
3809
+ let next;
3810
+ if (OBJECTIVE_KINDS.includes(item.kind)) {
3811
+ const answer = cleanResponse(item, response);
3812
+ const graded = gradeObjective(item, answer);
3813
+ if (!graded.gradable) return null;
3814
+ next = {
3815
+ score: graded.score,
3816
+ correct: graded.correct,
3817
+ errorTag: graded.correct ? "" : wrongLabel(item, answer)
3818
+ };
3819
+ } else {
3820
+ if (!grade || !Number.isFinite(Number(grade.score))) return null;
3821
+ const value = Math.min(1, Math.max(0, Number(grade.score)));
3822
+ const correct = grade.verdict ? grade.verdict === "correct" : value >= .8;
3823
+ next = {
3824
+ score: value,
3825
+ correct,
3826
+ errorTag: correct ? "" : text(grade.errorTag, 80)
3827
+ };
3828
+ }
3829
+ const before = Math.min(1, Math.max(0, Number(score) || 0));
3830
+ if (verdict === "unclear" && next.score <= before || Math.abs(next.score - before) < 1e-6) return null;
3831
+ return next;
3832
+ }
3091
3833
  //#endregion
3092
3834
  //#region src/host/lessons.js
3093
3835
  const EVENTS_KEPT = 200;
@@ -3537,12 +4279,13 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
3537
4279
  };
3538
4280
  });
3539
4281
  }
3540
- async function record({ courseId, lessonId, conceptIds = [], evidence = {} }) {
4282
+ async function record({ courseId, lessonId, conceptIds = [], evidence = {}, item = null }) {
3541
4283
  const course = await courses.readCourse(courseId);
3542
4284
  const index = conceptIndex(course);
3543
4285
  const ids = (Array.isArray(conceptIds) && conceptIds.length ? conceptIds : findLesson(course, lessonId)?.lesson.concepts.map((concept) => concept.id) ?? []).filter((id) => index.has(id)).slice(0, 8);
3544
4286
  const at = now().getTime();
3545
4287
  const evidenceId = newId("evd");
4288
+ const key = item?.source?.courseId === courseId ? itemKey(item.source) : null;
3546
4289
  const next = await store.updateJson(store.paths.progress(courseId), null, (progress) => {
3547
4290
  const value = progress ?? {
3548
4291
  courseId,
@@ -3568,7 +4311,11 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
3568
4311
  lessonId: lessonId ?? null,
3569
4312
  conceptIds: ids,
3570
4313
  evidence: JSON.parse(JSON.stringify(evidence)),
3571
- before
4314
+ before,
4315
+ ...key ? { item: {
4316
+ key,
4317
+ response: item.response ?? ""
4318
+ } } : {}
3572
4319
  });
3573
4320
  pushEvent(value, {
3574
4321
  type: "evidence",
@@ -3701,6 +4448,46 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
3701
4448
  save: rewrite(path, (value) => value?.items)
3702
4449
  };
3703
4450
  }
4451
+ async function regradeEarlier({ course, key, item, verdict, except, language, signal }) {
4452
+ const answers = answersTo(await store.readJson(store.paths.progress(course.id), null), key, except);
4453
+ const open = !OBJECTIVE_KINDS.includes(item.kind);
4454
+ const graded = await Promise.all((open ? answers.slice(-3) : answers).map(async (entry) => {
4455
+ const again = open ? await grade({
4456
+ item,
4457
+ response: entry.item.response,
4458
+ language,
4459
+ signal
4460
+ }).catch((error) => {
4461
+ if (signal?.aborted) throw error;
4462
+ return null;
4463
+ }) : null;
4464
+ if (open && !again) return null;
4465
+ return {
4466
+ id: entry.id,
4467
+ next: earlierGrade(item, entry.item.response, {
4468
+ verdict,
4469
+ score: entry.evidence.score,
4470
+ grade: again
4471
+ })
4472
+ };
4473
+ }));
4474
+ let found = null;
4475
+ for (const { id, next } of graded.filter((entry) => entry?.next)) {
4476
+ const corrected = await regradeEvidence(course, id, {
4477
+ score: next.score,
4478
+ errorTag: next.errorTag
4479
+ });
4480
+ if (corrected) found = {
4481
+ count: (found?.count ?? 0) + 1,
4482
+ mastery: {
4483
+ ...found?.mastery,
4484
+ ...corrected.mastery
4485
+ },
4486
+ recommendation: corrected.recommendation
4487
+ };
4488
+ }
4489
+ return found;
4490
+ }
3704
4491
  function challengeItem({ source, response, shown = {}, evidenceId, note }) {
3705
4492
  const doubt = text(note, 800);
3706
4493
  return jobs.start("item/challenge", async (api) => {
@@ -3736,24 +4523,39 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
3736
4523
  score: result.score,
3737
4524
  ...result.correct ? { errorTag: "" } : result.errorTag ? { errorTag: result.errorTag } : {}
3738
4525
  }) : null;
4526
+ const earlier = checked.item && place.course ? await regradeEarlier({
4527
+ course: place.course,
4528
+ key: itemKey(source),
4529
+ item,
4530
+ verdict: checked.verdict,
4531
+ except: evidenceId,
4532
+ language: place.language,
4533
+ signal: api.signal
4534
+ }) : null;
3739
4535
  if (place.course) await logEvent(place.course.id, {
3740
4536
  type: "item-challenge",
3741
4537
  lessonId: place.lessonId,
3742
4538
  verdict: checked.verdict,
3743
- changed
4539
+ changed,
4540
+ earlier: earlier?.count ?? 0
3744
4541
  });
4542
+ const mastery = corrected || earlier ? {
4543
+ ...corrected?.mastery,
4544
+ ...earlier?.mastery
4545
+ } : null;
3745
4546
  return {
3746
4547
  verdict: checked.verdict,
3747
4548
  summary: checked.summary,
3748
4549
  explanation: checked.explanation,
3749
4550
  item: checked.item ? item : null,
3750
4551
  result: changed ? result : null,
3751
- mastery: corrected?.mastery ?? null,
3752
- recommendation: corrected?.recommendation ?? null
4552
+ earlier: earlier?.count ?? 0,
4553
+ mastery,
4554
+ recommendation: earlier?.recommendation ?? corrected?.recommendation ?? null
3753
4555
  };
3754
4556
  });
3755
4557
  }
3756
- async function grade({ item, response, language = "zh-CN" }) {
4558
+ async function grade({ item, response, language = "zh-CN", signal }) {
3757
4559
  if (!item?.prompt) throw new TutorError("invalid", "Missing question");
3758
4560
  const work = item.kind === "work";
3759
4561
  const answer = text(response, 4e3);
@@ -3782,6 +4584,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
3782
4584
  language
3783
4585
  }),
3784
4586
  maxTokens: work ? 1800 : 1200,
4587
+ signal,
3785
4588
  normalize: (raw) => ({
3786
4589
  score: Math.min(1, Math.max(0, Number(raw?.score) || 0)),
3787
4590
  verdict: [
@@ -3961,6 +4764,44 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
3961
4764
  if (!exists && !jobs.running(`practice:${course.id}:${row.lecture.id}`)) lecturePractice(course, row);
3962
4765
  }).catch(() => void 0);
3963
4766
  }
4767
+ async function handout({ courseId, lessonId, force = false }) {
4768
+ const { course, row } = await context(courseId, lessonId);
4769
+ if (!force) {
4770
+ const existing = await store.readJson(store.paths.handout(courseId, lessonId), null);
4771
+ if (existing) return { handout: existing };
4772
+ }
4773
+ const board = await store.readJson(store.paths.board(courseId, lessonId), null);
4774
+ if (!board?.steps?.length) throw new TutorError("no-board", "The board is not written yet. Try again once its first step is up.");
4775
+ return { job: jobs.start("lesson/handout", async (api) => {
4776
+ const made = await generateJson(llm, {
4777
+ purpose: "teach",
4778
+ effort: "lowest",
4779
+ prompt: handoutPrompt({
4780
+ course,
4781
+ lesson: row.lesson,
4782
+ language: course.language,
4783
+ board: boardToPlainText(board, { maxChars: 12e3 })
4784
+ }),
4785
+ maxTokens: 5e3,
4786
+ signal: api.signal,
4787
+ normalize: normalizeHandout
4788
+ });
4789
+ const saved = {
4790
+ ...made,
4791
+ title: made.title || row.lesson.title,
4792
+ lessonId,
4793
+ createdAt: stamp()
4794
+ };
4795
+ await store.writeJson(store.paths.handout(courseId, lessonId), saved);
4796
+ return { handout: saved };
4797
+ }, {
4798
+ key: `handout:${courseId}:${lessonId}`,
4799
+ meta: {
4800
+ courseId,
4801
+ lessonId
4802
+ }
4803
+ }) };
4804
+ }
3964
4805
  async function unitSheet({ courseId, unitId, force = false }) {
3965
4806
  const course = await courses.readCourse(courseId);
3966
4807
  const unit = course.units?.find((item) => item.id === assertId(unitId, "unt"));
@@ -4117,6 +4958,7 @@ function createLessons({ store, llm, jobs, courses, learner, now = () => /* @__P
4117
4958
  reexplain,
4118
4959
  challenge,
4119
4960
  challengeItem,
4961
+ handout,
4120
4962
  record,
4121
4963
  grade,
4122
4964
  correct,
@@ -4249,14 +5091,17 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
4249
5091
  return null;
4250
5092
  }
4251
5093
  }
4252
- async function content(user, images, prepared) {
5094
+ async function content(user, images, prepared, required) {
4253
5095
  const blocks = [{
4254
5096
  type: "text",
4255
5097
  text: String(user ?? "")
4256
5098
  }];
4257
- if (!images?.length || prepared.inputModalities?.includes("image") !== true) return blocks;
5099
+ if (!images?.length) return blocks;
4258
5100
  const attachments = getAttachments();
4259
- if (typeof attachments?.saveImage !== "function") return blocks;
5101
+ if (prepared.inputModalities?.includes("image") !== true || typeof attachments?.saveImage !== "function") {
5102
+ if (required) throw new TutorError("llm-no-vision", "The selected model cannot see images. Pick one that can in Aha settings.");
5103
+ return blocks;
5104
+ }
4260
5105
  for (const [index, image] of images.slice(0, 4).entries()) try {
4261
5106
  const ref = await attachments.saveImage({
4262
5107
  data: Buffer.from(image.base64, "base64"),
@@ -4268,11 +5113,12 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
4268
5113
  attachment: ref
4269
5114
  });
4270
5115
  } catch (error) {
5116
+ if (required) throw new TutorError("image-unusable", "This picture could not be given to the model.");
4271
5117
  logger?.debug?.("aha-tutor could not attach an image: %s", error?.message);
4272
5118
  }
4273
5119
  return blocks;
4274
5120
  }
4275
- async function attempt({ purpose, effort, system, user, images, maxTokens, signal, onText, onReasoning }) {
5121
+ async function attempt({ purpose, effort, system, user, images, requireImages = false, maxTokens, signal, onText, onReasoning }) {
4276
5122
  const llm = getLlm();
4277
5123
  if (!llm) throw new TutorError("llm-missing", "DSH has no model service in this profile.");
4278
5124
  const target = route(purpose);
@@ -4299,7 +5145,7 @@ function createLlmPort({ getLlm, getDefault, getAttachments, prefs, logger }) {
4299
5145
  let finish = null;
4300
5146
  const messages = [{
4301
5147
  role: "user",
4302
- content: await content(user, images, prepared)
5148
+ content: await content(user, images, prepared, requireImages)
4303
5149
  }];
4304
5150
  try {
4305
5151
  for await (const chunk of prepared.stream({
@@ -4459,6 +5305,7 @@ function createStore({ root }) {
4459
5305
  practice: (id, lessonId) => join(paths.courseDir(id), "practice", `${assertId(lessonId, "les")}.json`),
4460
5306
  asides: (id, lessonId) => join(paths.courseDir(id), "asides", `${assertId(lessonId, "les")}.json`),
4461
5307
  sheet: (id, unitId) => join(paths.courseDir(id), "sheets", `${assertId(unitId, "unt")}.json`),
5308
+ handout: (id, lessonId) => join(paths.courseDir(id), "handouts", `${assertId(lessonId, "les")}.json`),
4462
5309
  unitQuiz: (id, unitId) => join(paths.courseDir(id), "quizzes", `${assertId(unitId, "unt")}.json`),
4463
5310
  materials: () => join(root, "materials"),
4464
5311
  materialDir: (id) => join(root, "materials", assertId(id, "mat")),
@@ -5271,6 +6118,18 @@ function answerOf(value) {
5271
6118
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
5272
6119
  return Array.isArray(value) ? value.filter((id) => typeof id === "string" && /^[a-z]$/u.test(id)).slice(0, 12) : "";
5273
6120
  }
6121
+ function answeredItem(payload, courseId) {
6122
+ if (!payload?.source || payload.source.kind === "deck") return null;
6123
+ try {
6124
+ const source = itemSource(payload);
6125
+ return source.courseId === courseId ? {
6126
+ source,
6127
+ response: answerOf(payload.response)
6128
+ } : null;
6129
+ } catch {
6130
+ return null;
6131
+ }
6132
+ }
5274
6133
  function shownGrade(value) {
5275
6134
  const grade = value && typeof value === "object" ? value : {};
5276
6135
  const words = (key, max) => typeof grade[key] === "string" ? grade[key].slice(0, max) : "";
@@ -5337,6 +6196,11 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
5337
6196
  courseId: need(payload, "courseId", "crs"),
5338
6197
  instruction: payload.instruction
5339
6198
  }) }),
6199
+ "course/add-materials": (payload) => ({ job: courses.addMaterials({
6200
+ courseId: need(payload, "courseId", "crs"),
6201
+ materialIds: idList(payload.materialIds, "mat", 12),
6202
+ instruction: payload.instruction
6203
+ }) }),
5340
6204
  "course/save-blueprint": (payload) => courses.saveBlueprint({
5341
6205
  courseId: need(payload, "courseId", "crs"),
5342
6206
  blueprint: payload.blueprint
@@ -5402,6 +6266,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
5402
6266
  "lesson/record": (payload) => lessons.record({
5403
6267
  courseId: need(payload, "courseId", "crs"),
5404
6268
  lessonId: optional(payload, "lessonId", "les"),
6269
+ item: answeredItem(payload, need(payload, "courseId", "crs")),
5405
6270
  conceptIds: (Array.isArray(payload.conceptIds) ? payload.conceptIds : []).filter((id) => /^cpt_[a-z0-9]{6,40}$/u.test(String(id))).slice(0, 8),
5406
6271
  evidence: {
5407
6272
  kind: payload.evidence?.kind,
@@ -5444,6 +6309,11 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
5444
6309
  lessonId: need(payload, "lessonId", "les"),
5445
6310
  selfRating: Number(payload.selfRating) || void 0
5446
6311
  }),
6312
+ "lesson/handout": (payload) => lessons.handout({
6313
+ courseId: need(payload, "courseId", "crs"),
6314
+ lessonId: need(payload, "lessonId", "les"),
6315
+ force: payload.force === true
6316
+ }),
5447
6317
  "lesson/practice": (payload) => lessons.practice({
5448
6318
  courseId: need(payload, "courseId", "crs"),
5449
6319
  lessonId: need(payload, "lessonId", "les")
@@ -5465,6 +6335,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
5465
6335
  }),
5466
6336
  "profile/get": () => learner.getProfile(),
5467
6337
  "profile/save": (payload) => learner.saveProfile({
6338
+ name: payload.name,
5468
6339
  background: payload.background,
5469
6340
  goals: payload.goals,
5470
6341
  strengths: payload.strengths,
@@ -5502,6 +6373,7 @@ function createRoutes({ courses, lessons, learner, studio, projects, jobs, setti
5502
6373
  }) }),
5503
6374
  "material/url": (payload) => ({ job: studio.addUrl({ url: payload.url }) }),
5504
6375
  "material/list": () => studio.listMaterials(),
6376
+ "material/search": (payload) => studio.searchMaterials({ query: typeof payload.query === "string" ? payload.query : "" }),
5505
6377
  "material/get": (payload) => studio.getMaterial({ id: need(payload, "id", "mat") }),
5506
6378
  "material/rename": (payload) => studio.renameMaterial({
5507
6379
  id: need(payload, "id", "mat"),
@@ -6282,6 +7154,73 @@ function createSettingsService({ prefs, keys, llmPort }) {
6282
7154
  };
6283
7155
  }
6284
7156
  //#endregion
7157
+ //#region src/host/recognize.js
7158
+ const AT_ONCE = 3;
7159
+ const STOPS = /* @__PURE__ */ new Set([
7160
+ "llm-auth",
7161
+ "llm-quota",
7162
+ "llm-missing",
7163
+ "llm-unavailable",
7164
+ "llm-no-vision"
7165
+ ]);
7166
+ const MAX_TOKENS = 6e3;
7167
+ async function visionPurpose(llm, signal) {
7168
+ await llm.refreshCapabilities?.(signal);
7169
+ return ["quick", "teach"].find((purpose) => llm.supportsImages?.(purpose)) ?? null;
7170
+ }
7171
+ function cleanTranscript(value) {
7172
+ return String(value ?? "").trim().replace(/^```[\w-]*\n([\s\S]*?)\n```$/u, "$1").replaceAll(RECOGNIZE_EMPTY, "").trim();
7173
+ }
7174
+ function createRecognizer({ llm, signal, onProgress, atOnce = AT_ONCE }) {
7175
+ let route = null;
7176
+ async function read(purpose, image) {
7177
+ const prompt = recognizePrompt();
7178
+ return cleanTranscript(await llm.complete({
7179
+ purpose,
7180
+ effort: "lowest",
7181
+ system: prompt.system,
7182
+ user: prompt.user,
7183
+ images: [{
7184
+ mime: image.mime,
7185
+ base64: Buffer.from(image.data).toString("base64")
7186
+ }],
7187
+ requireImages: true,
7188
+ maxTokens: MAX_TOKENS,
7189
+ signal
7190
+ }));
7191
+ }
7192
+ return async (sources) => {
7193
+ route ??= visionPurpose(llm, signal);
7194
+ const purpose = await route;
7195
+ if (!purpose) return null;
7196
+ const results = new Array(sources.length).fill(null);
7197
+ let next = 0;
7198
+ let done = 0;
7199
+ let stopped = null;
7200
+ onProgress?.(0, sources.length);
7201
+ const worker = async () => {
7202
+ while (next < sources.length) {
7203
+ const index = next;
7204
+ next += 1;
7205
+ signal?.throwIfAborted();
7206
+ try {
7207
+ if (stopped) throw stopped;
7208
+ const image = await sources[index].image();
7209
+ results[index] = { text: image ? await read(purpose, image) : "" };
7210
+ } catch (error) {
7211
+ if (signal?.aborted) throw signal.reason ?? error;
7212
+ if (STOPS.has(error?.code)) stopped ??= error;
7213
+ results[index] = { error };
7214
+ }
7215
+ done += 1;
7216
+ onProgress?.(done, sources.length);
7217
+ }
7218
+ };
7219
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(atOnce, sources.length)) }, worker));
7220
+ return results;
7221
+ };
7222
+ }
7223
+ //#endregion
6285
7224
  //#region src/host/studio.js
6286
7225
  const ASSIST_MODES = [
6287
7226
  "explain",
@@ -6304,6 +7243,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
6304
7243
  truncated: result.truncated === true,
6305
7244
  mime: result.mime ?? null,
6306
7245
  url: result.url ?? null,
7246
+ ocr: result.ocr ?? null,
6307
7247
  createdAt: stamp(),
6308
7248
  brief: null,
6309
7249
  ...extra
@@ -6334,6 +7274,11 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
6334
7274
  await store.writeJson(store.paths.material(id), meta);
6335
7275
  return meta;
6336
7276
  }
7277
+ const recognizer = (api) => createRecognizer({
7278
+ llm,
7279
+ signal: api.signal,
7280
+ onProgress: (done, total) => api.progress(done, total, "recognizing")
7281
+ });
6337
7282
  function upload({ name, base64, bytes: raw }) {
6338
7283
  const fileName = text(name, 200) || "file";
6339
7284
  if (!raw && (typeof base64 !== "string" || !base64)) throw new TutorError("invalid", "No file data");
@@ -6344,7 +7289,8 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
6344
7289
  const result = await ingestFile({
6345
7290
  name: fileName,
6346
7291
  bytes,
6347
- signal: api.signal
7292
+ signal: api.signal,
7293
+ recognize: recognizer(api)
6348
7294
  });
6349
7295
  api.progress(1, 2, "summarizing");
6350
7296
  return { material: await saveMaterial(result, {
@@ -6357,7 +7303,10 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
6357
7303
  function addUrl({ url }) {
6358
7304
  return jobs.start("material/url", async (api) => {
6359
7305
  api.progress(0, 2, "fetching");
6360
- const result = await ingestUrl(url, { signal: api.signal });
7306
+ const result = await ingestUrl(url, {
7307
+ signal: api.signal,
7308
+ recognize: recognizer(api)
7309
+ });
6361
7310
  api.progress(1, 2, "summarizing");
6362
7311
  return { material: await saveMaterial(result, { signal: api.signal }) };
6363
7312
  });
@@ -6366,6 +7315,37 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
6366
7315
  const ids = await store.listIds(store.paths.materials(), "mat");
6367
7316
  return (await Promise.all(ids.map((id) => store.readJson(store.paths.material(id), null)))).filter(Boolean).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
6368
7317
  }
7318
+ let libraryIndex = null;
7319
+ const SEARCH_CHUNKS = 3e4;
7320
+ async function searchMaterials({ query }) {
7321
+ const wanted = text(query, 200);
7322
+ if (!wanted) return { results: [] };
7323
+ const metas = (await listMaterials()).filter((meta) => meta.kind !== "image" || meta.chars > 0);
7324
+ const key = metas.map((meta) => `${meta.id}:${meta.title}:${meta.chars ?? meta.pages ?? ""}`).join("|");
7325
+ if (libraryIndex?.key !== key) {
7326
+ const chunks = [];
7327
+ for (const meta of metas) {
7328
+ if (chunks.length >= SEARCH_CHUNKS) break;
7329
+ const own = await store.readJson(store.paths.materialChunks(meta.id), []) ?? [];
7330
+ for (const chunk of own.slice(0, SEARCH_CHUNKS - chunks.length)) chunks.push({
7331
+ ...chunk,
7332
+ materialId: meta.id,
7333
+ materialTitle: meta.title
7334
+ });
7335
+ }
7336
+ libraryIndex = {
7337
+ key,
7338
+ index: buildIndex(chunks)
7339
+ };
7340
+ }
7341
+ return { results: search(libraryIndex.index, wanted, { limit: 12 }).map(({ chunk }) => ({
7342
+ materialId: chunk.materialId,
7343
+ title: chunk.materialTitle,
7344
+ page: chunk.page ?? null,
7345
+ slide: chunk.slide ?? null,
7346
+ snippet: snippetOf(chunk.text, wanted)
7347
+ })) };
7348
+ }
6369
7349
  async function getMaterial({ id }) {
6370
7350
  const meta = await store.readJson(store.paths.material(assertId(id, "mat")), null);
6371
7351
  if (!meta) throw new TutorError("not-found", "Material not found");
@@ -6806,6 +7786,7 @@ function createStudio({ store, llm, jobs, now = () => /* @__PURE__ */ new Date()
6806
7786
  upload,
6807
7787
  addUrl,
6808
7788
  listMaterials,
7789
+ searchMaterials,
6809
7790
  getMaterial,
6810
7791
  renameMaterial,
6811
7792
  removeMaterial,