zam-core 0.6.1 → 0.6.2

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/dist/cli/index.js CHANGED
@@ -552,7 +552,9 @@ CREATE TABLE IF NOT EXISTS tokens (
552
552
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
553
553
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
554
554
  deprecated_at TEXT,
555
- question TEXT
555
+ question TEXT,
556
+ provider TEXT,
557
+ topic_id TEXT
556
558
  );
557
559
 
558
560
  -- Prerequisite dependency graph: "to learn A, first know B"
@@ -992,6 +994,14 @@ async function runMigrations(db) {
992
994
  PRIMARY KEY (token_id, source_id)
993
995
  )
994
996
  `);
997
+ if (tokenCols.length > 0) {
998
+ if (!tokenCols.some((c) => c.name === "provider")) {
999
+ await db.exec(`ALTER TABLE tokens ADD COLUMN provider TEXT`);
1000
+ }
1001
+ if (!tokenCols.some((c) => c.name === "topic_id")) {
1002
+ await db.exec(`ALTER TABLE tokens ADD COLUMN topic_id TEXT`);
1003
+ }
1004
+ }
995
1005
  }
996
1006
 
997
1007
  // src/kernel/db/snapshot.ts
@@ -1485,8 +1495,8 @@ async function createToken(db, input8) {
1485
1495
  throw new Error(`bloom_level must be between 1 and 5, got ${bloom}`);
1486
1496
  }
1487
1497
  await db.prepare(`
1488
- INSERT INTO tokens (id, slug, concept, domain, bloom_level, context, symbiosis_mode, source_link, question, created_at, updated_at)
1489
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1498
+ INSERT INTO tokens (id, slug, concept, domain, bloom_level, context, symbiosis_mode, source_link, question, provider, topic_id, created_at, updated_at)
1499
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1490
1500
  `).run(
1491
1501
  id,
1492
1502
  input8.slug,
@@ -1497,16 +1507,33 @@ async function createToken(db, input8) {
1497
1507
  input8.symbiosis_mode ?? null,
1498
1508
  input8.source_link ?? null,
1499
1509
  input8.question ?? null,
1510
+ input8.provider ?? null,
1511
+ input8.topic_id ?? null,
1500
1512
  now,
1501
1513
  now
1502
1514
  );
1503
1515
  return await getTokenById(db, id);
1504
1516
  }
1517
+ function parseTokenFallback(token) {
1518
+ if (token && !token.provider && token.source_link) {
1519
+ if (token.source_link.includes("lehrplanplus.bayern.de")) {
1520
+ token.provider = "lehrplanplus-bayern";
1521
+ const match = token.source_link.match(/#(.*)$/);
1522
+ if (match) {
1523
+ token.topic_id = match[1];
1524
+ }
1525
+ }
1526
+ }
1527
+ }
1505
1528
  async function getTokenBySlug(db, slug) {
1506
- return await db.prepare("SELECT * FROM tokens WHERE slug = ?").get(slug);
1529
+ const token = await db.prepare("SELECT * FROM tokens WHERE slug = ?").get(slug);
1530
+ parseTokenFallback(token);
1531
+ return token;
1507
1532
  }
1508
1533
  async function getTokenById(db, id) {
1509
- return await db.prepare("SELECT * FROM tokens WHERE id = ?").get(id);
1534
+ const token = await db.prepare("SELECT * FROM tokens WHERE id = ?").get(id);
1535
+ parseTokenFallback(token);
1536
+ return token;
1510
1537
  }
1511
1538
  async function updateToken(db, slug, updates) {
1512
1539
  const token = await getTokenBySlug(db, slug);
@@ -1552,6 +1579,14 @@ async function updateToken(db, slug, updates) {
1552
1579
  fields.push("question = ?");
1553
1580
  values.push(updates.question);
1554
1581
  }
1582
+ if (updates.provider !== void 0) {
1583
+ fields.push("provider = ?");
1584
+ values.push(updates.provider);
1585
+ }
1586
+ if (updates.topic_id !== void 0) {
1587
+ fields.push("topic_id = ?");
1588
+ values.push(updates.topic_id);
1589
+ }
1555
1590
  if (fields.length === 0) {
1556
1591
  throw new Error("updateToken called with no fields to update");
1557
1592
  }
@@ -1677,14 +1712,20 @@ async function findTokens(db, query) {
1677
1712
  return scored;
1678
1713
  }
1679
1714
  async function listTokens(db, options) {
1715
+ let tokens;
1680
1716
  if (options?.domain) {
1681
- return await db.prepare(
1717
+ tokens = await db.prepare(
1682
1718
  "SELECT * FROM tokens WHERE domain = ? AND deprecated_at IS NULL ORDER BY bloom_level, slug"
1683
1719
  ).all(options.domain);
1720
+ } else {
1721
+ tokens = await db.prepare(
1722
+ "SELECT * FROM tokens WHERE deprecated_at IS NULL ORDER BY bloom_level, domain, slug"
1723
+ ).all();
1684
1724
  }
1685
- return await db.prepare(
1686
- "SELECT * FROM tokens WHERE deprecated_at IS NULL ORDER BY bloom_level, domain, slug"
1687
- ).all();
1725
+ for (const token of tokens) {
1726
+ parseTokenFallback(token);
1727
+ }
1728
+ return tokens;
1688
1729
  }
1689
1730
  function slugify(text) {
1690
1731
  return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
@@ -1736,6 +1777,8 @@ async function listPersonalCards(db, userId, options) {
1736
1777
  t.question,
1737
1778
  t.created_at AS createdAt,
1738
1779
  t.updated_at AS updatedAt,
1780
+ t.provider,
1781
+ t.topic_id AS topicId,
1739
1782
  c.id AS cardId,
1740
1783
  c.state,
1741
1784
  c.due_at AS dueAt,
@@ -1765,6 +1808,17 @@ async function listPersonalCards(db, userId, options) {
1765
1808
  }
1766
1809
  sql += " ORDER BY t.created_at DESC";
1767
1810
  const rows = await db.prepare(sql).all(...values);
1811
+ for (const row of rows) {
1812
+ if (!row.provider && row.sourceLink) {
1813
+ if (row.sourceLink.includes("lehrplanplus.bayern.de")) {
1814
+ row.provider = "lehrplanplus-bayern";
1815
+ const match = row.sourceLink.match(/#(.*)$/);
1816
+ if (match) {
1817
+ row.topicId = match[1];
1818
+ }
1819
+ }
1820
+ }
1821
+ }
1768
1822
  return rows;
1769
1823
  }
1770
1824
  async function importCurriculumCards(db, userId, cards) {
@@ -1811,9 +1865,18 @@ async function importCurriculumCards(db, userId, cards) {
1811
1865
  context: card.context || "",
1812
1866
  symbiosis_mode: symbiosisMode,
1813
1867
  source_link: card.source_link || null,
1814
- question: card.question || null
1868
+ question: card.question || null,
1869
+ provider: card.provider || null,
1870
+ topic_id: card.topic_id || null
1815
1871
  });
1816
1872
  createdCount++;
1873
+ } else {
1874
+ if (!token.provider && card.provider) {
1875
+ await updateToken(tx, token.slug, {
1876
+ provider: card.provider,
1877
+ topic_id: card.topic_id || null
1878
+ });
1879
+ }
1817
1880
  }
1818
1881
  const existingCard = await getCard(tx, token.id, userId);
1819
1882
  if (!existingCard) {
@@ -2035,11 +2098,12 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
2035
2098
  let createdCount = 0;
2036
2099
  let linkedCount = 0;
2037
2100
  await db.transaction(async (tx) => {
2038
- const source = await tx.prepare("SELECT id FROM sources WHERE id = ?").get(sourceId);
2039
- if (!source) {
2040
- throw new Error(`Source not found: ${sourceId}`);
2041
- }
2042
2101
  for (const card of proposals) {
2102
+ const cardSourceId = card.source_id || sourceId;
2103
+ const source = await tx.prepare("SELECT id FROM sources WHERE id = ?").get(cardSourceId);
2104
+ if (!source) {
2105
+ throw new Error(`Source not found: ${cardSourceId}`);
2106
+ }
2043
2107
  const baseText = card.question && card.question.trim().length > 0 ? card.question : card.concept;
2044
2108
  const cleanDomain = slugify(card.domain || "");
2045
2109
  const cleanBase = slugify(baseText);
@@ -2070,11 +2134,19 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
2070
2134
  bloom_level: bloom,
2071
2135
  context: card.excerpt || "",
2072
2136
  symbiosis_mode: symbiosisMode,
2073
- question: card.question || null
2137
+ question: card.question || null,
2138
+ provider: card.provider || null,
2139
+ topic_id: card.topic_id || null
2074
2140
  });
2075
2141
  createdCount++;
2076
2142
  } else {
2077
2143
  linkedCount++;
2144
+ if (!token.provider && card.provider) {
2145
+ await updateToken(tx, token.slug, {
2146
+ provider: card.provider,
2147
+ topic_id: card.topic_id || null
2148
+ });
2149
+ }
2078
2150
  }
2079
2151
  const existingCard = await getCard(tx, token.id, userId);
2080
2152
  if (!existingCard) {
@@ -2086,7 +2158,12 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
2086
2158
  ON CONFLICT(token_id, source_id) DO UPDATE SET
2087
2159
  excerpt = excluded.excerpt,
2088
2160
  page_number = excluded.page_number`
