zam-core 0.6.0 → 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, "");
@@ -1722,10 +1763,22 @@ async function listPersonalCards(db, userId, options) {
1722
1763
  t.bloom_level AS bloomLevel,
1723
1764
  t.context,
1724
1765
  t.symbiosis_mode AS symbiosisMode,
1725
- t.source_link AS sourceLink,
1766
+ COALESCE(
1767
+ t.source_link,
1768
+ (
1769
+ SELECT s.uri
1770
+ FROM token_sources ts
1771
+ INNER JOIN sources s ON s.id = ts.source_id
1772
+ WHERE ts.token_id = t.id
1773
+ ORDER BY s.created_at DESC, s.id DESC
1774
+ LIMIT 1
1775
+ )
1776
+ ) AS sourceLink,
1726
1777
  t.question,
1727
1778
  t.created_at AS createdAt,
1728
1779
  t.updated_at AS updatedAt,
1780
+ t.provider,
1781
+ t.topic_id AS topicId,
1729
1782
  c.id AS cardId,
1730
1783
  c.state,
1731
1784
  c.due_at AS dueAt,
@@ -1755,6 +1808,17 @@ async function listPersonalCards(db, userId, options) {
1755
1808
  }
1756
1809
  sql += " ORDER BY t.created_at DESC";
1757
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
+ }
1758
1822
  return rows;
1759
1823
  }
1760
1824
  async function importCurriculumCards(db, userId, cards) {
@@ -1801,9 +1865,18 @@ async function importCurriculumCards(db, userId, cards) {
1801
1865
  context: card.context || "",
1802
1866
  symbiosis_mode: symbiosisMode,
1803
1867
  source_link: card.source_link || null,
1804
- question: card.question || null
1868
+ question: card.question || null,
1869
+ provider: card.provider || null,
1870
+ topic_id: card.topic_id || null
1805
1871
  });
1806
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
+ }
1807
1880
  }
1808
1881
  const existingCard = await getCard(tx, token.id, userId);
1809
1882
  if (!existingCard) {
@@ -2025,11 +2098,12 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
2025
2098
  let createdCount = 0;
2026
2099
  let linkedCount = 0;
2027
2100
  await db.transaction(async (tx) => {
2028
- const source = await tx.prepare("SELECT id FROM sources WHERE id = ?").get(sourceId);
2029
- if (!source) {
2030
- throw new Error(`Source not found: ${sourceId}`);
2031
- }
2032
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
+ }
2033
2107
  const baseText = card.question && card.question.trim().length > 0 ? card.question : card.concept;
2034
2108
  const cleanDomain = slugify(card.domain || "");
2035
2109
  const cleanBase = slugify(baseText);
@@ -2060,11 +2134,19 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
2060
2134
  bloom_level: bloom,
2061
2135
  context: card.excerpt || "",
2062
2136
  symbiosis_mode: symbiosisMode,
2063
- question: card.question || null
2137
+ question: card.question || null,
2138
+ provider: card.provider || null,
2139
+ topic_id: card.topic_id || null
2064
2140
  });
2065
2141
  createdCount++;
2066
2142
  } else {
2067
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
+ }
2068
2150
  }
2069
2151
  const existingCard = await getCard(tx, token.id, userId);
2070
2152
  if (!existingCard) {
@@ -2076,7 +2158,12 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
2076
2158
  ON CONFLICT(token_id, source_id) DO UPDATE SET
2077
2159
  excerpt = excluded.excerpt,
2078
2160
  page_number = excluded.page_number`
2079
- ).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
+ );
2080
2167
  }
2081
2168
  });
2082
2169
  return { createdCount, linkedCount };
@@ -5019,12 +5106,7 @@ function installFastFlowLM() {
5019
5106
  }
5020
5107
  }
5021
5108
  function installOllama() {
5022
- const isMac = process.platform === "darwin";
5023
- const isWin = process.platform === "win32";
5024
- const hasOllama = hasCommand("ollama") || isMac && existsSync9("/Applications/Ollama.app") || isWin && existsSync9(
5025
- join10(homedir7(), "AppData", "Local", "Programs", "Ollama", "ollama.exe")
5026
- );
5027
- if (hasOllama) {
5109
+ if (isOllamaInstalled()) {
5028
5110
  return { success: true, message: "Ollama is already installed." };
5029
5111
  }
5030
5112
  if (process.platform === "darwin") {
@@ -5079,19 +5161,23 @@ function installOllama() {
5079
5161
  }
5080
5162
  }
5081
5163
  }
5082
- function resolveOllamaCommand() {
5083
- if (hasCommand("ollama")) return "ollama";
5084
- const candidates = process.platform === "win32" ? [
5085
- join10(
5086
- homedir7(),
5087
- "AppData",
5088
- "Local",
5089
- "Programs",
5090
- "Ollama",
5091
- "ollama.exe"
5092
- )
5093
- ] : process.platform === "darwin" ? ["/Applications/Ollama.app/Contents/Resources/ollama"] : [];
5094
- return candidates.find((candidate) => existsSync9(candidate));
5164
+ function resolveOllamaCommand(options = {}) {
5165
+ const platform = options.platform ?? process.platform;
5166
+ const homeDir = options.homeDir ?? homedir7();
5167
+ const commandAvailable = options.commandAvailable ?? hasCommand;
5168
+ const pathExists2 = options.pathExists ?? existsSync9;
5169
+ if (commandAvailable("ollama")) return "ollama";
5170
+ const candidates = platform === "win32" ? [join10(homeDir, "AppData", "Local", "Programs", "Ollama", "ollama.exe")] : platform === "darwin" ? [
5171
+ "/opt/homebrew/bin/ollama",
5172
+ "/usr/local/bin/ollama",
5173
+ "/Applications/Ollama.app/Contents/Resources/ollama"
5174
+ ] : ["/usr/local/bin/ollama", "/usr/bin/ollama"];
5175
+ return candidates.find((candidate) => pathExists2(candidate));
5176
+ }
5177
+ function isOllamaInstalled(options = {}) {
5178
+ const platform = options.platform ?? process.platform;
5179
+ const pathExists2 = options.pathExists ?? existsSync9;
5180
+ return resolveOllamaCommand(options) !== void 0 || platform === "darwin" && pathExists2("/Applications/Ollama.app");
5095
5181
  }
5096
5182
  function prepareLocalModel(runner, model) {
5097
5183
  if (runner === "fastflowlm") {
@@ -6060,6 +6146,14 @@ var BLOOM_VERBS2 = {
6060
6146
  4: "Analyze",
6061
6147
  5: "Synthesize"
6062
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
+ };
6063
6157
  async function readChatContent(res, label) {
6064
6158
  if (!res.ok) {
6065
6159
  const errorText = await res.text().catch(() => "");
@@ -6068,11 +6162,16 @@ async function readChatContent(res, label) {
6068
6162
  );
6069
6163
  }
6070
6164
  const data = await res.json();
6071
- const content = data.choices?.[0]?.message?.content;
6072
- 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) {
6073
6172
  throw new Error("Empty response from LLM");
6074
6173
  }
6075
- return content.trim();
6174
+ return trimmed;
6076
6175
  }
6077
6176
  async function generateQuestionViaLLM(db, input8) {
6078
6177
  const cfg = await getProviderForRole(db, "recall");
@@ -6239,15 +6338,42 @@ function parseGeneratedCardArray(responseText, label, limits) {
6239
6338
  };
6240
6339
  });
6241
6340
  }
6242
- async function importCurriculumViaLLM(db, text, targetCategory, sourceUrl) {
6243
- if (text.length > MAX_IMPORT_TEXT_CHARS) {
6244
- throw new Error(
6245
- `Curriculum text exceeds the ${MAX_IMPORT_TEXT_CHARS.toLocaleString()} character limit`
6246
- );
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;
6247
6364
  }
6248
- const cfg = await getProviderForRole(db, "text");
6249
- const endpoint = await resolveUsableTextEndpoint(db);
6250
- 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) {
6251
6377
  const systemPrompt = `You are ZAM, a highly precise agentic curriculum parser.
6252
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}.
6253
6379
 
@@ -6263,9 +6389,20 @@ Guidelines:
6263
6389
  - Break complex requirements into multiple separate, atomic cards.
6264
6390
  - Keep questions focused on one fact.
6265
6391
  - Do not repeat the same concept in multiple cards.