2089
- ).run(token.id, sourceId, card.excerpt || "", card.page_number || null);
2161
+ ).run(
2162
+ token.id,
2163
+ cardSourceId,
2164
+ card.excerpt || "",
2165
+ card.page_number || null
2166
+ );
2090
2167
  }
2091
2168
  });
2092
2169
  return { createdCount, linkedCount };
@@ -6069,6 +6146,14 @@ var BLOOM_VERBS2 = {
6069
6146
  4: "Analyze",
6070
6147
  5: "Synthesize"
6071
6148
  };
6149
+ var LlmResponseTruncatedError = class extends Error {
6150
+ constructor(label) {
6151
+ super(
6152
+ `${label}: the model's response was cut off before producing usable output (finish_reason=length). The prompt likely left no room in the model's context window for a reply.`
6153
+ );
6154
+ this.name = "LlmResponseTruncatedError";
6155
+ }
6156
+ };
6072
6157
  async function readChatContent(res, label) {
6073
6158
  if (!res.ok) {
6074
6159
  const errorText = await res.text().catch(() => "");
@@ -6077,11 +6162,16 @@ async function readChatContent(res, label) {
6077
6162
  );
6078
6163
  }
6079
6164
  const data = await res.json();
6080
- const content = data.choices?.[0]?.message?.content;
6081
- if (!content) {
6165
+ const choice = data.choices?.[0];
6166
+ const content = choice?.message?.content;
6167
+ const trimmed = content?.trim() ?? "";
6168
+ if (choice?.finish_reason === "length") {
6169
+ throw new LlmResponseTruncatedError(label);
6170
+ }
6171
+ if (!trimmed) {
6082
6172
  throw new Error("Empty response from LLM");
6083
6173
  }
6084
- return content.trim();
6174
+ return trimmed;
6085
6175
  }
6086
6176
  async function generateQuestionViaLLM(db, input8) {
6087
6177
  const cfg = await getProviderForRole(db, "recall");
@@ -6248,15 +6338,42 @@ function parseGeneratedCardArray(responseText, label, limits) {
6248
6338
  };
6249
6339
  });
6250
6340
  }
6251
- async function importCurriculumViaLLM(db, text, targetCategory, sourceUrl) {
6252
- if (text.length > MAX_IMPORT_TEXT_CHARS) {
6253
- throw new Error(
6254
- `Curriculum text exceeds the ${MAX_IMPORT_TEXT_CHARS.toLocaleString()} character limit`
6255
- );
6341
+ var CURRICULUM_CHUNK_TEXT_CHARS = 3e3;
6342
+ function splitCurriculumText(text, maxChars) {
6343
+ if (text.length <= maxChars) return [text];
6344
+ const chunks = [];
6345
+ let start = 0;
6346
+ while (start < text.length) {
6347
+ let end = Math.min(start + maxChars, text.length);
6348
+ if (end < text.length) {
6349
+ const minimumBoundary = start + Math.floor(maxChars * 0.6);
6350
+ const sentenceWindow = text.slice(minimumBoundary, end);
6351
+ let sentenceBoundary = -1;
6352
+ for (const match of sentenceWindow.matchAll(/[.!?;:]\s+/g)) {
6353
+ sentenceBoundary = (match.index ?? 0) + match[0].length;
6354
+ }
6355
+ if (sentenceBoundary > 0) {
6356
+ end = minimumBoundary + sentenceBoundary;
6357
+ } else {
6358
+ const whitespaceBoundary = text.lastIndexOf(" ", end);
6359
+ if (whitespaceBoundary >= minimumBoundary) end = whitespaceBoundary + 1;
6360
+ }
6361
+ }
6362
+ chunks.push(text.slice(start, end));
6363
+ start = end;
6256
6364
  }
6257
- const cfg = await getProviderForRole(db, "text");
6258
- const endpoint = await resolveUsableTextEndpoint(db);
6259
- const langName = LANGUAGE_NAMES[cfg.locale] || "English";
6365
+ return chunks;
6366
+ }
6367
+ function deduplicateGeneratedCards(cards) {
6368
+ const seen = /* @__PURE__ */ new Set();
6369
+ return cards.filter((card) => {
6370
+ const key = `${card.question.trim().toLocaleLowerCase()}\0${card.concept.trim().toLocaleLowerCase()}`;
6371
+ if (seen.has(key)) return false;
6372
+ seen.add(key);
6373
+ return true;
6374
+ });
6375
+ }
6376
+ async function requestCurriculumCards(endpoint, locale, langName, curriculumText, targetCategory, sourceUrl) {
6260
6377
  const systemPrompt = `You are ZAM, a highly precise agentic curriculum parser.
6261
6378
  Your task is to analyze curriculum objectives, syllabus requirements, or textbook notes, and extract atomic facts, concepts, or skills as structured learning cards in ${langName}.
6262
6379
 
@@ -6285,7 +6402,7 @@ Guidelines:
6285
6402
  curriculum section.
6286
6403
  - Output ONLY a raw valid JSON array of objects. Do NOT wrap the JSON in markdown code blocks, HTML, or include any preamble, headers, or conversational filler.`;
6287
6404
  const userPrompt = `Curriculum Text to Parse:
6288
- ${text}
6405
+ ${curriculumText}
6289
6406
 
6290
6407
  Target Category: ${targetCategory}
6291
6408
  ${sourceUrl ? `Source Reference Link: ${sourceUrl}` : ""}
@@ -6308,10 +6425,73 @@ JSON Array Output:`;
6308
6425
  temperature: 0.1,
6309
6426
  max_tokens: DEFAULT_LLM_MAX_TOKENS
6310
6427
  }),
6311
- locale: cfg.locale
6428
+ locale
6312
6429
  }
6313
6430
  );