6392
+ - Test the subject matter directly. Never ask what learners "must know", what
6393
+ a curriculum "requires", or merely restate a competency as a question.
6394
+ - Stay within the scope of the supplied curriculum. A reference answer may add
6395
+ the definition, formula, or minimal worked example needed to answer a direct
6396
+ subject-matter question, but it must not introduce named methods, terminology,
6397
+ or adjacent topics that the curriculum passage does not mention.
6398
+ - Keep every reference answer concise, mathematically/factually correct, and
6399
+ focused on the competency being tested.
6400
+ - Set "context" to the exact sentence or bullet that supports that specific
6401
+ card. Never reuse an unrelated passage merely because it comes from the same
6402
+ curriculum section.
6266
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.`;
6267
6404
  const userPrompt = `Curriculum Text to Parse:
6268
- ${text}
6405
+ ${curriculumText}
6269
6406
 
6270
6407
  Target Category: ${targetCategory}
6271
6408
  ${sourceUrl ? `Source Reference Link: ${sourceUrl}` : ""}
@@ -6288,10 +6425,73 @@ JSON Array Output:`;
6288
6425
  temperature: 0.1,
6289
6426
  max_tokens: DEFAULT_LLM_MAX_TOKENS
6290
6427
  }),
6291
- locale: cfg.locale
6428
+ locale
6292
6429
  }
6293
6430
  );
6294
- 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
+ }
6295
6495
  return parseGeneratedCardArray(responseText, "curriculum import", {
6296
6496
  min: 0,
6297
6497
  max: 200
@@ -7186,7 +7386,7 @@ async function readWebLink(url) {
7186
7386
  redirect: "manual",
7187
7387
  signal: controller.signal,
7188
7388
  headers: {
7189
- "User-Agent": "ZAM-Content-Studio/0.6.0"
7389
+ "User-Agent": "ZAM-Content-Studio/0.6.1"
7190
7390
  }
7191
7391
  });
7192
7392
  if (res.status >= 300 && res.status < 400) {
@@ -7249,6 +7449,412 @@ async function readImageOCR(db, imagePath) {
7249
7449
  return extractTextFromScanViaLLM(db, imagePath);
7250
7450
  }
7251
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
+
7252
7858
  // src/cli/llm/vision.ts
7253
7859
  import { randomBytes } from "crypto";
7254
7860
  import { readFileSync as readFileSync10 } from "fs";
@@ -9955,7 +10561,7 @@ bridgeCommand.command("cloud-model-hint").description("Suggest a cloud model for
9955
10561
  bridgeCommand.command("local-llm-hints").description("Detect installed local LLM servers and suggest defaults (JSON)").action(() => {
9956
10562
  const profile = getSystemProfile();
9957
10563
  const flmInstalled = hasCommand("flm") || existsSync17("C:\\Program Files\\flm\\flm.exe");
9958
- const ollamaInstalled = hasCommand("ollama");
10564
+ const ollamaInstalled = isOllamaInstalled();
9959
10565
  const runners = [
9960
10566
  { id: "flm", label: "FastFlowLM", installed: flmInstalled },
9961
10567
  { id: "ollama", label: "Ollama", installed: ollamaInstalled },
@@ -10535,8 +11141,22 @@ bridgeCommand.command("personal-card-confirm-foundations").description("Save con
10535
11141
  });
10536
11142
  bridgeCommand.command("personal-source-import").description(
10537
11143
  "Fetch and clean plain text from local file, web link, or vision scan (JSON)"
10538
- ).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) => {
10539
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
+ }
10540
11160
  let content = "";
10541
11161
  if (opts.type === "file") {
10542
11162
  content = await readLocalFile(opts.uri);
@@ -10559,7 +11179,8 @@ bridgeCommand.command("personal-source-import").description(
10559
11179
  jsonOut2({
10560
11180
  success: true,
10561
11181
  sourceId: record.id,
10562
- content: record.content
11182
+ content: record.content,
11183
+ cached: false
10563
11184
  });
10564
11185
  });
10565
11186
  });
@@ -10585,6 +11206,205 @@ bridgeCommand.command("personal-source-confirm-import").description(
10585
11206
  });
10586
11207
  });
10587
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
+ });
10588
11408
  bridgeCommand.command("serve").description("Start the persistent JSON-RPC stdin/stdout server").option("--stdin", "Use stdin/stdout for communication").action(async (_opts) => {
10589
11409
  isServeMode = true;
10590
11410
  const {