6314
- const responseText = await readChatContent(res, "LLM curriculum import");
6431
+ return readChatContent(res, "LLM curriculum import");
6432
+ }
6433
+ async function importCurriculumViaLLM(db, text, targetCategory, sourceUrl) {
6434
+ if (text.length > MAX_IMPORT_TEXT_CHARS) {
6435
+ throw new Error(
6436
+ `Curriculum text exceeds the ${MAX_IMPORT_TEXT_CHARS.toLocaleString()} character limit`
6437
+ );
6438
+ }
6439
+ const cfg = await getProviderForRole(db, "text");
6440
+ const endpoint = await resolveUsableTextEndpoint(db);
6441
+ const langName = LANGUAGE_NAMES[cfg.locale] || "English";
6442
+ let responseText;
6443
+ try {
6444
+ responseText = await requestCurriculumCards(
6445
+ endpoint,
6446
+ cfg.locale,
6447
+ langName,
6448
+ text,
6449
+ targetCategory,
6450
+ sourceUrl
6451
+ );
6452
+ } catch (err) {
6453
+ if (!(err instanceof LlmResponseTruncatedError) || text.length <= CURRICULUM_CHUNK_TEXT_CHARS) {
6454
+ throw err;
6455
+ }
6456
+ const chunks = splitCurriculumText(text, CURRICULUM_CHUNK_TEXT_CHARS);
6457
+ const cards = [];
6458
+ for (const [index, chunk] of chunks.entries()) {
6459
+ let chunkResponse;
6460
+ try {
6461
+ chunkResponse = await requestCurriculumCards(
6462
+ endpoint,
6463
+ cfg.locale,
6464
+ langName,
6465
+ chunk,
6466
+ targetCategory,
6467
+ sourceUrl
6468
+ );
6469
+ } catch (chunkError) {
6470
+ if (chunkError instanceof LlmResponseTruncatedError) {
6471
+ throw new Error(
6472
+ `Curriculum chunk ${index + 1} of ${chunks.length} is still too large for the configured model's context window. Configure a model with a larger context window, or import a smaller source.`
6473
+ );
6474
+ }
6475
+ throw chunkError;
6476
+ }
6477
+ cards.push(
6478
+ ...parseGeneratedCardArray(chunkResponse, "curriculum import", {
6479
+ min: 0,
6480
+ max: 200
6481
+ })
6482
+ );
6483
+ }
6484
+ const deduplicated = deduplicateGeneratedCards(cards);
6485
+ if (deduplicated.length > 200) {
6486
+ throw new Error(
6487
+ `Invalid curriculum import response: expected 0-200 cards, got ${deduplicated.length}`
6488
+ );
6489
+ }
6490
+ return deduplicated.map((card) => ({
6491
+ ...card,
6492
+ source_link: sourceUrl || null
6493
+ }));
6494
+ }
6315
6495
  return parseGeneratedCardArray(responseText, "curriculum import", {
6316
6496
  min: 0,
6317
6497
  max: 200
@@ -7269,6 +7449,412 @@ async function readImageOCR(db, imagePath) {
7269
7449
  return extractTextFromScanViaLLM(db, imagePath);
7270
7450
  }
7271
7451
 
7452
+ // src/cli/curriculum/breadcrumb.ts
7453
+ var LAST_SELECTION_KEY = "curriculum.lastSelection";
7454
+ async function getLastCurriculumSelection(db) {
7455
+ const raw = await getSetting(db, LAST_SELECTION_KEY);
7456
+ if (!raw) return void 0;
7457
+ try {
7458
+ return JSON.parse(raw);
7459
+ } catch {
7460
+ return void 0;
7461
+ }
7462
+ }
7463
+ async function setLastCurriculumSelection(db, breadcrumb) {
7464
+ await setSetting(db, LAST_SELECTION_KEY, JSON.stringify(breadcrumb));
7465
+ }
7466
+
7467
+ // src/cli/curriculum/providers/bildungsplan-bw/manifest.ts
7468
+ var BILDUNGSPLAN_BW_MANIFEST = {
7469
+ schoolYear: "2026/2027",
7470
+ capturedOn: "2026-07-02",
7471
+ sourceRevision: "Bildungsplan Baden-W\xFCrttemberg Gymnasium 2016",
7472
+ schoolTypes: [
7473
+ { id: "gymnasium", label: "Gymnasium" },
7474
+ { id: "realschule", label: "Realschule" }
7475
+ ],
7476
+ grades: {
7477
+ gymnasium: ["9", "10", "11", "12"]
7478
+ },
7479
+ subjects: {
7480
+ gymnasium: [
7481
+ { id: "mathematik", label: "Mathematik" },
7482
+ { id: "physik", label: "Physik" },
7483
+ { id: "englisch", label: "Englisch" }
7484
+ ]
7485
+ },
7486
+ tracks: {},
7487
+ topics: {
7488
+ "gymnasium|10|mathematik": [
7489
+ { id: "leitidee-zahl", label: "Leitidee Zahl - Variable - Operation" },
7490
+ { id: "leitidee-messung", label: "Leitidee Messen" },
7491
+ { id: "leitidee-raum", label: "Leitidee Raum und Form" },
7492
+ { id: "leitidee-funktion", label: "Leitidee Funktionaler Zusammenhang" },
7493
+ { id: "leitidee-daten", label: "Leitidee Daten und Zufall" }
7494
+ ]
7495
+ },
7496
+ contentUrls: {
7497
+ "gymnasium|10|mathematik": "http://www.bildungsplaene-bw.de/,Lde/LS/BP2016BW/ALLG/GYM/M/bp/klasse-10"
7498
+ }
7499
+ };
7500
+
7501
+ // src/cli/curriculum/providers/bildungsplan-bw/index.ts
7502
+ var bildungsplanBwProvider = {
7503
+ id: "bildungsplan-bw",
7504
+ country: "DE",
7505
+ countryLabel: "Deutschland",
7506
+ region: "BW",
7507
+ regionLabel: "Baden-W\xFCrttemberg",
7508
+ label: "Bildungsplan (Baden-W\xFCrttemberg)",
7509
+ listSchoolTypes() {
7510
+ return BILDUNGSPLAN_BW_MANIFEST.schoolTypes;
7511
+ },
7512
+ listGrades(schoolType) {
7513
+ return (BILDUNGSPLAN_BW_MANIFEST.grades[schoolType] || []).map((id) => ({
7514
+ id,
7515
+ label: `Klasse ${id}`
7516
+ }));
7517
+ },
7518
+ listSubjects(schoolType, _grade) {
7519
+ return BILDUNGSPLAN_BW_MANIFEST.subjects[schoolType] || [];
7520
+ },
7521
+ listTracks(schoolType, grade, subject) {
7522
+ const key = `${schoolType}|${grade}|${subject}`;
7523
+ return BILDUNGSPLAN_BW_MANIFEST.tracks[key] || [];
7524
+ },
7525
+ listTopics(selection) {
7526
+ const key = selection.track ? `${selection.schoolType}|${selection.grade}|${selection.subject}|${selection.track}` : `${selection.schoolType}|${selection.grade}|${selection.subject}`;
7527
+ const list = BILDUNGSPLAN_BW_MANIFEST.topics[key] || [];
7528
+ return list.map((t2) => ({
7529
+ ...t2,
7530
+ sourceRef: key
7531
+ }));
7532
+ },
7533
+ resolveTopic(topic) {
7534
+ const uri = BILDUNGSPLAN_BW_MANIFEST.contentUrls[topic.sourceRef];
7535
+ if (!uri) {
7536
+ throw new Error(
7537
+ `Bildungsplan Baden-W\xFCrttemberg: no resolvable source URL for topic "${topic.id}" (${topic.sourceRef}).`
7538
+ );
7539
+ }
7540
+ return {
7541
+ provider: "bildungsplan-bw",
7542
+ topicId: `${topic.sourceRef}#${topic.id}`,
7543
+ uri
7544
+ };
7545
+ },
7546
+ extractTopics(html, topicIds) {
7547
+ const chunks = html.split('<div class="bp-topic-block" id="bp_topic_');
7548
+ const sections = [];
7549
+ for (let i = 1; i < chunks.length; i++) {
7550
+ const chunk = chunks[i];
7551
+ const quoteIdx = chunk.indexOf('"');
7552
+ if (quoteIdx === -1) continue;
7553
+ const id = chunk.slice(0, quoteIdx);
7554
+ const contentHtml = chunk.slice(quoteIdx + 1);
7555
+ const headerMatch = contentHtml.match(
7556
+ /<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i
7557
+ );
7558
+ const headerText = headerMatch ? cleanHtmlText(headerMatch[1]).trim() : "";
7559
+ sections.push({
7560
+ id,
7561
+ headerText,
7562
+ contentHtml
7563
+ });
7564
+ }
7565
+ const results = {};
7566
+ for (const topicId of topicIds) {
7567
+ const hashIdx = topicId.indexOf("#");
7568
+ if (hashIdx === -1) continue;
7569
+ const key = topicId.substring(0, hashIdx);
7570
+ const shortId = topicId.substring(hashIdx + 1);
7571
+ const list = BILDUNGSPLAN_BW_MANIFEST.topics[key] ?? [];
7572
+ const match = list.find((t2) => t2.id === shortId);
7573
+ if (!match) continue;
7574
+ const label = match.label;
7575
+ const normalizedLabel = normalizeForComparison(label);
7576
+ const sectionIndex = sections.findIndex((s) => {
7577
+ const normalizedHeader = normalizeForComparison(s.headerText);
7578
+ return normalizedHeader.includes(normalizedLabel) || s.id === shortId;
7579
+ });
7580
+ if (sectionIndex === -1) {
7581
+ continue;
7582
+ }
7583
+ results[topicId] = cleanHtmlText(sections[sectionIndex].contentHtml);
7584
+ }
7585
+ return results;
7586
+ }
7587
+ };
7588
+ function cleanHtmlText(html) {
7589
+ let text = html.replace(
7590
+ /<(head|script|style|svg)[^>]*>[\s\S]*?<\/\1>/gi,
7591
+ " "
7592
+ );
7593
+ text = text.replace(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/gi, "\n\n$1\n\n");
7594
+ text = text.replace(/<li[^>]*>/gi, "\n- ");
7595
+ text = text.replace(/<p[^>]*>/gi, "\n");
7596
+ text = text.replace(/<br\s*\/?>/gi, "\n");
7597
+ text = text.replace(/<[^>]+>/g, " ");
7598
+ text = text.replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&ndash;/g, "\u2013").replace(/&mdash;/g, "\u2014").replace(/&middot;/g, "\xB7");
7599
+ return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
7600
+ }
7601
+ function normalizeForComparison(str) {
7602
+ return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
7603
+ }
7604
+
7605
+ // src/cli/curriculum/providers/lehrplanplus-bayern/manifest.ts
7606
+ var LEHRPLANPLUS_BAYERN_MANIFEST = {
7607
+ schoolYear: "2026/2027",
7608
+ capturedOn: "2026-07-02",
7609
+ sourceRevision: "LehrplanPLUS Realschule \u2013 Oktober 2023",
7610
+ schoolTypes: [
7611
+ { id: "grundschule", label: "Grundschule" },
7612
+ { id: "mittelschule", label: "Mittelschule" },
7613
+ { id: "foerderschule", label: "F\xF6rderschule" },
7614
+ { id: "realschule", label: "Realschule" },
7615
+ { id: "gymnasium", label: "Gymnasium" },
7616
+ { id: "wirtschaftsschule", label: "Wirtschaftsschule" },
7617
+ { id: "fos", label: "Fachoberschule" },
7618
+ { id: "bos", label: "Berufsoberschule" }
7619
+ ],
7620
+ grades: {
7621
+ realschule: ["5", "6", "7", "8", "9", "10"]
7622
+ },
7623
+ subjects: {
7624
+ realschule: [
7625
+ {
7626
+ id: "bwl-rechnungswesen",
7627
+ label: "Betriebswirtschaftslehre / Rechnungswesen"
7628
+ },
7629
+ { id: "biologie", label: "Biologie" },
7630
+ { id: "chemie", label: "Chemie" },
7631
+ { id: "deutsch", label: "Deutsch" },
7632
+ { id: "englisch", label: "Englisch" },
7633
+ { id: "ernaehrung_und_gesundheit", label: "Ern\xE4hrung und Gesundheit" },
7634
+ { id: "ethik", label: "Ethik" },
7635
+ {
7636
+ id: "evangelische-religionslehre",
7637
+ label: "Evangelische Religionslehre"
7638
+ },
7639
+ { id: "franzoesisch", label: "Franz\xF6sisch" },
7640
+ { id: "geographie", label: "Geographie" },
7641
+ { id: "geschichte", label: "Geschichte" },
7642
+ { id: "it", label: "Informationstechnologie" },
7643
+ { id: "iu", label: "Islamischer Unterricht" },
7644
+ { id: "ir", label: "Israelitische Religionslehre" },
7645
+ {
7646
+ id: "katholische-religionslehre",
7647
+ label: "Katholische Religionslehre"
7648
+ },
7649
+ { id: "kunst", label: "Kunst" },
7650
+ { id: "mathematik", label: "Mathematik" },
7651
+ { id: "musik", label: "Musik" },
7652
+ { id: "or", label: "Orthodoxe Religionslehre" },
7653
+ { id: "physik", label: "Physik" },
7654
+ { id: "pug", label: "Politik und Gesellschaft" },
7655
+ { id: "soziallehre", label: "Soziallehre" },
7656
+ { id: "sozialwesen", label: "Sozialwesen" },
7657
+ { id: "spanisch", label: "Spanisch" },
7658
+ { id: "sport", label: "Sport" },
7659
+ { id: "textiles-gestalten", label: "Textiles Gestalten" },
7660
+ { id: "werken", label: "Werken" },
7661
+ { id: "wirtschaft-und-recht", label: "Wirtschaft und Recht" }
7662
+ ]
7663
+ },
7664
+ tracks: {
7665
+ "realschule|9|mathematik": [
7666
+ { id: "wpfg1", label: "Mathematik 9 (I)" },
7667
+ { id: "wpfg2-3", label: "Mathematik 9 (II/III)" }
7668
+ ]
7669
+ },
7670
+ topics: {
7671
+ "realschule|9|mathematik|wpfg1": [
7672
+ { id: "lb1", label: "Reelle Zahlen", hours: 10 },
7673
+ { id: "lb2", label: "Zentrische Streckung", hours: 17 },
7674
+ { id: "lb3", label: "Rechtwinklige Dreiecke", hours: 20 },
7675
+ { id: "lb4", label: "Kreis", hours: 10 },
7676
+ { id: "lb5", label: "Raumgeometrie", hours: 20 },
7677
+ { id: "lb6", label: "Systeme linearer Gleichungen", hours: 12 },
7678
+ {
7679
+ id: "lb7",
7680
+ label: "Quadratische Funktionen und quadratische Gleichungen",
7681
+ hours: 42
7682
+ },
7683
+ { id: "lb8", label: "Daten und Zufall", hours: 9 }
7684
+ ],
7685
+ "realschule|9|mathematik|wpfg2-3": [
7686
+ { id: "lb1", label: "Reelle Zahlen", hours: 7 },
7687
+ { id: "lb2", label: "Zentrische Streckung", hours: 13 },
7688
+ { id: "lb3", label: "Rechtwinklige Dreiecke", hours: 20 },
7689
+ { id: "lb4", label: "Kreis", hours: 10 },
7690
+ { id: "lb5", label: "Lineare Funktionen", hours: 15 },
7691
+ { id: "lb6", label: "Systeme linearer Gleichungen", hours: 10 },
7692
+ { id: "lb7", label: "Daten und Zufall", hours: 9 }
7693
+ ],
7694
+ "realschule|9|deutsch": [
7695
+ { id: "lb1", label: "Sprechen und Zuh\xF6ren" },
7696
+ { id: "lb2", label: "Lesen \u2013 mit Texten und weiteren Medien umgehen" },
7697
+ { id: "lb3", label: "Schreiben" },
7698
+ {
7699
+ id: "lb4",
7700
+ label: "Sprachgebrauch und Sprache untersuchen und reflektieren"
7701
+ }
7702
+ ],
7703
+ "realschule|9|englisch": [
7704
+ { id: "lb1", label: "Kommunikative Kompetenzen" },
7705
+ { id: "lb2", label: "Interkulturelle Kompetenzen" },
7706
+ { id: "lb3", label: "Text- und Medienkompetenzen" },
7707
+ { id: "lb4", label: "Methodische Kompetenzen" },
7708
+ { id: "lb5", label: "Themengebiete" }
7709
+ ]
7710
+ },
7711
+ contentUrls: {
7712
+ "realschule|9|mathematik|wpfg1": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/9/fach/mathematik/inhalt/fachlehrplaene?w_schulart=realschule&wt_1=schulart&w_fach=mathematik&wt_2=fach&w_jgs=9&wt_3=jgs&w_auspraegung=wpfg1",
7713
+ "realschule|9|mathematik|wpfg2-3": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/9/fach/mathematik/inhalt/fachlehrplaene?w_schulart=realschule&wt_1=schulart&w_fach=mathematik&wt_2=fach&w_jgs=9&wt_3=jgs&w_auspraegung=wpfg2-3",
7714
+ "realschule|9|deutsch": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/9/fach/deutsch/inhalt/fachlehrplaene",
7715
+ "realschule|9|englisch": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/9/fach/englisch/inhalt/fachlehrplaene"
7716
+ }
7717
+ };
7718
+
7719
+ // src/cli/curriculum/providers/lehrplanplus-bayern/index.ts
7720
+ function levelKey(schoolType, grade, subject, track) {
7721
+ return track ? `${schoolType}|${grade}|${subject}|${track}` : `${schoolType}|${grade}|${subject}`;
7722
+ }
7723
+ var lehrplanplusBayernProvider = {
7724
+ id: "lehrplanplus-bayern",
7725
+ country: "DE",
7726
+ countryLabel: "Deutschland",
7727
+ region: "BY",
7728
+ regionLabel: "Bayern",
7729
+ label: "LehrplanPLUS (Bayern)",
7730
+ listSchoolTypes() {
7731
+ return LEHRPLANPLUS_BAYERN_MANIFEST.schoolTypes;
7732
+ },
7733
+ listGrades(schoolType) {
7734
+ return (LEHRPLANPLUS_BAYERN_MANIFEST.grades[schoolType] ?? []).map((grade) => ({
7735
+ id: grade,
7736
+ label: grade
7737
+ }));
7738
+ },
7739
+ listSubjects(schoolType, _grade) {
7740
+ return LEHRPLANPLUS_BAYERN_MANIFEST.subjects[schoolType] ?? [];
7741
+ },
7742
+ listTracks(schoolType, grade, subject) {
7743
+ return LEHRPLANPLUS_BAYERN_MANIFEST.tracks[levelKey(schoolType, grade, subject)] ?? [];
7744
+ },
7745
+ listTopics(selection) {
7746
+ const { schoolType, grade, subject, track } = selection;
7747
+ if (!schoolType || !grade || !subject) return [];
7748
+ const key = levelKey(schoolType, grade, subject, track);
7749
+ return (LEHRPLANPLUS_BAYERN_MANIFEST.topics[key] ?? []).map((topic) => ({
7750
+ ...topic,
7751
+ sourceRef: key
7752
+ }));
7753
+ },
7754
+ resolveTopic(topic) {
7755
+ const uri = LEHRPLANPLUS_BAYERN_MANIFEST.contentUrls[topic.sourceRef];
7756
+ if (!uri) {
7757
+ throw new Error(
7758
+ `LehrplanPLUS Bayern: no resolvable source URL for topic "${topic.id}" (${topic.sourceRef}). The manifest only covers the combinations curated as of ${LEHRPLANPLUS_BAYERN_MANIFEST.capturedOn}.`
7759
+ );
7760
+ }
7761
+ return {
7762
+ provider: "lehrplanplus-bayern",
7763
+ topicId: `${topic.sourceRef}#${topic.id}`,
7764
+ uri
7765
+ };
7766
+ },
7767
+ extractTopics(html, topicIds) {
7768
+ const chunks = html.split('<div id="thema_');
7769
+ const sections = [];
7770
+ for (let i = 1; i < chunks.length; i++) {
7771
+ const chunk = chunks[i];
7772
+ const quoteIdx = chunk.indexOf('"');
7773
+ if (quoteIdx === -1) continue;
7774
+ const id = chunk.slice(0, quoteIdx);
7775
+ const classStart = chunk.indexOf('class="');
7776
+ if (classStart === -1) continue;
7777
+ const classEnd = chunk.indexOf('"', classStart + 7);
7778
+ const classContent = chunk.slice(classStart + 7, classEnd);
7779
+ const lvlMatch = classContent.match(/headline_lvl(\d+)/);
7780
+ if (!lvlMatch) continue;
7781
+ const level = parseInt(lvlMatch[1], 10);
7782
+ const contentHtml = chunk.slice(classEnd + 1);
7783
+ const headerMatch = contentHtml.match(
7784
+ /<a[^>]*class="paragraph_toggle"[^>]*>([\s\S]*?)<\/a>/i
7785
+ );
7786
+ const headerText = headerMatch ? cleanHtmlText2(headerMatch[1]).trim() : "";
7787
+ sections.push({
7788
+ id,
7789
+ level,
7790
+ headerText,
7791
+ contentHtml
7792
+ });
7793
+ }
7794
+ const results = {};
7795
+ for (const topicId of topicIds) {
7796
+ let label = "";
7797
+ for (const key of Object.keys(LEHRPLANPLUS_BAYERN_MANIFEST.topics)) {
7798
+ const list = LEHRPLANPLUS_BAYERN_MANIFEST.topics[key];
7799
+ const match = list.find(
7800
+ (t2) => t2.id === topicId || `${key}#${t2.id}` === topicId
7801
+ );
7802
+ if (match) {
7803
+ label = match.label;
7804
+ break;
7805
+ }
7806
+ }
7807
+ if (!label) {
7808
+ continue;
7809
+ }
7810
+ const normalizedLabel = normalizeForComparison2(label);
7811
+ const lvl1Index = sections.findIndex((s) => {
7812
+ if (s.level !== 1) return false;
7813
+ const normalizedHeader = normalizeForComparison2(s.headerText);
7814
+ return normalizedHeader.includes(normalizedLabel);
7815
+ });
7816
+ if (lvl1Index === -1) {
7817
+ continue;
7818
+ }
7819
+ const collectedSections = [sections[lvl1Index]];
7820
+ for (let i = lvl1Index + 1; i < sections.length; i++) {
7821
+ if (sections[i].level === 1) {
7822
+ break;
7823
+ }
7824
+ collectedSections.push(sections[i]);
7825
+ }
7826
+ const fullHtml = collectedSections.map((s) => s.contentHtml).join("\n");
7827
+ results[topicId] = cleanHtmlText2(fullHtml);
7828
+ }
7829
+ return results;
7830
+ }
7831
+ };
7832
+ function cleanHtmlText2(html) {
7833
+ let text = html.replace(
7834
+ /<(head|script|style|svg)[^>]*>[\s\S]*?<\/\1>/gi,
7835
+ " "
7836
+ );
7837
+ text = text.replace(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/gi, "\n\n$1\n\n");
7838
+ text = text.replace(/<li[^>]*>/gi, "\n- ");
7839
+ text = text.replace(/<p[^>]*>/gi, "\n");
7840
+ text = text.replace(/<br\s*\/?>/gi, "\n");
7841
+ text = text.replace(/<[^>]+>/g, " ");
7842
+ text = text.replace(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&ndash;/g, "\u2013").replace(/&mdash;/g, "\u2014").replace(/&middot;/g, "\xB7");
7843
+ return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
7844
+ }
7845
+ function normalizeForComparison2(str) {
7846
+ return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
7847
+ }
7848
+
7849
+ // src/cli/curriculum/registry.ts
7850
+ var CURRICULUM_PROVIDERS = [
7851
+ lehrplanplusBayernProvider,
7852
+ bildungsplanBwProvider
7853
+ ];
7854
+ function getCurriculumProvider(id) {
7855
+ return CURRICULUM_PROVIDERS.find((provider) => provider.id === id);
7856
+ }
7857
+
7272
7858
  // src/cli/llm/vision.ts
7273
7859
  import { randomBytes } from "crypto";
7274
7860
  import { readFileSync as readFileSync10 } from "fs";
@@ -10555,8 +11141,22 @@ bridgeCommand.command("personal-card-confirm-foundations").description("Save con
10555
11141
  });
10556
11142
  bridgeCommand.command("personal-source-import").description(
10557
11143
  "Fetch and clean plain text from local file, web link, or vision scan (JSON)"
10558
- ).requiredOption("--type <file|web|scan>", "Source type").requiredOption("--uri <uri>", "Source path or URL").action(async (opts) => {
11144
+ ).requiredOption("--type <file|web|scan>", "Source type").requiredOption("--uri <uri>", "Source path or URL").option("--refresh", "Re-fetch an already cached web source").action(async (opts) => {
10559
11145
  await withDb2(async (db) => {
11146
+ if (opts.type === "web" && !opts.refresh) {
11147
+ const cached = await db.prepare(
11148
+ "SELECT id, content FROM sources WHERE uri = ? AND type = 'web'"
11149
+ ).get(opts.uri);
11150
+ if (cached?.content) {
11151
+ jsonOut2({
11152
+ success: true,
11153
+ sourceId: cached.id,
11154
+ content: cached.content,
11155
+ cached: true
11156
+ });
11157
+ return;
11158
+ }
11159
+ }
10560
11160
  let content = "";
10561
11161
  if (opts.type === "file") {
10562
11162
  content = await readLocalFile(opts.uri);
@@ -10579,7 +11179,8 @@ bridgeCommand.command("personal-source-import").description(
10579
11179
  jsonOut2({
10580
11180
  success: true,
10581
11181
  sourceId: record.id,
10582
- content: record.content
11182
+ content: record.content,
11183
+ cached: false
10583
11184
  });
10584
11185
  });
10585
11186
  });
@@ -10605,6 +11206,205 @@ bridgeCommand.command("personal-source-confirm-import").description(
10605
11206
  });
10606
11207
  });
10607
11208
  });
11209
+ bridgeCommand.command("curriculum-list-providers").description("List registered curriculum providers (JSON)").action(() => {
11210
+ jsonOut2({
11211
+ success: true,
11212
+ providers: CURRICULUM_PROVIDERS.map((provider) => ({
11213
+ id: provider.id,
11214
+ country: provider.country,
11215
+ countryLabel: provider.countryLabel,
11216
+ region: provider.region,
11217
+ regionLabel: provider.regionLabel,
11218
+ label: provider.label
11219
+ }))
11220
+ });
11221
+ });
11222
+ var CURRICULUM_LEVELS = [
11223
+ "schoolType",
11224
+ "grade",
11225
+ "subject",
11226
+ "track",
11227
+ "topic"
11228
+ ];
11229
+ bridgeCommand.command("curriculum-list-level").description("List taxonomy options for the next import-wizard step (JSON)").requiredOption("--provider <id>", "Curriculum provider id").requiredOption(
11230
+ "--level <level>",
11231
+ `Level to list: ${CURRICULUM_LEVELS.join("|")}`
11232
+ ).option("--selection <json>", "JSON selection made so far", "{}").action((opts) => {
11233
+ const provider = getCurriculumProvider(opts.provider);
11234
+ if (!provider) jsonError(`Unknown curriculum provider: ${opts.provider}`);
11235
+ if (!CURRICULUM_LEVELS.includes(opts.level)) {
11236
+ jsonError(
11237
+ `Invalid --level: ${opts.level}. Use one of ${CURRICULUM_LEVELS.join(", ")}.`
11238
+ );
11239
+ }
11240
+ let selection;
11241
+ try {
11242
+ selection = JSON.parse(opts.selection);
11243
+ } catch {
11244
+ jsonError("Invalid --selection JSON");
11245
+ return;
11246
+ }
11247
+ const level = opts.level;
11248
+ if (level === "schoolType") {
11249
+ jsonOut2({ success: true, options: provider.listSchoolTypes() });
11250
+ return;
11251
+ }
11252
+ if (!selection.schoolType) jsonError("selection.schoolType is required");
11253
+ if (level === "grade") {
11254
+ jsonOut2({
11255
+ success: true,
11256
+ options: provider.listGrades(selection.schoolType)
11257
+ });
11258
+ return;
11259
+ }
11260
+ if (!selection.grade) jsonError("selection.grade is required");
11261
+ if (level === "subject") {
11262
+ jsonOut2({
11263
+ success: true,
11264
+ options: provider.listSubjects(selection.schoolType, selection.grade)
11265
+ });
11266
+ return;
11267
+ }
11268
+ if (!selection.subject) jsonError("selection.subject is required");
11269
+ if (level === "track") {
11270
+ jsonOut2({
11271
+ success: true,
11272
+ options: provider.listTracks(
11273
+ selection.schoolType,
11274
+ selection.grade,
11275
+ selection.subject
11276
+ )
11277
+ });
11278
+ return;
11279
+ }
11280
+ jsonOut2({ success: true, options: provider.listTopics(selection) });
11281
+ });
11282
+ bridgeCommand.command("curriculum-resolve-topics").description("Resolve selected curriculum topics to source URLs (JSON)").requiredOption("--provider <id>", "Curriculum provider id").requiredOption("--topics <json>", "JSON array of topic nodes to resolve").action((opts) => {
11283
+ const provider = getCurriculumProvider(opts.provider);
11284
+ if (!provider) jsonError(`Unknown curriculum provider: ${opts.provider}`);
11285
+ let topics;
11286
+ try {
11287
+ topics = JSON.parse(opts.topics);
11288
+ } catch {
11289
+ jsonError("Invalid --topics JSON");
11290
+ return;
11291
+ }
11292
+ const resolved = topics.map((topic) => provider.resolveTopic(topic));
11293
+ jsonOut2({ success: true, resolved });
11294
+ });
11295
+ async function fetchRawHtml(url) {
11296
+ if (!await isSafeUrl(url)) {
11297
+ throw new Error(`Access denied to unsafe target URL: ${url}`);
11298
+ }
11299
+ const controller = new AbortController();
11300
+ const timeoutId = setTimeout(() => controller.abort(), 1e4);
11301
+ try {
11302
+ const res = await fetch(url, {
11303
+ signal: controller.signal,
11304
+ headers: {
11305
+ "User-Agent": "ZAM-Content-Studio/0.6.1"
11306
+ }
11307
+ });
11308
+ if (!res.ok) {
11309
+ throw new Error(`Web server responded with status ${res.status}`);
11310
+ }
11311
+ const contentType = res.headers.get("content-type") || "";
11312
+ if (!contentType.includes("text/html") && !contentType.includes("application/xhtml+xml")) {
11313
+ throw new Error(`Unsupported content type: ${contentType}`);
11314
+ }
11315
+ return await res.text();
11316
+ } catch (err) {
11317
+ if (err instanceof Error && err.name === "AbortError") {
11318
+ throw new Error("Connection request timed out after 10 seconds");
11319
+ }
11320
+ throw err;
11321
+ } finally {
11322
+ clearTimeout(timeoutId);
11323
+ }
11324
+ }
11325
+ bridgeCommand.command("curriculum-extract-topics").description(
11326
+ "Fetch and extract specific texts for selected curriculum topics (JSON)"
11327
+ ).requiredOption("--provider <id>", "Curriculum provider id").requiredOption("--topics <json>", "JSON array of selected topic nodes").action(async (opts) => {
11328
+ await withDb2(async (db) => {
11329
+ const provider = getCurriculumProvider(opts.provider);
11330
+ if (!provider) jsonError(`Unknown curriculum provider: ${opts.provider}`);
11331
+ let topics;
11332
+ try {
11333
+ topics = JSON.parse(opts.topics);
11334
+ } catch {
11335
+ jsonError("Invalid --topics JSON");
11336
+ return;
11337
+ }
11338
+ const topicsByUri = /* @__PURE__ */ new Map();
11339
+ for (const topic of topics) {
11340
+ const resolved = provider.resolveTopic(topic);
11341
+ const list = topicsByUri.get(resolved.uri) || [];
11342
+ list.push(topic);
11343
+ topicsByUri.set(resolved.uri, list);
11344
+ }
11345
+ const extracted = [];
11346
+ for (const [uri, uriTopics] of topicsByUri.entries()) {
11347
+ const rawHtml = await fetchRawHtml(uri);
11348
+ let extractedTexts = {};
11349
+ if (provider.extractTopics) {
11350
+ const fullTopicIds = uriTopics.map((t2) => `${t2.sourceRef}#${t2.id}`);
11351
+ extractedTexts = provider.extractTopics(rawHtml, fullTopicIds);
11352
+ } else {
11353
+ const cleanText = cleanHtml(rawHtml);
11354
+ for (const t2 of uriTopics) {
11355
+ extractedTexts[`${t2.sourceRef}#${t2.id}`] = cleanText;
11356
+ }
11357
+ }
11358
+ const pageCleanedText = cleanHtml(rawHtml);
11359
+ const sourceId = ulid8();
11360
+ await db.prepare(
11361
+ `INSERT INTO sources (id, type, uri, content)
11362
+ VALUES (?, 'web', ?, ?)
11363
+ ON CONFLICT(uri) DO UPDATE SET
11364
+ type = excluded.type,
11365
+ content = excluded.content`
11366
+ ).run(sourceId, uri, pageCleanedText);
11367
+ const record = await db.prepare("SELECT id FROM sources WHERE uri = ?").get(uri);
11368
+ for (const topic of uriTopics) {
11369
+ const resolved = provider.resolveTopic(topic);
11370
+ const text = extractedTexts[resolved.topicId] || "";
11371
+ extracted.push({
11372
+ topicId: resolved.topicId,
11373
+ uri,
11374
+ sourceId: record.id,
11375
+ text
11376
+ });
11377
+ }
11378
+ }
11379
+ jsonOut2({ success: true, extracted });
11380
+ });
11381
+ });
11382
+ bridgeCommand.command("curriculum-get-last-selection").description("Read the learner's last navigated curriculum path (JSON)").action(async () => {
11383
+ await withDb2(async (db) => {
11384
+ const breadcrumb = await getLastCurriculumSelection(db);
11385
+ jsonOut2({ success: true, breadcrumb: breadcrumb ?? null });
11386
+ });
11387
+ });
11388
+ bridgeCommand.command("curriculum-set-last-selection").description("Persist the learner's last navigated curriculum path (JSON)").requiredOption(
11389
+ "--breadcrumb <json>",
11390
+ "JSON breadcrumb: {providerId, schoolType?, grade?, subject?, track?}"
11391
+ ).action(async (opts) => {
11392
+ await withDb2(async (db) => {
11393
+ let breadcrumb;
11394
+ try {
11395
+ breadcrumb = JSON.parse(opts.breadcrumb);
11396
+ } catch {
11397
+ jsonError("Invalid --breadcrumb JSON");
11398
+ return;
11399
+ }
11400
+ if (!breadcrumb.providerId) {
11401
+ jsonError("breadcrumb.providerId is required");
11402
+ return;
11403
+ }
11404
+ await setLastCurriculumSelection(db, breadcrumb);
11405
+ jsonOut2({ success: true });
11406
+ });
11407
+ });
10608
11408
  bridgeCommand.command("serve").description("Start the persistent JSON-RPC stdin/stdout server").option("--stdin", "Use stdin/stdout for communication").action(async (_opts) => {
10609
11409
  isServeMode = true;
10610
11410
  const {