zam-core 0.10.4 → 0.10.5

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/app.js CHANGED
@@ -2176,6 +2176,56 @@ async function listPersonalCards(db, userId, options) {
2176
2176
  }
2177
2177
  return rows;
2178
2178
  }
2179
+ function curriculumTopicScopeClause() {
2180
+ return `(t.topic_id = ? OR t.topic_id LIKE ? || '@%')`;
2181
+ }
2182
+ function tokenMatchesCurriculumTopicScope(token, provider, topicId) {
2183
+ if (token.provider !== provider || !token.topic_id) return false;
2184
+ return token.topic_id === topicId || token.topic_id.startsWith(`${topicId}@`);
2185
+ }
2186
+ async function deleteCurriculumCardForUser(db, userId, slug, provider, topicId) {
2187
+ const token = await getTokenBySlug(db, slug);
2188
+ if (!token) return false;
2189
+ if (!tokenMatchesCurriculumTopicScope(token, provider, topicId)) {
2190
+ throw new Error(
2191
+ `Token "${slug}" is not removable for curriculum topic "${topicId}"`
2192
+ );
2193
+ }
2194
+ const card = await getCard(db, token.id, userId);
2195
+ if (!card) return false;
2196
+ await deleteCardForUser(db, token.id, userId);
2197
+ return true;
2198
+ }
2199
+ async function countUserCardsForCurriculumTopic(db, userId, provider, topicId) {
2200
+ const row = await db.prepare(
2201
+ `SELECT COUNT(*) AS count
2202
+ FROM cards c
2203
+ INNER JOIN tokens t ON t.id = c.token_id
2204
+ WHERE c.user_id = ?
2205
+ AND t.provider = ?
2206
+ AND ${curriculumTopicScopeClause()}`
2207
+ ).get(userId, provider, topicId, topicId);
2208
+ return row?.count ?? 0;
2209
+ }
2210
+ async function listUserCardsForCurriculumTopic(db, userId, provider, topicId) {
2211
+ const rows = await db.prepare(
2212
+ `SELECT
2213
+ t.slug,
2214
+ t.question,
2215
+ t.concept,
2216
+ t.domain,
2217
+ t.bloom_level AS bloomLevel,
2218
+ t.symbiosis_mode AS symbiosisMode,
2219
+ t.topic_id AS topicId
2220
+ FROM cards c
2221
+ INNER JOIN tokens t ON t.id = c.token_id
2222
+ WHERE c.user_id = ?
2223
+ AND t.provider = ?
2224
+ AND ${curriculumTopicScopeClause()}
2225
+ ORDER BY t.slug`
2226
+ ).all(userId, provider, topicId, topicId);
2227
+ return rows;
2228
+ }
2179
2229
  async function importCurriculumCards(db, userId, cards) {
2180
2230
  let createdCount = 0;
2181
2231
  let ensuredCount = 0;
@@ -2459,81 +2509,86 @@ async function confirmFoundations(db, userId, originalSlug, proposals) {
2459
2509
  });
2460
2510
  return { createdCount, linkedCount };
2461
2511
  }
2462
- async function confirmSourceImport(db, userId, sourceId, proposals) {
2512
+ async function applySourceProposals(db, userId, sourceId, proposals) {
2463
2513
  let createdCount = 0;
2464
2514
  let linkedCount = 0;
2465
- await db.transaction(async (tx) => {
2466
- for (const card of proposals) {
2467
- const cardSourceId = card.source_id || sourceId;
2468
- const source = await tx.prepare("SELECT id FROM sources WHERE id = ?").get(cardSourceId);
2469
- if (!source) {
2470
- throw new Error(`Source not found: ${cardSourceId}`);
2471
- }
2472
- const baseText = card.question && card.question.trim().length > 0 ? card.question : card.concept;
2473
- const cleanDomain = slugify(card.domain || "");
2474
- const cleanBase = slugify(baseText);
2475
- let baseSlug = cleanDomain ? `${cleanDomain}-${cleanBase}` : cleanBase;
2476
- if (baseSlug.length > 60) {
2477
- baseSlug = baseSlug.slice(0, 60).replace(/-$/, "");
2478
- }
2479
- if (!baseSlug) {
2480
- baseSlug = "token";
2515
+ for (const card of proposals) {
2516
+ const cardSourceId = card.source_id || sourceId;
2517
+ const source = await db.prepare("SELECT id FROM sources WHERE id = ?").get(cardSourceId);
2518
+ if (!source) {
2519
+ throw new Error(`Source not found: ${cardSourceId}`);
2520
+ }
2521
+ const baseText = card.question && card.question.trim().length > 0 ? card.question : card.concept;
2522
+ const cleanDomain = slugify(card.domain || "");
2523
+ const cleanBase = slugify(baseText);
2524
+ let baseSlug = cleanDomain ? `${cleanDomain}-${cleanBase}` : cleanBase;
2525
+ if (baseSlug.length > 60) {
2526
+ baseSlug = baseSlug.slice(0, 60).replace(/-$/, "");
2527
+ }
2528
+ if (!baseSlug) {
2529
+ baseSlug = "token";
2530
+ }
2531
+ let token = await getTokenBySlug(db, baseSlug);
2532
+ if (!token) {
2533
+ const finalSlug = await generateTokenSlug(
2534
+ db,
2535
+ card.domain,
2536
+ card.concept,
2537
+ card.question
2538
+ );
2539
+ const bloom = card.bloom_level !== void 0 ? card.bloom_level : 1;
2540
+ let symbiosisMode = null;
2541
+ if (card.symbiosis_mode && card.symbiosis_mode !== "none") {
2542
+ symbiosisMode = card.symbiosis_mode;
2481
2543
  }
2482
- let token = await getTokenBySlug(tx, baseSlug);
2483
- if (!token) {
2484
- const finalSlug = await generateTokenSlug(
2485
- tx,
2486
- card.domain,
2487
- card.concept,
2488
- card.question
2489
- );
2490
- const bloom = card.bloom_level !== void 0 ? card.bloom_level : 1;
2491
- let symbiosisMode = null;
2492
- if (card.symbiosis_mode && card.symbiosis_mode !== "none") {
2493
- symbiosisMode = card.symbiosis_mode;
2494
- }
2495
- token = await createToken(tx, {
2496
- slug: finalSlug,
2497
- title: card.title,
2498
- concept: card.concept,
2499
- domain: card.domain,
2500
- bloom_level: bloom,
2501
- context: card.excerpt || "",
2502
- symbiosis_mode: symbiosisMode,
2503
- question: card.question || null,
2504
- provider: card.provider || null,
2544
+ token = await createToken(db, {
2545
+ slug: finalSlug,
2546
+ title: card.title,
2547
+ concept: card.concept,
2548
+ domain: card.domain,
2549
+ bloom_level: bloom,
2550
+ context: card.excerpt || "",
2551
+ symbiosis_mode: symbiosisMode,
2552
+ question: card.question || null,
2553
+ provider: card.provider || null,
2554
+ topic_id: card.topic_id || null
2555
+ });
2556
+ createdCount++;
2557
+ } else {
2558
+ linkedCount++;
2559
+ if (!token.provider && card.provider) {
2560
+ await updateToken(db, token.slug, {
2561
+ provider: card.provider,
2505
2562
  topic_id: card.topic_id || null
2506
2563
  });
2507
- createdCount++;
2508
- } else {
2509
- linkedCount++;
2510
- if (!token.provider && card.provider) {
2511
- await updateToken(tx, token.slug, {
2512
- provider: card.provider,
2513
- topic_id: card.topic_id || null
2514
- });
2515
- }
2516
- }
2517
- const existingCard = await getCard(tx, token.id, userId);
2518
- if (!existingCard) {
2519
- await ensureCard(tx, token.id, userId);
2520
2564
  }
2521
- await tx.prepare(
2522
- `INSERT INTO token_sources (token_id, source_id, excerpt, page_number)
2523
- VALUES (?, ?, ?, ?)
2524
- ON CONFLICT(token_id, source_id) DO UPDATE SET
2525
- excerpt = excluded.excerpt,
2526
- page_number = excluded.page_number`
2527
- ).run(
2528
- token.id,
2529
- cardSourceId,
2530
- card.excerpt || "",
2531
- card.page_number || null
2532
- );
2533
2565
  }
2534
- });
2566
+ const existingCard = await getCard(db, token.id, userId);
2567
+ if (!existingCard) {
2568
+ await ensureCard(db, token.id, userId);
2569
+ }
2570
+ await db.prepare(
2571
+ `INSERT INTO token_sources (token_id, source_id, excerpt, page_number)
2572
+ VALUES (?, ?, ?, ?)
2573
+ ON CONFLICT(token_id, source_id) DO UPDATE SET
2574
+ excerpt = excluded.excerpt,
2575
+ page_number = excluded.page_number`
2576
+ ).run(
2577
+ token.id,
2578
+ cardSourceId,
2579
+ card.excerpt || "",
2580
+ card.page_number || null
2581
+ );
2582
+ }
2535
2583
  return { createdCount, linkedCount };
2536
2584
  }
2585
+ async function confirmSourceImport(db, userId, sourceId, proposals) {
2586
+ let result = { createdCount: 0, linkedCount: 0 };
2587
+ await db.transaction(async (tx) => {
2588
+ result = await applySourceProposals(tx, userId, sourceId, proposals);
2589
+ });
2590
+ return result;
2591
+ }
2537
2592
  async function assertPrerequisiteDoesNotCreateCycle(db, tokenId, prerequisiteId) {
2538
2593
  const cycleCheck = await db.prepare(
2539
2594
  `WITH RECURSIVE dependents(token_id) AS (
@@ -5903,6 +5958,17 @@ var init_i18n = __esm({
5903
5958
  import { existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
5904
5959
  import { homedir as homedir6 } from "os";
5905
5960
  import { dirname as dirname4, join as join9 } from "path";
5961
+ import { ulid as ulid9 } from "ulid";
5962
+ function emptyCapabilityFlags() {
5963
+ return {
5964
+ text: false,
5965
+ embedding: false,
5966
+ image: false,
5967
+ video: false,
5968
+ stt: false,
5969
+ tts: false
5970
+ };
5971
+ }
5906
5972
  function defaultConfigPath() {
5907
5973
  return process.env.ZAM_CONFIG_PATH || join9(homedir6(), ".zam", "config.json");
5908
5974
  }
@@ -5946,6 +6012,104 @@ function saveMachineAiConfig(ai, path = defaultConfigPath()) {
5946
6012
  config.ai = ai;
5947
6013
  saveInstallConfig(config, path);
5948
6014
  }
6015
+ function ensureMachineProviderRolesSanitized(path = defaultConfigPath()) {
6016
+ if (sanitizedMachineRolePaths.has(path)) return;
6017
+ sanitizedMachineRolePaths.add(path);
6018
+ const ai = getMachineAiConfig(path);
6019
+ if (!ai.roles?.text) return;
6020
+ const roles = { ...ai.roles };
6021
+ delete roles.text;
6022
+ saveMachineAiConfig({ ...ai, roles }, path);
6023
+ }
6024
+ function getMachineAiModels(path = defaultConfigPath()) {
6025
+ return getMachineAiConfig(path).models ?? [];
6026
+ }
6027
+ function saveMachineAiModels(models, path = defaultConfigPath()) {
6028
+ const ai = getMachineAiConfig(path);
6029
+ saveMachineAiConfig({ ...ai, models }, path);
6030
+ }
6031
+ function isAnthropicUrl(url) {
6032
+ try {
6033
+ return new URL(url).hostname.toLowerCase().endsWith("anthropic.com");
6034
+ } catch {
6035
+ return false;
6036
+ }
6037
+ }
6038
+ function isLocalUrl(url) {
6039
+ try {
6040
+ const host = new URL(url).hostname.toLowerCase();
6041
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".local");
6042
+ } catch {
6043
+ return false;
6044
+ }
6045
+ }
6046
+ function migrateMachineRolesToModels(ai) {
6047
+ const providers = ai.providers ?? {};
6048
+ const providerNames = Object.keys(providers);
6049
+ if (providerNames.length === 0) return null;
6050
+ const roles = ai.roles ?? {};
6051
+ const capsByName = /* @__PURE__ */ new Map();
6052
+ const capsFor = (name) => {
6053
+ let flags = capsByName.get(name);
6054
+ if (!flags) {
6055
+ flags = emptyCapabilityFlags();
6056
+ capsByName.set(name, flags);
6057
+ }
6058
+ return flags;
6059
+ };
6060
+ const priorityRoles = [
6061
+ "recall",
6062
+ "text",
6063
+ "vision",
6064
+ "embedding"
6065
+ ];
6066
+ for (const role of priorityRoles) {
6067
+ const binding = roles[role];
6068
+ if (!binding) continue;
6069
+ const capability = ROLE_TO_CAPABILITY[role];
6070
+ for (const ref of [binding.primary, binding.fallback]) {
6071
+ if (ref && providers[ref]) capsFor(ref)[capability] = true;
6072
+ }
6073
+ }
6074
+ const ordered = [];
6075
+ const pushName = (name) => {
6076
+ if (name && providers[name] && !ordered.includes(name)) ordered.push(name);
6077
+ };
6078
+ for (const role of priorityRoles) {
6079
+ pushName(roles[role]?.primary);
6080
+ pushName(roles[role]?.fallback);
6081
+ }
6082
+ for (const name of providerNames) pushName(name);
6083
+ return ordered.map((name, index) => {
6084
+ const rec = providers[name];
6085
+ const url = rec.url ?? "";
6086
+ const capabilities = capsByName.get(name) ?? emptyCapabilityFlags();
6087
+ const entry = {
6088
+ id: ulid9(),
6089
+ label: rec.label ?? name,
6090
+ url,
6091
+ model: rec.model ?? "",
6092
+ local: rec.local ?? isLocalUrl(url),
6093
+ apiFlavor: rec.apiFlavor ?? (isAnthropicUrl(url) ? "anthropic-messages" : "chat-completions"),
6094
+ order: index,
6095
+ capabilities,
6096
+ detectedCapabilities: { ...capabilities }
6097
+ };
6098
+ if (rec.runner) entry.runner = rec.runner;
6099
+ if (rec.apiKeyRef) entry.apiKeyRef = rec.apiKeyRef;
6100
+ return entry;
6101
+ });
6102
+ }
6103
+ function ensureMachineAiModelsMigrated(path = defaultConfigPath()) {
6104
+ const ai = getMachineAiConfig(path);
6105
+ if (ai.models && ai.models.length > 0) return ai.models;
6106
+ if (migratedModelConfigPaths.has(path)) return ai.models ?? [];
6107
+ migratedModelConfigPaths.add(path);
6108
+ const models = migrateMachineRolesToModels(ai);
6109
+ if (!models) return [];
6110
+ saveMachineAiConfig({ ...ai, models }, path);
6111
+ return models;
6112
+ }
5949
6113
  function getAgentConnectAutoDone(path = defaultConfigPath()) {
5950
6114
  return loadInstallConfig(path).agent?.connectAutoDone === true;
5951
6115
  }
@@ -6042,9 +6206,26 @@ function setActiveWorkspaceContext(contextName, path = defaultConfigPath()) {
6042
6206
  }
6043
6207
  return false;
6044
6208
  }
6209
+ var ALL_CAPABILITIES, sanitizedMachineRolePaths, ROLE_TO_CAPABILITY, migratedModelConfigPaths;
6045
6210
  var init_install_config = __esm({
6046
6211
  "src/kernel/system/install-config.ts"() {
6047
6212
  "use strict";
6213
+ ALL_CAPABILITIES = [
6214
+ "text",
6215
+ "embedding",
6216
+ "image",
6217
+ "video",
6218
+ "stt",
6219
+ "tts"
6220
+ ];
6221
+ sanitizedMachineRolePaths = /* @__PURE__ */ new Set();
6222
+ ROLE_TO_CAPABILITY = {
6223
+ recall: "text",
6224
+ text: "text",
6225
+ vision: "image",
6226
+ embedding: "embedding"
6227
+ };
6228
+ migratedModelConfigPaths = /* @__PURE__ */ new Set();
6048
6229
  }
6049
6230
  });
6050
6231
 
@@ -6560,6 +6741,7 @@ var init_update_check = __esm({
6560
6741
  // src/kernel/index.ts
6561
6742
  var kernel_exports = {};
6562
6743
  __export(kernel_exports, {
6744
+ ALL_CAPABILITIES: () => ALL_CAPABILITIES,
6563
6745
  BUILT_IN_SENSITIVE_MATCHERS: () => BUILT_IN_SENSITIVE_MATCHERS,
6564
6746
  DEFAULT_OBSERVER_POLICY: () => DEFAULT_OBSERVER_POLICY,
6565
6747
  DEFAULT_REVIEW_CONTEXT_MAX_CHARS: () => DEFAULT_REVIEW_CONTEXT_MAX_CHARS,
@@ -6575,6 +6757,7 @@ __export(kernel_exports, {
6575
6757
  analyzeObservation: () => analyzeObservation,
6576
6758
  appendUiObservationReport: () => appendUiObservationReport,
6577
6759
  applySessionSynthesis: () => applySessionSynthesis,
6760
+ applySourceProposals: () => applySourceProposals,
6578
6761
  assignTokenToContext: () => assignTokenToContext,
6579
6762
  buildAncestorMap: () => buildAncestorMap,
6580
6763
  buildReviewQueue: () => buildReviewQueue,
@@ -6590,6 +6773,7 @@ __export(kernel_exports, {
6590
6773
  confirmFoundations: () => confirmFoundations,
6591
6774
  confirmSourceImport: () => confirmSourceImport,
6592
6775
  cosineSimilarity: () => cosineSimilarity,
6776
+ countUserCardsForCurriculumTopic: () => countUserCardsForCurriculumTopic,
6593
6777
  createAgentSkill: () => createAgentSkill,
6594
6778
  createFSRS: () => createFSRS,
6595
6779
  createGoal: () => createGoal,
@@ -6600,6 +6784,7 @@ __export(kernel_exports, {
6600
6784
  decideUpdate: () => decideUpdate,
6601
6785
  decodeEmbedding: () => decodeEmbedding,
6602
6786
  deleteCardForUser: () => deleteCardForUser,
6787
+ deleteCurriculumCardForUser: () => deleteCurriculumCardForUser,
6603
6788
  deleteKnowledgeContext: () => deleteKnowledgeContext,
6604
6789
  deleteSetting: () => deleteSetting,
6605
6790
  deleteToken: () => deleteToken,
@@ -6609,9 +6794,12 @@ __export(kernel_exports, {
6609
6794
  discoverSkills: () => discoverSkills,
6610
6795
  distributeGlobalSkills: () => distributeGlobalSkills,
6611
6796
  embeddingContentForToken: () => embeddingContentForToken,
6797
+ emptyCapabilityFlags: () => emptyCapabilityFlags,
6612
6798
  encodeEmbedding: () => encodeEmbedding,
6613
6799
  endSession: () => endSession,
6614
6800
  ensureCard: () => ensureCard,
6801
+ ensureMachineAiModelsMigrated: () => ensureMachineAiModelsMigrated,
6802
+ ensureMachineProviderRolesSanitized: () => ensureMachineProviderRolesSanitized,
6615
6803
  ensureMonitorDir: () => ensureMonitorDir,
6616
6804
  ensureUiObserverDir: () => ensureUiObserverDir,
6617
6805
  evaluateRating: () => evaluateRating,
@@ -6657,6 +6845,7 @@ __export(kernel_exports, {
6657
6845
  getKnowledgeContextById: () => getKnowledgeContextById,
6658
6846
  getKnowledgeContextByName: () => getKnowledgeContextByName,
6659
6847
  getMachineAiConfig: () => getMachineAiConfig,
6848
+ getMachineAiModels: () => getMachineAiModels,
6660
6849
  getMonitorDir: () => getMonitorDir,
6661
6850
  getMonitorLogStats: () => getMonitorLogStats,
6662
6851
  getMonitorPath: () => getMonitorPath,
@@ -6700,6 +6889,7 @@ __export(kernel_exports, {
6700
6889
  listProviderApiKeyRefs: () => listProviderApiKeyRefs,
6701
6890
  listTokens: () => listTokens,
6702
6891
  listTokensNeedingEmbedding: () => listTokensNeedingEmbedding,
6892
+ listUserCardsForCurriculumTopic: () => listUserCardsForCurriculumTopic,
6703
6893
  loadADOConfig: () => loadADOConfig,
6704
6894
  loadCredentials: () => loadCredentials,
6705
6895
  loadInstallConfig: () => loadInstallConfig,
@@ -6708,6 +6898,7 @@ __export(kernel_exports, {
6708
6898
  matchBuiltInSensitive: () => matchBuiltInSensitive,
6709
6899
  matchDenylist: () => matchDenylist,
6710
6900
  matchesFilePath: () => matchesFilePath,
6901
+ migrateMachineRolesToModels: () => migrateMachineRolesToModels,
6711
6902
  monitorLogExists: () => monitorLogExists,
6712
6903
  normalizeLocale: () => normalizeLocale,
6713
6904
  normalizePath: () => normalizePath,
@@ -6739,6 +6930,7 @@ __export(kernel_exports, {
6739
6930
  saveCredentials: () => saveCredentials,
6740
6931
  saveInstallConfig: () => saveInstallConfig,
6741
6932
  saveMachineAiConfig: () => saveMachineAiConfig,
6933
+ saveMachineAiModels: () => saveMachineAiModels,
6742
6934
  searchTokensHybrid: () => searchTokensHybrid,
6743
6935
  serializeGoal: () => serializeGoal,
6744
6936
  setADOCredentials: () => setADOCredentials,
@@ -6756,6 +6948,7 @@ __export(kernel_exports, {
6756
6948
  syncObserverSidecarPolicy: () => syncObserverSidecarPolicy,
6757
6949
  t: () => t,
6758
6950
  toSidecarPrivacyPolicy: () => toSidecarPrivacyPolicy,
6951
+ tokenMatchesCurriculumTopicScope: () => tokenMatchesCurriculumTopicScope,
6759
6952
  uiObservationLogExists: () => uiObservationLogExists,
6760
6953
  uiObservationTimeSpan: () => uiObservationTimeSpan,
6761
6954
  unassignTokenFromContext: () => unassignTokenFromContext,
@@ -8087,7 +8280,7 @@ import { existsSync as existsSync19, readdirSync as readdirSync2, readFileSync a
8087
8280
  import { homedir as homedir13, tmpdir as tmpdir3 } from "os";
8088
8281
  import { join as join22, resolve as resolve5 } from "path";
8089
8282
  import { Command as Command2 } from "commander";
8090
- import { ulid as ulid9 } from "ulid";
8283
+ import { ulid as ulid10 } from "ulid";
8091
8284
 
8092
8285
  // src/cli/adapters/source-reader.ts
8093
8286
  import dns from "dns";
@@ -8099,6 +8292,8 @@ import { spawn as spawn2 } from "child_process";
8099
8292
  import { existsSync as existsSync16, readFileSync as readFileSync12, statSync as statSync2 } from "fs";
8100
8293
  var DEFAULT_LLM_URL = "http://localhost:8000/v1";
8101
8294
  var DEFAULT_LLM_MAX_TOKENS = 1e4;
8295
+ var LOCAL_CURRICULUM_IMPORT_HARD_TIMEOUT_MS = 6e5;
8296
+ var CLOUD_CURRICULUM_IMPORT_HARD_TIMEOUT_MS = 18e4;
8102
8297
  var RECALL_QUESTION_MAX_OUTPUT_TOKENS = 400;
8103
8298
  var RECALL_EVALUATION_MAX_OUTPUT_TOKENS = 1200;
8104
8299
  var RECALL_DISCUSSION_MAX_OUTPUT_TOKENS = 1200;
@@ -8175,7 +8370,58 @@ function resolveProviderApiKey(rec) {
8175
8370
  }
8176
8371
  return DEFAULT_LLM_API_KEY;
8177
8372
  }
8373
+ var ROLE_TO_CAPABILITY2 = {
8374
+ recall: "text",
8375
+ text: "text",
8376
+ vision: "image",
8377
+ embedding: "embedding"
8378
+ };
8379
+ function materializeModelEntry(entry, base, enabled, maxFrames) {
8380
+ const url = entry.url || base.url;
8381
+ const cfg = {
8382
+ enabled,
8383
+ url,
8384
+ model: entry.model || base.model,
8385
+ apiKey: entry.apiKeyRef ? getProviderApiKey(entry.apiKeyRef) ?? DEFAULT_LLM_API_KEY : DEFAULT_LLM_API_KEY,
8386
+ apiFlavor: entry.apiFlavor || inferApiFlavor(url),
8387
+ locale: base.locale,
8388
+ providerName: entry.id,
8389
+ label: entry.label,
8390
+ source: "machine",
8391
+ local: entry.local
8392
+ };
8393
+ if (entry.runner) cfg.runner = entry.runner;
8394
+ if (maxFrames !== void 0) cfg.maxFrames = maxFrames;
8395
+ return cfg;
8396
+ }
8397
+ async function resolveCapability(db, capability) {
8398
+ const models = getMachineAiModels();
8399
+ if (models.length === 0) return null;
8400
+ const isVisual = capability === "image" || capability === "video";
8401
+ const enabled = (isVisual ? await getSetting(db, "llm.vision.enabled") : await getSetting(db, "llm.enabled")) === "true";
8402
+ const base = await getLlmConfig(db);
8403
+ let maxFrames;
8404
+ if (isVisual) {
8405
+ const raw = await getSetting(db, "llm.vision.max_frames");
8406
+ const parsed = raw ? parseInt(raw, 10) : 100;
8407
+ maxFrames = Number.isNaN(parsed) ? 100 : parsed;
8408
+ }
8409
+ const eligible = [...models].sort((a, b) => a.order - b.order).filter(
8410
+ (entry) => entry.capabilities[capability] && entry.detectedCapabilities[capability]
8411
+ );
8412
+ if (eligible.length === 0) return null;
8413
+ const configs = eligible.map(
8414
+ (entry) => materializeModelEntry(entry, base, enabled, maxFrames)
8415
+ );
8416
+ for (let i = configs.length - 1; i > 0; i--) {
8417
+ configs[i - 1] = { ...configs[i - 1], fallback: configs[i] };
8418
+ }
8419
+ return configs[0];
8420
+ }
8178
8421
  async function getProviderForRole(db, role) {
8422
+ ensureMachineProviderRolesSanitized();
8423
+ const viaRegistry = await resolveCapability(db, ROLE_TO_CAPABILITY2[role]);
8424
+ if (viaRegistry) return viaRegistry;
8179
8425
  const enabled = role === "vision" ? await getSetting(db, "llm.vision.enabled") === "true" : await getSetting(db, "llm.enabled") === "true";
8180
8426
  const base = await getLegacyRoleConfig(db, role, enabled);
8181
8427
  const providers = await readJsonSetting(db, "llm.providers");
@@ -8216,6 +8462,9 @@ async function getProviderForRole(db, role) {
8216
8462
  ) : void 0;
8217
8463
  return { ...primary, fallback };
8218
8464
  }
8465
+ if (role === "text") {
8466
+ return getProviderForRole(db, "recall");
8467
+ }
8219
8468
  return resolved;
8220
8469
  }
8221
8470
  function materializeProvider(rec, base, role, meta) {
@@ -8656,6 +8905,7 @@ Target Category: ${targetCategory}
8656
8905
  ${sourceUrl ? `Source Reference Link: ${sourceUrl}` : ""}
8657
8906
 
8658
8907
  JSON Array Output:`;
8908
+ const hardTimeoutMs = isLocalEndpoint(endpoint.url) ? LOCAL_CURRICULUM_IMPORT_HARD_TIMEOUT_MS : CLOUD_CURRICULUM_IMPORT_HARD_TIMEOUT_MS;
8659
8909
  const res = await fetchWithInteractiveTimeout(
8660
8910
  `${endpoint.url}/chat/completions`,
8661
8911
  {
@@ -8673,7 +8923,9 @@ JSON Array Output:`;
8673
8923
  temperature: 0.1,
8674
8924
  max_tokens: DEFAULT_LLM_MAX_TOKENS
8675
8925
  }),
8676
- locale
8926
+ locale,
8927
+ hardTimeoutMs,
8928
+ timeoutMs: hardTimeoutMs
8677
8929
  }
8678
8930
  );
8679
8931
  return readChatContent(res, "LLM curriculum import");
@@ -9069,7 +9321,11 @@ async function resolveUsableTextEndpoint(db) {
9069
9321
  const chain = await checkProviderChain(cfg);
9070
9322
  const selected = chain.firstUsable;
9071
9323
  if (!selected || !isEndpointUsable(selected)) {
9072
- throw new Error("No text LLM endpoint is online");
9324
+ const primary = chain.primary.endpoint;
9325
+ const reason = !selected ? chain.primary.online ? "configured model is unavailable" : "endpoint is offline" : "endpoint is not usable";
9326
+ throw new Error(
9327
+ `No text LLM endpoint is online (${reason}; role: text; url: ${primary.url}; model: ${primary.model})`
9328
+ );
9073
9329
  }
9074
9330
  return selected.endpoint;
9075
9331
  }
@@ -12924,7 +13180,7 @@ function normalizeForComparison11(str) {
12924
13180
  // src/cli/curriculum/providers/lehrplanplus-bayern/manifest.ts
12925
13181
  var LEHRPLANPLUS_BAYERN_MANIFEST = {
12926
13182
  schoolYear: "2026/2027",
12927
- capturedOn: "2026-07-02",
13183
+ capturedOn: "2026-07-12",
12928
13184
  sourceRevision: "LehrplanPLUS Realschule \u2013 Oktober 2023",
12929
13185
  schoolTypes: [
12930
13186
  { id: "grundschule", label: "Grundschule" },
@@ -12981,12 +13237,242 @@ var LEHRPLANPLUS_BAYERN_MANIFEST = {
12981
13237
  ]
12982
13238
  },
12983
13239
  tracks: {
13240
+ "realschule|5|sport": [
13241
+ { id: "basis_sport", label: "Basissport 5" },
13242
+ { id: "diff_sport", label: "Differenzierter Sport" }
13243
+ ],
12984
13244
  "realschule|9|mathematik": [
12985
13245
  { id: "wpfg1", label: "Mathematik 9 (I)" },
12986
13246
  { id: "wpfg2-3", label: "Mathematik 9 (II/III)" }
12987
13247
  ]
12988
13248
  },
12989
13249
  topics: {
13250
+ "realschule|5|biologie": [
13251
+ { id: "lb1", label: "Prozessbezogene Kompetenzen" },
13252
+ {
13253
+ id: "lb2",
13254
+ label: "Biologie, die Wissenschaft von den Lebewesen",
13255
+ hours: 14
13256
+ },
13257
+ {
13258
+ id: "lb3",
13259
+ label: "Bau und Funktion des menschlichen K\xF6rpers",
13260
+ hours: 22
13261
+ },
13262
+ {
13263
+ id: "lb4",
13264
+ label: "Tiere und Pflanzen in der Umgebung des Menschen",
13265
+ hours: 20
13266
+ }
13267
+ ],
13268
+ "realschule|5|deutsch": [
13269
+ { id: "lb1", label: "Sprechen und Zuh\xF6ren" },
13270
+ { id: "lb2", label: "Lesen \u2013 mit Texten und weiteren Medien umgehen" },
13271
+ { id: "lb3", label: "Schreiben" },
13272
+ {
13273
+ id: "lb4",
13274
+ label: "Sprachgebrauch und Sprache untersuchen und reflektieren"
13275
+ }
13276
+ ],
13277
+ "realschule|5|englisch": [
13278
+ { id: "lb1", label: "Kommunikative Kompetenzen" },
13279
+ { id: "lb2", label: "Interkulturelle Kompetenzen" },
13280
+ { id: "lb3", label: "Text- und Medienkompetenzen" },
13281
+ { id: "lb4", label: "Methodische Kompetenzen" },
13282
+ { id: "lb5", label: "Themengebiete" }
13283
+ ],
13284
+ "realschule|5|ethik": [
13285
+ { id: "lb1", label: "Meine Wirklichkeit und ich", hours: 16 },
13286
+ { id: "lb2", label: "Leben in der Familie", hours: 12 },
13287
+ { id: "lb3", label: "Spielen", hours: 12 },
13288
+ {
13289
+ id: "lb4",
13290
+ label: "Feste und Riten in Religion und Brauchtum",
13291
+ hours: 16
13292
+ }
13293
+ ],
13294
+ "realschule|5|evangelische-religionslehre": [
13295
+ { id: "lb1", label: "Leben in Gemeinschaft" },
13296
+ { id: "lb2", label: "Die Bibel \u2013 Buch des Lebens" },
13297
+ {
13298
+ id: "lb3",
13299
+ label: "Erfahrungen mit Gott als Begleiter auf dem Lebensweg"
13300
+ },
13301
+ { id: "lb4", label: "Glaube wird sichtbar und hinterl\xE4sst Spuren" },
13302
+ {
13303
+ id: "lb5",
13304
+ label: "Sch\xF6pfung \u2013 Unsere Welt und unser Leben als Geschenk Gottes?"
13305
+ }
13306
+ ],
13307
+ "realschule|5|geographie": [
13308
+ { id: "lb1", label: "Einf\xFChrung in das Fach", hours: 8 },
13309
+ { id: "lb2", label: "Planet Erde", hours: 8 },
13310
+ { id: "lb3", label: "Gestalt und Gliederung der Erde", hours: 10 },
13311
+ { id: "lb4", label: "Ver\xE4nderung der Erdoberfl\xE4che", hours: 10 },
13312
+ {
13313
+ id: "lb5",
13314
+ label: "Naturr\xE4umliche und politische Strukturen in Deutschland und Bayern",
13315
+ hours: 8
13316
+ },
13317
+ { id: "lb6", label: "Anwendung im Nahraum", hours: 8 },
13318
+ { id: "lb7", label: "Aktuelle geographische Fragestellung", hours: 4 }
13319
+ ],
13320
+ "realschule|5|it": [
13321
+ { id: "lb1", label: "Anfangsunterricht" },
13322
+ { id: "lb2", label: "Aufbauunterricht" },
13323
+ { id: "lb3", label: "Bilingualer Sachfachunterricht (optional)" }
13324
+ ],
13325
+ "realschule|5|iu": [
13326
+ { id: "lb1", label: "Miteinander leben \u2013 Eigene Aufgaben wahrnehmen" },
13327
+ {
13328
+ id: "lb2",
13329
+ label: "Religi\xF6ses Leben \u2013 Ausdrucksformen des Islams kennen"
13330
+ },
13331
+ {
13332
+ id: "lb3",
13333
+ label: "Glaubenslehre des Islams \u2013 Gottesvorstellungen reflektieren"
13334
+ },
13335
+ {
13336
+ id: "lb4",
13337
+ label: "Propheten \u2013 Gottes Offenbarungen beschreiben"
13338
+ },
13339
+ {
13340
+ id: "lb5",
13341
+ label: "Muhammads Leben und Wirken \u2013 Eigenschaften von Vorbildern reflektieren"
13342
+ },
13343
+ {
13344
+ id: "lb6",
13345
+ label: "Koran und Schrifttradition \u2013 Fachsprache entwickeln"
13346
+ },
13347
+ {
13348
+ id: "lb7",
13349
+ label: "Geschichte und Geographie des Islams \u2013 Historische Kontexte erl\xE4utern"
13350
+ },
13351
+ {
13352
+ id: "lb8",
13353
+ label: "Religionen und Weltanschauungen \u2013 Gemeinsamkeiten und Unterschiede vergleichen"
13354
+ }
13355
+ ],
13356
+ "realschule|5|ir": [
13357
+ {
13358
+ id: "lb1",
13359
+ label: "J\xFCdischer Kalender und Jahreszyklus: Grundlagen des j\xFCdischen Kalenders, Feiertage im Jahreskreis, Chanukka und Purim",
13360
+ hours: 12
13361
+ },
13362
+ {
13363
+ id: "lb2",
13364
+ label: "Gebet und Ritus: Schma und Amida, die beiden Hauptgebete",
13365
+ hours: 10
13366
+ },
13367
+ {
13368
+ id: "lb3",
13369
+ label: "Mensch und Welt: Vertrauend einen neuen Anfang wagen",
13370
+ hours: 10
13371
+ },
13372
+ {
13373
+ id: "lb4",
13374
+ label: "J\xFCdische Geschichte und Philosophie: Biblische Zeit und Monotheismus",
13375
+ hours: 12
13376
+ },
13377
+ {
13378
+ id: "lb5",
13379
+ label: "Schriftliche Quellen \u2013 Werte: Tora und Anawa (Bescheidenheit)",
13380
+ hours: 12
13381
+ }
13382
+ ],
13383
+ "realschule|5|katholische-religionslehre": [
13384
+ {
13385
+ id: "lb1",
13386
+ label: "Auf Gott vertrauen \u2013 einen neuen Anfang wagen",
13387
+ hours: 10
13388
+ },
13389
+ {
13390
+ id: "lb2",
13391
+ label: "\u201EUmsorge mich mit deiner Liebe\u201C \u2013 beten und meditieren",
13392
+ hours: 10
13393
+ },
13394
+ {
13395
+ id: "lb3",
13396
+ label: "Erfahrungen mit Gott \u2013 Die Heilige Schrift",
13397
+ hours: 12
13398
+ },
13399
+ {
13400
+ id: "lb4",
13401
+ label: '\u201EIn jenen Tagen trat einer auf" \u2013 Jesus im Blickwinkel seiner Zeit und Umwelt',
13402
+ hours: 12
13403
+ },
13404
+ {
13405
+ id: "lb5",
13406
+ label: "Leben in der Pfarrgemeinde \u2013 Eingebundensein in die Kirche",
13407
+ hours: 12
13408
+ }
13409
+ ],
13410
+ "realschule|5|kunst": [
13411
+ {
13412
+ id: "lb1",
13413
+ label: "Bildnerische Auseinandersetzung mit Wirklichkeit und Fantasie",
13414
+ hours: 44
13415
+ },
13416
+ { id: "lb2", label: "Bildende Kunst", hours: 20 },
13417
+ { id: "lb3", label: "Angewandte Kunst", hours: 20 }
13418
+ ],
13419
+ "realschule|5|mathematik": [
13420
+ { id: "lb1", label: "Nat\xFCrliche Zahlen", hours: 50 },
13421
+ { id: "lb2", label: "Ganze Zahlen", hours: 20 },
13422
+ {
13423
+ id: "lb3",
13424
+ label: "Geometrische Grundvorstellungen und Grundbegriffe",
13425
+ hours: 30
13426
+ },
13427
+ { id: "lb4", label: "Gr\xF6\xDFen", hours: 20 },
13428
+ {
13429
+ id: "lb5",
13430
+ label: "Umfang und Fl\xE4cheninhalt ebener Figuren",
13431
+ hours: 15
13432
+ },
13433
+ { id: "lb6", label: "Auswertung von Daten", hours: 5 }
13434
+ ],
13435
+ "realschule|5|musik": [
13436
+ { id: "lb1", label: "Sprechen \u2013 Singen \u2013 Musizieren", hours: 20 },
13437
+ { id: "lb2", label: "Musik \u2013 Mensch \u2013 Zeit", hours: 10 },
13438
+ { id: "lb3", label: "Bewegung \u2013 Tanz \u2013 Szene", hours: 10 },
13439
+ { id: "lb4", label: "Musik und ihre Grundlagen", hours: 16 }
13440
+ ],
13441
+ "realschule|5|or": [
13442
+ { id: "lb1", label: "Miteinander leben", hours: 10 },
13443
+ { id: "lb2", label: "Von Gott und zu Gott sprechen", hours: 10 },
13444
+ { id: "lb3", label: "Die Bibel", hours: 12 },
13445
+ { id: "lb4", label: "Ursprung der Kirche", hours: 12 },
13446
+ { id: "lb5", label: "Kirche vor Ort", hours: 12 }
13447
+ ],
13448
+ "realschule|5|sport|basis_sport": [
13449
+ { id: "lb1", label: "Gesundheit und Fitness" },
13450
+ { id: "lb2", label: "Fairness/Kooperation/Selbstkompetenz" },
13451
+ { id: "lb3", label: "Freizeit und Umwelt" },
13452
+ { id: "lb4", label: "Sportliche Handlungsfelder" }
13453
+ ],
13454
+ "realschule|5|sport|diff_sport": [
13455
+ { id: "lb1", label: "Bewegungsk\xFCnste" },
13456
+ { id: "lb2", label: "Radsport" },
13457
+ { id: "lb3", label: "Rhythmische Sportgymnastik" },
13458
+ { id: "lb4", label: "Sportklettern" }
13459
+ ],
13460
+ "realschule|5|textiles-gestalten": [
13461
+ { id: "lb1", label: "Eine textile Fl\xE4che bilden \u2013 Filzen", hours: 18 },
13462
+ { id: "lb2", label: "Eine textile Fl\xE4che bilden \u2013 H\xE4keln", hours: 18 },
13463
+ { id: "lb3", label: "Eine textile Fl\xE4che bilden \u2013 Weben", hours: 24 },
13464
+ { id: "lb4", label: "Eine textile Fl\xE4che bilden \u2013 Kn\xFCpfen", hours: 24 },
13465
+ {
13466
+ id: "lb5",
13467
+ label: "Eine textile Fl\xE4che verarbeiten \u2013 Handn\xE4hen, Maschinenn\xE4hen",
13468
+ hours: 42
13469
+ }
13470
+ ],
13471
+ "realschule|5|werken": [
13472
+ { id: "lb1", label: "Arbeiten mit dem Werkstoff Holz", hours: 28 },
13473
+ { id: "lb2", label: "Arbeiten mit Papierwerkstoffen", hours: 28 },
13474
+ { id: "lb3", label: "Arbeiten mit plastischen Massen", hours: 28 }
13475
+ ],
12990
13476
  "realschule|9|mathematik|wpfg1": [
12991
13477
  { id: "lb1", label: "Reelle Zahlen", hours: 10 },
12992
13478
  { id: "lb2", label: "Zentrische Streckung", hours: 17 },
@@ -13028,6 +13514,24 @@ var LEHRPLANPLUS_BAYERN_MANIFEST = {
13028
13514
  ]
13029
13515
  },
13030
13516
  contentUrls: {
13517
+ "realschule|5|biologie": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/biologie/inhalt/fachlehrplaene",
13518
+ "realschule|5|deutsch": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/deutsch/inhalt/fachlehrplaene",
13519
+ "realschule|5|englisch": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/englisch/inhalt/fachlehrplaene",
13520
+ "realschule|5|ethik": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/ethik/inhalt/fachlehrplaene",
13521
+ "realschule|5|evangelische-religionslehre": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/evangelische-religionslehre/inhalt/fachlehrplaene",
13522
+ "realschule|5|geographie": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/geographie/inhalt/fachlehrplaene",
13523
+ "realschule|5|it": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/it/inhalt/fachlehrplaene",
13524
+ "realschule|5|iu": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/iu/inhalt/fachlehrplaene",
13525
+ "realschule|5|ir": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/ir/inhalt/fachlehrplaene",
13526
+ "realschule|5|katholische-religionslehre": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/katholische-religionslehre/inhalt/fachlehrplaene",
13527
+ "realschule|5|kunst": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/kunst/inhalt/fachlehrplaene",
13528
+ "realschule|5|mathematik": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/mathematik/inhalt/fachlehrplaene",
13529
+ "realschule|5|musik": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/musik/inhalt/fachlehrplaene",
13530
+ "realschule|5|or": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/or/inhalt/fachlehrplaene",
13531
+ "realschule|5|sport|basis_sport": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/sport/inhalt/fachlehrplaene?w_schulart=realschule&wt_1=schulart&w_fach=sport&wt_2=fach&w_jgs=5&wt_3=jgs&w_auspraegung=basis_sport",
13532
+ "realschule|5|sport|diff_sport": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/sport/inhalt/fachlehrplaene?w_schulart=realschule&wt_1=schulart&w_fach=sport&wt_2=fach&w_jgs=5&wt_3=jgs&w_auspraegung=diff_sport",
13533
+ "realschule|5|textiles-gestalten": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/textiles-gestalten/inhalt/fachlehrplaene",
13534
+ "realschule|5|werken": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/5/fach/werken/inhalt/fachlehrplaene",
13031
13535
  "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",
13032
13536
  "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",
13033
13537
  "realschule|9|deutsch": "https://www.lehrplanplus.bayern.de/schulart/realschule/jgs/9/fach/deutsch/inhalt/fachlehrplaene",
@@ -13039,6 +13543,95 @@ var LEHRPLANPLUS_BAYERN_MANIFEST = {
13039
13543
  function levelKey(schoolType, grade, subject, track) {
13040
13544
  return track ? `${schoolType}|${grade}|${subject}|${track}` : `${schoolType}|${grade}|${subject}`;
13041
13545
  }
13546
+ function parseHtmlSections(html) {
13547
+ const chunks = html.split('<div id="thema_');
13548
+ const sections = [];
13549
+ for (let i = 1; i < chunks.length; i++) {
13550
+ const chunk = chunks[i];
13551
+ const quoteIdx = chunk.indexOf('"');
13552
+ if (quoteIdx === -1) continue;
13553
+ const id = chunk.slice(0, quoteIdx);
13554
+ const classStart = chunk.indexOf('class="');
13555
+ if (classStart === -1) continue;
13556
+ const classEnd = chunk.indexOf('"', classStart + 7);
13557
+ const classContent = chunk.slice(classStart + 7, classEnd);
13558
+ const lvlMatch = classContent.match(/headline_lvl(\d+)/);
13559
+ if (!lvlMatch) continue;
13560
+ const level = parseInt(lvlMatch[1], 10);
13561
+ const contentHtml = chunk.slice(classEnd + 1);
13562
+ const headerMatch = contentHtml.match(
13563
+ /<a[^>]*class="paragraph_toggle"[^>]*>([\s\S]*?)<\/a>/i
13564
+ );
13565
+ const headerText = headerMatch ? cleanHtmlText12(headerMatch[1]).trim() : "";
13566
+ sections.push({
13567
+ id,
13568
+ level,
13569
+ headerText,
13570
+ contentHtml
13571
+ });
13572
+ }
13573
+ return sections;
13574
+ }
13575
+ function resolveTopicLabel(topicId) {
13576
+ for (const key of Object.keys(LEHRPLANPLUS_BAYERN_MANIFEST.topics)) {
13577
+ const list = LEHRPLANPLUS_BAYERN_MANIFEST.topics[key];
13578
+ const match = list.find(
13579
+ (t2) => t2.id === topicId || `${key}#${t2.id}` === topicId
13580
+ );
13581
+ if (match) {
13582
+ return match.label;
13583
+ }
13584
+ }
13585
+ return null;
13586
+ }
13587
+ function findTopicSectionHtml(sections, topicId) {
13588
+ const label = resolveTopicLabel(topicId);
13589
+ if (!label) return null;
13590
+ const normalizedLabel = normalizeForComparison12(label);
13591
+ const lvl1Index = sections.findIndex((s) => {
13592
+ if (s.level !== 1) return false;
13593
+ const normalizedHeader = normalizeForComparison12(s.headerText);
13594
+ return normalizedHeader.includes(normalizedLabel);
13595
+ });
13596
+ if (lvl1Index === -1) return null;
13597
+ const collectedSections = [sections[lvl1Index]];
13598
+ for (let i = lvl1Index + 1; i < sections.length; i++) {
13599
+ if (sections[i].level === 1) {
13600
+ break;
13601
+ }
13602
+ collectedSections.push(sections[i]);
13603
+ }
13604
+ return collectedSections.map((s) => s.contentHtml).join("\n");
13605
+ }
13606
+ function isExcludedCompetenceItem(liAttrs, text) {
13607
+ if (/plus_servicematerialien|plus_ueberg_ziele/i.test(liAttrs)) {
13608
+ return true;
13609
+ }
13610
+ const normalized = text.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").trim();
13611
+ if (/^\+?\s*servicematerialien/.test(normalized)) return true;
13612
+ if (/^\+?\s*uebergreifende\s+ziele/.test(normalized)) return true;
13613
+ return false;
13614
+ }
13615
+ function extractCompetenceItems(topicHtml) {
13616
+ const items = [];
13617
+ const abschBlocks = topicHtml.match(/<div class="thema_absch">[\s\S]*?<\/div>/gi) ?? [];
13618
+ for (const block of abschBlocks) {
13619
+ const liRegex = /<li([^>]*)>([\s\S]*?)<\/li>/gi;
13620
+ let match = liRegex.exec(block);
13621
+ while (match !== null) {
13622
+ const text = cleanHtmlText12(match[2]).trim();
13623
+ if (text.length > 0 && !isExcludedCompetenceItem(match[1], text)) {
13624
+ items.push(text);
13625
+ }
13626
+ match = liRegex.exec(block);
13627
+ }
13628
+ }
13629
+ return items;
13630
+ }
13631
+ function truncateLabel(text, maxLen = 72) {
13632
+ if (text.length <= maxLen) return text;
13633
+ return `${text.slice(0, maxLen - 1).trim()}\u2026`;
13634
+ }
13042
13635
  var lehrplanplusBayernProvider = {
13043
13636
  id: "lehrplanplus-bayern",
13044
13637
  country: "DE",
@@ -13084,68 +13677,42 @@ var lehrplanplusBayernProvider = {
13084
13677
  };
13085
13678
  },
13086
13679
  extractTopics(html, topicIds) {
13087
- const chunks = html.split('<div id="thema_');
13088
- const sections = [];
13089
- for (let i = 1; i < chunks.length; i++) {
13090
- const chunk = chunks[i];
13091
- const quoteIdx = chunk.indexOf('"');
13092
- if (quoteIdx === -1) continue;
13093
- const id = chunk.slice(0, quoteIdx);
13094
- const classStart = chunk.indexOf('class="');
13095
- if (classStart === -1) continue;
13096
- const classEnd = chunk.indexOf('"', classStart + 7);
13097
- const classContent = chunk.slice(classStart + 7, classEnd);
13098
- const lvlMatch = classContent.match(/headline_lvl(\d+)/);
13099
- if (!lvlMatch) continue;
13100
- const level = parseInt(lvlMatch[1], 10);
13101
- const contentHtml = chunk.slice(classEnd + 1);
13102
- const headerMatch = contentHtml.match(
13103
- /<a[^>]*class="paragraph_toggle"[^>]*>([\s\S]*?)<\/a>/i
13104
- );
13105
- const headerText = headerMatch ? cleanHtmlText12(headerMatch[1]).trim() : "";
13106
- sections.push({
13107
- id,
13108
- level,
13109
- headerText,
13110
- contentHtml
13111
- });
13112
- }
13680
+ const sections = parseHtmlSections(html);
13113
13681
  const results = {};
13114
13682
  for (const topicId of topicIds) {
13115
- let label = "";
13116
- for (const key of Object.keys(LEHRPLANPLUS_BAYERN_MANIFEST.topics)) {
13117
- const list = LEHRPLANPLUS_BAYERN_MANIFEST.topics[key];
13118
- const match = list.find(
13119
- (t2) => t2.id === topicId || `${key}#${t2.id}` === topicId
13120
- );
13121
- if (match) {
13122
- label = match.label;
13123
- break;
13124
- }
13125
- }
13126
- if (!label) {
13127
- continue;
13128
- }
13129
- const normalizedLabel = normalizeForComparison12(label);
13130
- const lvl1Index = sections.findIndex((s) => {
13131
- if (s.level !== 1) return false;
13132
- const normalizedHeader = normalizeForComparison12(s.headerText);
13133
- return normalizedHeader.includes(normalizedLabel);
13134
- });
13135
- if (lvl1Index === -1) {
13136
- continue;
13683
+ const topicHtml = findTopicSectionHtml(sections, topicId);
13684
+ if (topicHtml) {
13685
+ results[topicId] = cleanHtmlText12(topicHtml);
13137
13686
  }
13138
- const collectedSections = [sections[lvl1Index]];
13139
- for (let i = lvl1Index + 1; i < sections.length; i++) {
13140
- if (sections[i].level === 1) {
13141
- break;
13142
- }
13143
- collectedSections.push(sections[i]);
13144
- }
13145
- const fullHtml = collectedSections.map((s) => s.contentHtml).join("\n");
13146
- results[topicId] = cleanHtmlText12(fullHtml);
13147
13687
  }
13148
13688
  return results;
13689
+ },
13690
+ extractSubTopics(html, topicId) {
13691
+ const sections = parseHtmlSections(html);
13692
+ const topicHtml = findTopicSectionHtml(sections, topicId);
13693
+ if (!topicHtml) return [];
13694
+ const header = sections.find((s) => {
13695
+ if (s.level !== 1) return false;
13696
+ const label = resolveTopicLabel(topicId);
13697
+ if (!label) return false;
13698
+ return normalizeForComparison12(s.headerText).includes(
13699
+ normalizeForComparison12(label)
13700
+ );
13701
+ });
13702
+ const headerText = header?.headerText ?? resolveTopicLabel(topicId) ?? "";
13703
+ const competenceItems = extractCompetenceItems(topicHtml);
13704
+ if (competenceItems.length === 0) return [];
13705
+ return competenceItems.map((item, index) => {
13706
+ const text = `${headerText}
13707
+
13708
+ ${item}`;
13709
+ return {
13710
+ id: `ku${index + 1}`,
13711
+ label: truncateLabel(item),
13712
+ textLength: text.length,
13713
+ text
13714
+ };
13715
+ });
13149
13716
  }
13150
13717
  };
13151
13718
  function cleanHtmlText12(html) {
@@ -13684,6 +14251,127 @@ function getCurriculumProvider(id) {
13684
14251
  return CURRICULUM_PROVIDERS.find((provider) => provider.id === id);
13685
14252
  }
13686
14253
 
14254
+ // src/cli/llm/capability-probe.ts
14255
+ init_kernel();
14256
+ var EMBEDDING_MODEL_HINTS = [
14257
+ "embed",
14258
+ "text-embedding",
14259
+ "bge-",
14260
+ "gte-",
14261
+ "nomic",
14262
+ "mxbai"
14263
+ ];
14264
+ var VISION_MODEL_HINTS = [
14265
+ "vision",
14266
+ "-vl",
14267
+ "vl-",
14268
+ "vlm",
14269
+ "llava",
14270
+ "gpt-4o",
14271
+ "gpt-4.1",
14272
+ "gpt-5",
14273
+ "gemini",
14274
+ "pixtral",
14275
+ "minicpm-v",
14276
+ "internvl",
14277
+ "moondream",
14278
+ "llama-3.2",
14279
+ "llama3.2",
14280
+ // Xiaomi MiMo(-VL) is multimodal; the plain "mimo-v*" tag carries no "-vl".
14281
+ "mimo"
14282
+ ];
14283
+ function matchesAny(id, hints) {
14284
+ const lower = id.toLowerCase();
14285
+ return hints.some((hint) => lower.includes(hint));
14286
+ }
14287
+ function catalogHasModel(catalog, model) {
14288
+ const lower = model.toLowerCase();
14289
+ return catalog.some((id) => id.toLowerCase() === lower);
14290
+ }
14291
+ function classifyCapabilities(entry, catalog, catalogKnown, dimProbeEmbedding = false) {
14292
+ const detected = emptyCapabilityFlags();
14293
+ if (entry.apiFlavor === "anthropic-messages") {
14294
+ detected.text = true;
14295
+ detected.image = true;
14296
+ return detected;
14297
+ }
14298
+ const looksEmbedding = matchesAny(entry.model, EMBEDDING_MODEL_HINTS);
14299
+ const looksVision = matchesAny(entry.model, VISION_MODEL_HINTS);
14300
+ const inCatalog = catalogHasModel(catalog, entry.model);
14301
+ detected.embedding = looksEmbedding || dimProbeEmbedding;
14302
+ detected.image = looksVision;
14303
+ detected.text = !detected.embedding && (inCatalog || !catalogKnown);
14304
+ return detected;
14305
+ }
14306
+ function resolveApiKey(apiKeyRef) {
14307
+ if (!apiKeyRef) return DEFAULT_LLM_API_KEY;
14308
+ return getProviderApiKey(apiKeyRef) ?? DEFAULT_LLM_API_KEY;
14309
+ }
14310
+ async function probeModelCapabilities(entry, opts = {}) {
14311
+ const apiKey = resolveApiKey(entry.apiKeyRef);
14312
+ if (entry.apiFlavor === "anthropic-messages") {
14313
+ const online2 = await isLlmOnline(entry.url);
14314
+ return {
14315
+ reachable: online2,
14316
+ catalog: [],
14317
+ detected: classifyCapabilities(entry, [], false)
14318
+ };
14319
+ }
14320
+ const online = await isLlmOnline(entry.url);
14321
+ if (!online) {
14322
+ return { reachable: false, catalog: [], detected: emptyCapabilityFlags() };
14323
+ }
14324
+ const catalog = await getAvailableModels(entry.url, apiKey);
14325
+ const catalogKnown = catalog.length > 0;
14326
+ let dimProbeEmbedding = false;
14327
+ const looksEmbedding = matchesAny(entry.model, EMBEDDING_MODEL_HINTS);
14328
+ if (opts.embeddingDimProbe && !catalogKnown && !looksEmbedding) {
14329
+ try {
14330
+ const [vector] = await embedTexts(
14331
+ { url: entry.url, model: entry.model, apiKey },
14332
+ ["capability probe"]
14333
+ );
14334
+ dimProbeEmbedding = Array.isArray(vector) && vector.length > 0;
14335
+ } catch {
14336
+ dimProbeEmbedding = false;
14337
+ }
14338
+ }
14339
+ return {
14340
+ reachable: true,
14341
+ catalog,
14342
+ detected: classifyCapabilities(
14343
+ entry,
14344
+ catalog,
14345
+ catalogKnown,
14346
+ dimProbeEmbedding
14347
+ )
14348
+ };
14349
+ }
14350
+ function reconcileCapabilities(userSelected, detected) {
14351
+ const result = emptyCapabilityFlags();
14352
+ for (const key of Object.keys(result)) {
14353
+ result[key] = userSelected[key] && detected[key];
14354
+ }
14355
+ return result;
14356
+ }
14357
+ function validateModelSave(entry, probe, now = () => (/* @__PURE__ */ new Date()).toISOString()) {
14358
+ if (!probe.reachable) {
14359
+ return {
14360
+ ok: false,
14361
+ error: "Endpoint is unreachable \u2014 cannot verify capabilities. Bring it online and retry."
14362
+ };
14363
+ }
14364
+ return {
14365
+ ok: true,
14366
+ entry: {
14367
+ ...entry,
14368
+ capabilities: reconcileCapabilities(entry.capabilities, probe.detected),
14369
+ detectedCapabilities: probe.detected,
14370
+ probedAt: now()
14371
+ }
14372
+ };
14373
+ }
14374
+
13687
14375
  // src/cli/llm/vision.ts
13688
14376
  init_kernel();
13689
14377
  import { randomBytes } from "crypto";
@@ -14157,6 +14845,12 @@ function bindRoleProviders(roles, role, primary, fallback) {
14157
14845
  if (fallback) binding.fallback = fallback;
14158
14846
  return { ...roles, [role]: binding };
14159
14847
  }
14848
+ function unbindRole(roles, role) {
14849
+ if (!(role in roles)) return roles;
14850
+ const next = { ...roles };
14851
+ delete next[role];
14852
+ return next;
14853
+ }
14160
14854
  function maskSecret(key) {
14161
14855
  return key.length <= 4 ? "\u2022\u2022\u2022\u2022" : `\u2026${key.slice(-4)}`;
14162
14856
  }
@@ -16235,12 +16929,15 @@ bridgeCommand.command("provider-config-bind").description("Bind providers to an
16235
16929
  await withProviderScope(machine, async (db) => {
16236
16930
  const providers = await readScopedProviders(db, machine);
16237
16931
  const roles = await readScopedRoles(db, machine);
16238
- const nextRoles = bindRoleProviders(
16932
+ let nextRoles = bindRoleProviders(
16239
16933
  roles,
16240
16934
  opts.role,
16241
16935
  opts.primary,
16242
16936
  opts.fallback
16243
16937
  );
16938
+ if (opts.role === "recall") {
16939
+ nextRoles = unbindRole(nextRoles, "text");
16940
+ }
16244
16941
  await writeScopedRoles(db, machine, nextRoles);
16245
16942
  const binding = nextRoles[opts.role];
16246
16943
  const primary = providers[opts.primary];
@@ -16272,6 +16969,196 @@ bridgeCommand.command("list-models").description("List models exposed by an LLM
16272
16969
  const models = await getAvailableModels(opts.url, apiKey);
16273
16970
  jsonOut2({ models });
16274
16971
  });
16972
+ function modelRow(entry) {
16973
+ return {
16974
+ id: entry.id,
16975
+ label: entry.label,
16976
+ url: entry.url,
16977
+ model: entry.model,
16978
+ local: entry.local,
16979
+ apiFlavor: entry.apiFlavor,
16980
+ runner: entry.runner,
16981
+ order: entry.order,
16982
+ capabilities: entry.capabilities,
16983
+ detectedCapabilities: entry.detectedCapabilities,
16984
+ probedAt: entry.probedAt,
16985
+ apiKeyRef: entry.apiKeyRef,
16986
+ keyState: entry.apiKeyRef ? getProviderApiKey(entry.apiKeyRef) ? "set" : "missing" : "none"
16987
+ };
16988
+ }
16989
+ function parseCapabilityFlags(json) {
16990
+ const flags = emptyCapabilityFlags();
16991
+ if (!json) return flags;
16992
+ let parsed;
16993
+ try {
16994
+ parsed = JSON.parse(json);
16995
+ } catch {
16996
+ jsonError("Invalid --capabilities JSON");
16997
+ }
16998
+ if (parsed && typeof parsed === "object") {
16999
+ const record = parsed;
17000
+ for (const key of Object.keys(flags)) {
17001
+ if (record[key] === true) flags[key] = true;
17002
+ }
17003
+ }
17004
+ return flags;
17005
+ }
17006
+ function urlLooksLocal(url) {
17007
+ return /localhost|127\.0\.0\.1|\[::1\]|::1/.test(url);
17008
+ }
17009
+ bridgeCommand.command("model-list").description("List the machine-local capability model registry (JSON)").action(() => {
17010
+ ensureMachineAiModelsMigrated();
17011
+ const models = [...getMachineAiModels()].sort((a, b) => a.order - b.order);
17012
+ jsonOut2({ models: models.map(modelRow) });
17013
+ });
17014
+ bridgeCommand.command("model-probe").description("Detect capabilities of an endpoint via metadata (JSON)").requiredOption("--url <url>", "Endpoint base URL").requiredOption("--model <model>", "Model id").option(
17015
+ "--flavor <flavor>",
17016
+ `Wire protocol: ${VALID_API_FLAVORS.join(" | ")}`
17017
+ ).option("--key-ref <ref>", "Credential reference for API key").option("--embedding-dim-probe", "Allow one cheap /v1/embeddings dim probe").action(async (opts) => {
17018
+ if (opts.flavor && !VALID_API_FLAVORS.includes(opts.flavor)) {
17019
+ jsonError(`Invalid --flavor: ${opts.flavor}.`);
17020
+ }
17021
+ const apiFlavor = opts.flavor ?? inferApiFlavor(opts.url);
17022
+ const probe = await probeModelCapabilities(
17023
+ {
17024
+ url: opts.url,
17025
+ model: opts.model,
17026
+ apiFlavor,
17027
+ apiKeyRef: opts.keyRef
17028
+ },
17029
+ { embeddingDimProbe: opts.embeddingDimProbe === true }
17030
+ );
17031
+ jsonOut2({
17032
+ reachable: probe.reachable,
17033
+ catalog: probe.catalog,
17034
+ detected: probe.detected
17035
+ });
17036
+ });
17037
+ bridgeCommand.command("model-upsert").description(
17038
+ "Add or update a registry entry; probes before persisting (JSON)"
17039
+ ).option("--id <id>", "Existing entry id (omit to create)").option("--label <label>", "Human label").option("--url <url>", "Endpoint base URL").option("--model <model>", "Model id").option(
17040
+ "--flavor <flavor>",
17041
+ `Wire protocol: ${VALID_API_FLAVORS.join(" | ")}`
17042
+ ).option("--local", "Mark as local endpoint").option("--no-local", "Mark as cloud/non-local endpoint").option("--runner <runner>", "Local runner hint").option("--key-ref <ref>", "Credential reference for API key").option("--capabilities <json>", "JSON object of user-selected capabilities").option("--order <n>", "Explicit sort order").action(async (opts, command) => {
17043
+ if (opts.flavor && !VALID_API_FLAVORS.includes(opts.flavor)) {
17044
+ jsonError(`Invalid --flavor: ${opts.flavor}.`);
17045
+ }
17046
+ const models = getMachineAiModels();
17047
+ const existingIndex = opts.id ? models.findIndex((m) => m.id === opts.id) : -1;
17048
+ if (opts.id && existingIndex < 0) jsonError(`No such model: ${opts.id}`);
17049
+ const prev = existingIndex >= 0 ? models[existingIndex] : void 0;
17050
+ const url = opts.url ?? prev?.url ?? "";
17051
+ if (!url) jsonError("--url is required");
17052
+ const model = opts.model ?? prev?.model ?? "";
17053
+ if (!model) jsonError("--model is required");
17054
+ const apiFlavor = opts.flavor ?? prev?.apiFlavor ?? inferApiFlavor(url);
17055
+ const local = command.getOptionValueSource("local") === "cli" ? opts.local === true : prev?.local ?? urlLooksLocal(url);
17056
+ const order = opts.order !== void 0 ? Number.parseInt(opts.order, 10) : prev?.order ?? models.length;
17057
+ const candidate = {
17058
+ id: opts.id ?? ulid10(),
17059
+ label: opts.label ?? prev?.label ?? model,
17060
+ url,
17061
+ model,
17062
+ local,
17063
+ apiFlavor,
17064
+ order,
17065
+ capabilities: opts.capabilities ? parseCapabilityFlags(opts.capabilities) : prev?.capabilities ?? emptyCapabilityFlags(),
17066
+ detectedCapabilities: prev?.detectedCapabilities ?? emptyCapabilityFlags()
17067
+ };
17068
+ const runner = opts.runner ?? prev?.runner;
17069
+ if (runner) candidate.runner = runner;
17070
+ const apiKeyRef = opts.keyRef ?? prev?.apiKeyRef;
17071
+ if (apiKeyRef) candidate.apiKeyRef = apiKeyRef;
17072
+ const probe = await probeModelCapabilities(candidate, {
17073
+ embeddingDimProbe: true
17074
+ });
17075
+ const validation = validateModelSave(candidate, probe);
17076
+ if (!validation.ok || !validation.entry) {
17077
+ jsonError(validation.error ?? "Model could not be saved.");
17078
+ }
17079
+ const next = [...models];
17080
+ if (existingIndex >= 0) next[existingIndex] = validation.entry;
17081
+ else next.push(validation.entry);
17082
+ saveMachineAiModels(next);
17083
+ jsonOut2({
17084
+ ok: true,
17085
+ model: modelRow(validation.entry),
17086
+ probe: { reachable: probe.reachable, detected: probe.detected }
17087
+ });
17088
+ });
17089
+ bridgeCommand.command("model-reprobe").description("Re-run capability detection for an entry; may widen (JSON)").requiredOption("--id <id>", "Registry entry id").action(async (opts) => {
17090
+ const models = getMachineAiModels();
17091
+ const index = models.findIndex((m) => m.id === opts.id);
17092
+ if (index < 0) jsonError(`No such model: ${opts.id}`);
17093
+ const entry = models[index];
17094
+ const probe = await probeModelCapabilities(entry, {
17095
+ embeddingDimProbe: true
17096
+ });
17097
+ const validation = validateModelSave(entry, probe);
17098
+ if (!validation.ok || !validation.entry) {
17099
+ jsonError(validation.error ?? "Re-probe failed.");
17100
+ }
17101
+ const next = [...models];
17102
+ next[index] = validation.entry;
17103
+ saveMachineAiModels(next);
17104
+ jsonOut2({
17105
+ ok: true,
17106
+ model: modelRow(validation.entry),
17107
+ probe: { reachable: probe.reachable, detected: probe.detected }
17108
+ });
17109
+ });
17110
+ bridgeCommand.command("model-remove").description("Remove a registry entry (JSON)").requiredOption("--id <id>", "Registry entry id").action((opts) => {
17111
+ const models = getMachineAiModels();
17112
+ const next = models.filter((m) => m.id !== opts.id);
17113
+ if (next.length === models.length) jsonError(`No such model: ${opts.id}`);
17114
+ next.sort((a, b) => a.order - b.order).forEach((m, i) => {
17115
+ m.order = i;
17116
+ });
17117
+ saveMachineAiModels(next);
17118
+ jsonOut2({ ok: true, id: opts.id, models: next.map(modelRow) });
17119
+ });
17120
+ bridgeCommand.command("model-reorder").description("Set registry order from an ordered id list (JSON)").requiredOption("--ids <json>", "JSON array of entry ids in desired order").action((opts) => {
17121
+ let ids;
17122
+ try {
17123
+ ids = JSON.parse(opts.ids);
17124
+ } catch {
17125
+ jsonError("Invalid --ids JSON");
17126
+ return;
17127
+ }
17128
+ if (!Array.isArray(ids)) jsonError("--ids must be a JSON array");
17129
+ const models = getMachineAiModels();
17130
+ const rank = new Map(ids.map((id, i) => [id, i]));
17131
+ const next = [...models].sort((a, b) => {
17132
+ const ra = rank.get(a.id) ?? ids.length + a.order;
17133
+ const rb = rank.get(b.id) ?? ids.length + b.order;
17134
+ return ra - rb;
17135
+ });
17136
+ next.forEach((m, i) => {
17137
+ m.order = i;
17138
+ });
17139
+ saveMachineAiModels(next);
17140
+ jsonOut2({ ok: true, models: next.map(modelRow) });
17141
+ });
17142
+ bridgeCommand.command("model-set-capabilities").description(
17143
+ "Set user-enabled capabilities within the detected ceiling (JSON)"
17144
+ ).requiredOption("--id <id>", "Registry entry id").requiredOption(
17145
+ "--capabilities <json>",
17146
+ "JSON object of desired capabilities"
17147
+ ).action((opts) => {
17148
+ const models = getMachineAiModels();
17149
+ const index = models.findIndex((m) => m.id === opts.id);
17150
+ if (index < 0) jsonError(`No such model: ${opts.id}`);
17151
+ const requested = parseCapabilityFlags(opts.capabilities);
17152
+ const detected = models[index].detectedCapabilities;
17153
+ const capabilities = emptyCapabilityFlags();
17154
+ for (const key of Object.keys(capabilities)) {
17155
+ capabilities[key] = requested[key] && detected[key];
17156
+ }
17157
+ const next = [...models];
17158
+ next[index] = { ...models[index], capabilities };
17159
+ saveMachineAiModels(next);
17160
+ jsonOut2({ ok: true, model: modelRow(next[index]) });
17161
+ });
16275
17162
  bridgeCommand.command("cloud-model-hint").description("Suggest a cloud model for an endpoint URL (JSON)").requiredOption("--url <url>", "Endpoint base URL").action((opts) => {
16276
17163
  jsonOut2({ recommendation: getCloudModelRecommendation(opts.url) });
16277
17164
  });
@@ -17011,7 +17898,10 @@ bridgeCommand.command("personal-card-delete").description("Hard-delete a token a
17011
17898
  });
17012
17899
  });
17013
17900
  });
17014
- bridgeCommand.command("personal-card-import-curriculum").description("Parse curriculum text using LLM and import cards (JSON)").option("--user <id>", "User ID (default: whoami)").requiredOption("--text <text>", "Curriculum syllabus/content text").requiredOption(
17901
+ bridgeCommand.command("personal-card-import-curriculum").description("Parse curriculum text using LLM and import cards (JSON)").option("--user <id>", "User ID (default: whoami)").option("--text <text>", "Curriculum syllabus/content text").option(
17902
+ "--sourceId <id>",
17903
+ "Read curriculum text from a sources row (avoids large IPC payloads)"
17904
+ ).requiredOption(
17015
17905
  "--domain <domain>",
17016
17906
  "Default category/domain for imported cards"
17017
17907
  ).option("--source <url>", "Provenance source link or URL").option("--preview", "Return parsed cards without saving them").option(
@@ -17025,6 +17915,17 @@ bridgeCommand.command("personal-card-import-curriculum").description("Parse curr
17025
17915
  ).action(async (opts) => {
17026
17916
  await withDb2(async (db) => {
17027
17917
  const userId = await resolveUser(opts, db, { json: true });
17918
+ let curriculumText = opts.text;
17919
+ if (opts.sourceId) {
17920
+ const row = await db.prepare("SELECT content FROM sources WHERE id = ?").get(opts.sourceId);
17921
+ if (!row) {
17922
+ jsonError(`Source not found: ${opts.sourceId}`);
17923
+ }
17924
+ curriculumText = row.content ?? "";
17925
+ }
17926
+ if (!curriculumText?.trim()) {
17927
+ jsonError("Curriculum text is required (--text or --sourceId)");
17928
+ }
17028
17929
  const contextNames = parseKnowledgeContextNames2(
17029
17930
  opts.knowledgeContext || []
17030
17931
  );
@@ -17032,7 +17933,7 @@ bridgeCommand.command("personal-card-import-curriculum").description("Parse curr
17032
17933
  const firstContext = contexts[0]?.name;
17033
17934
  const cards = await importCurriculumViaLLM(
17034
17935
  db,
17035
- opts.text,
17936
+ curriculumText,
17036
17937
  opts.domain,
17037
17938
  opts.source || null,
17038
17939
  { knowledgeContext: firstContext }
@@ -17201,7 +18102,7 @@ bridgeCommand.command("personal-source-import").description(
17201
18102
  } else {
17202
18103
  throw new Error(`Invalid source type: ${opts.type}`);
17203
18104
  }
17204
- const sourceId = ulid9();
18105
+ const sourceId = ulid10();
17205
18106
  await db.prepare(
17206
18107
  `INSERT INTO sources (id, type, uri, content)
17207
18108
  VALUES (?, ?, ?, ?)
@@ -17386,10 +18287,86 @@ async function fetchRawHtml(url) {
17386
18287
  clearTimeout(timeoutId);
17387
18288
  }
17388
18289
  }
17389
- bridgeCommand.command("curriculum-extract-topics").description(
17390
- "Fetch and extract specific texts for selected curriculum topics (JSON)"
17391
- ).requiredOption("--provider <id>", "Curriculum provider id").requiredOption("--topics <json>", "JSON array of selected topic nodes").action(async (opts) => {
18290
+ var CURRICULUM_TOPIC_URI_PREFIX = "zam-curriculum-topic://";
18291
+ async function extractAndStoreCurriculumTopics(db, provider, topics) {
18292
+ const topicsByUri = /* @__PURE__ */ new Map();
18293
+ for (const topic of topics) {
18294
+ const resolved = provider.resolveTopic(topic);
18295
+ const list = topicsByUri.get(resolved.uri) || [];
18296
+ list.push(topic);
18297
+ topicsByUri.set(resolved.uri, list);
18298
+ }
18299
+ const extracted = [];
18300
+ for (const [uri, uriTopics] of topicsByUri.entries()) {
18301
+ const rawHtml = await fetchRawHtml(uri);
18302
+ let extractedTexts = {};
18303
+ if (provider.extractTopics) {
18304
+ const fullTopicIds = uriTopics.map((t2) => `${t2.sourceRef}#${t2.id}`);
18305
+ extractedTexts = provider.extractTopics(rawHtml, fullTopicIds);
18306
+ } else {
18307
+ const cleanText = cleanHtml(rawHtml);
18308
+ for (const t2 of uriTopics) {
18309
+ extractedTexts[`${t2.sourceRef}#${t2.id}`] = cleanText;
18310
+ }
18311
+ }
18312
+ const pageCleanedText = cleanHtml(rawHtml);
18313
+ const sourceId = ulid10();
18314
+ await db.prepare(
18315
+ `INSERT INTO sources (id, type, uri, content)
18316
+ VALUES (?, 'web', ?, ?)
18317
+ ON CONFLICT(uri) DO UPDATE SET
18318
+ type = excluded.type,
18319
+ content = excluded.content`
18320
+ ).run(sourceId, uri, pageCleanedText);
18321
+ const record = await db.prepare("SELECT id FROM sources WHERE uri = ?").get(uri);
18322
+ for (const topic of uriTopics) {
18323
+ const resolved = provider.resolveTopic(topic);
18324
+ const text = extractedTexts[resolved.topicId] || "";
18325
+ const topicUri = `${CURRICULUM_TOPIC_URI_PREFIX}${resolved.topicId}`;
18326
+ const topicSourceId = ulid10();
18327
+ await db.prepare(
18328
+ `INSERT INTO sources (id, type, uri, content)
18329
+ VALUES (?, 'web', ?, ?)
18330
+ ON CONFLICT(uri) DO UPDATE SET
18331
+ content = excluded.content`
18332
+ ).run(topicSourceId, topicUri, text);
18333
+ const topicRecord = await db.prepare("SELECT id FROM sources WHERE uri = ?").get(topicUri);
18334
+ extracted.push({
18335
+ topicId: resolved.topicId,
18336
+ uri,
18337
+ sourceId: record.id,
18338
+ topicSourceId: topicRecord.id,
18339
+ textLength: text.length
18340
+ });
18341
+ }
18342
+ }
18343
+ return extracted;
18344
+ }
18345
+ async function assignConfirmedProposalContexts(db, proposals, contexts) {
18346
+ for (const p of proposals) {
18347
+ const baseText = p.question && p.question.trim().length > 0 ? p.question : p.concept;
18348
+ const cleanDomain = slugify(p.domain || "");
18349
+ const cleanBase = slugify(baseText);
18350
+ let baseSlug = cleanDomain ? `${cleanDomain}-${cleanBase}` : cleanBase;
18351
+ if (baseSlug.length > 60) {
18352
+ baseSlug = baseSlug.slice(0, 60).replace(/-$/, "");
18353
+ }
18354
+ if (!baseSlug) {
18355
+ baseSlug = "token";
18356
+ }
18357
+ const token = await getTokenBySlug(db, baseSlug);
18358
+ if (token) {
18359
+ for (const context of contexts) {
18360
+ await assignTokenToContext(db, token.id, context.id);
18361
+ }
18362
+ }
18363
+ }
18364
+ }
18365
+ bridgeCommand.command("curriculum-import-status").description(
18366
+ "Check which selected curriculum topics are already imported for a user (JSON)"
18367
+ ).requiredOption("--provider <id>", "Curriculum provider id").requiredOption("--topics <json>", "JSON array of selected topic nodes").option("--user <id>", "User ID (default: whoami)").action(async (opts) => {
17392
18368
  await withDb2(async (db) => {
18369
+ const userId = await resolveUser(opts, db, { json: true });
17393
18370
  const provider = getCurriculumProvider(opts.provider);
17394
18371
  if (!provider) jsonError(`Unknown curriculum provider: ${opts.provider}`);
17395
18372
  let topics;
@@ -17399,47 +18376,446 @@ bridgeCommand.command("curriculum-extract-topics").description(
17399
18376
  jsonError("Invalid --topics JSON");
17400
18377
  return;
17401
18378
  }
17402
- const topicsByUri = /* @__PURE__ */ new Map();
18379
+ const status = [];
17403
18380
  for (const topic of topics) {
17404
- const resolved = provider.resolveTopic(topic);
17405
- const list = topicsByUri.get(resolved.uri) || [];
17406
- list.push(topic);
17407
- topicsByUri.set(resolved.uri, list);
17408
- }
17409
- const extracted = [];
17410
- for (const [uri, uriTopics] of topicsByUri.entries()) {
17411
- const rawHtml = await fetchRawHtml(uri);
17412
- let extractedTexts = {};
17413
- if (provider.extractTopics) {
17414
- const fullTopicIds = uriTopics.map((t2) => `${t2.sourceRef}#${t2.id}`);
17415
- extractedTexts = provider.extractTopics(rawHtml, fullTopicIds);
17416
- } else {
17417
- const cleanText = cleanHtml(rawHtml);
17418
- for (const t2 of uriTopics) {
17419
- extractedTexts[`${t2.sourceRef}#${t2.id}`] = cleanText;
17420
- }
17421
- }
17422
- const pageCleanedText = cleanHtml(rawHtml);
17423
- const sourceId = ulid9();
17424
- await db.prepare(
17425
- `INSERT INTO sources (id, type, uri, content)
17426
- VALUES (?, 'web', ?, ?)
17427
- ON CONFLICT(uri) DO UPDATE SET
17428
- type = excluded.type,
17429
- content = excluded.content`
17430
- ).run(sourceId, uri, pageCleanedText);
17431
- const record = await db.prepare("SELECT id FROM sources WHERE uri = ?").get(uri);
17432
- for (const topic of uriTopics) {
18381
+ try {
17433
18382
  const resolved = provider.resolveTopic(topic);
17434
- const text = extractedTexts[resolved.topicId] || "";
17435
- extracted.push({
18383
+ const cardCount = await countUserCardsForCurriculumTopic(
18384
+ db,
18385
+ userId,
18386
+ provider.id,
18387
+ resolved.topicId
18388
+ );
18389
+ status.push({
17436
18390
  topicId: resolved.topicId,
17437
- uri,
17438
- sourceId: record.id,
17439
- text
18391
+ shortId: topic.id,
18392
+ cardCount,
18393
+ alreadyImported: cardCount > 0
18394
+ });
18395
+ } catch (err) {
18396
+ status.push({
18397
+ topicId: topic.id,
18398
+ shortId: topic.id,
18399
+ cardCount: 0,
18400
+ alreadyImported: false,
18401
+ error: err.message || String(err)
18402
+ });
18403
+ }
18404
+ }
18405
+ jsonOut2({ success: true, status });
18406
+ });
18407
+ });
18408
+ bridgeCommand.command("curriculum-import-topic").description(
18409
+ "Extract, LLM-preview, and confirm-import one curriculum topic server-side (JSON)"
18410
+ ).requiredOption("--provider <id>", "Curriculum provider id").requiredOption("--topic <json>", "Single topic node JSON").requiredOption("--domain <domain>", "Subject label for imported card domain").option("--user <id>", "User ID (default: whoami)").option(
18411
+ "--knowledge-context <context>",
18412
+ "Assign confirmed tokens to a knowledge context (repeatable)",
18413
+ (val, memo) => {
18414
+ memo.push(val);
18415
+ return memo;
18416
+ },
18417
+ []
18418
+ ).action(async (opts) => {
18419
+ await withDb2(async (db) => {
18420
+ const userId = await resolveUser(opts, db, { json: true });
18421
+ const provider = getCurriculumProvider(opts.provider);
18422
+ if (!provider) jsonError(`Unknown curriculum provider: ${opts.provider}`);
18423
+ let topic;
18424
+ try {
18425
+ topic = JSON.parse(opts.topic);
18426
+ } catch {
18427
+ jsonError("Invalid --topic JSON");
18428
+ return;
18429
+ }
18430
+ const extracted = await extractAndStoreCurriculumTopics(db, provider, [
18431
+ topic
18432
+ ]);
18433
+ const item = extracted[0];
18434
+ if (!item || item.textLength === 0) {
18435
+ jsonError(`No curriculum text extracted for topic "${topic.id}"`);
18436
+ }
18437
+ const contextNames = parseKnowledgeContextNames2(
18438
+ opts.knowledgeContext || []
18439
+ );
18440
+ const contexts = await resolveKnowledgeContexts2(db, contextNames);
18441
+ const firstContext = contexts[0]?.name;
18442
+ const topicRow = await db.prepare("SELECT content FROM sources WHERE id = ?").get(item.topicSourceId);
18443
+ const curriculumText = topicRow.content ?? "";
18444
+ const cards = await importCurriculumViaLLM(
18445
+ db,
18446
+ curriculumText,
18447
+ opts.domain,
18448
+ item.uri,
18449
+ { knowledgeContext: firstContext }
18450
+ );
18451
+ if (cards.length === 0) {
18452
+ jsonError(`No cards were generated for ${item.topicId}`);
18453
+ }
18454
+ const proposals = cards.map((card) => ({
18455
+ question: card.question,
18456
+ concept: card.concept,
18457
+ domain: card.domain,
18458
+ title: card.title,
18459
+ bloom_level: card.bloom_level,
18460
+ symbiosis_mode: card.symbiosis_mode || "none",
18461
+ excerpt: card.context || "",
18462
+ page_number: null,
18463
+ provider: provider.id,
18464
+ topic_id: item.topicId
18465
+ }));
18466
+ const result = await confirmSourceImport(
18467
+ db,
18468
+ userId,
18469
+ item.sourceId,
18470
+ proposals
18471
+ );
18472
+ await assignConfirmedProposalContexts(db, proposals, contexts);
18473
+ jsonOut2({
18474
+ success: true,
18475
+ topicId: item.topicId,
18476
+ proposalCount: proposals.length,
18477
+ createdCount: result.createdCount,
18478
+ ensuredCount: result.linkedCount
18479
+ });
18480
+ });
18481
+ });
18482
+ function proposalBaseSlug(domain, question, concept) {
18483
+ const baseText = question?.trim() ? question : concept;
18484
+ const cleanDomain = slugify(domain || "");
18485
+ const cleanBase = slugify(baseText);
18486
+ let baseSlug = cleanDomain ? `${cleanDomain}-${cleanBase}` : cleanBase;
18487
+ if (baseSlug.length > 60) {
18488
+ baseSlug = baseSlug.slice(0, 60).replace(/-$/, "");
18489
+ }
18490
+ return baseSlug || "token";
18491
+ }
18492
+ async function executeCurriculumConfirmOperations(db, userId, operations, contexts) {
18493
+ let createdCount = 0;
18494
+ let ensuredCount = 0;
18495
+ let removedCount = 0;
18496
+ await db.transaction(async (tx) => {
18497
+ for (const op of operations) {
18498
+ if (op.create.length > 0) {
18499
+ const result = await applySourceProposals(
18500
+ tx,
18501
+ userId,
18502
+ op.sourceId,
18503
+ op.create
18504
+ );
18505
+ createdCount += result.createdCount;
18506
+ ensuredCount += result.linkedCount;
18507
+ await assignConfirmedProposalContexts(tx, op.create, contexts);
18508
+ }
18509
+ for (const slug of op.removeSlugs) {
18510
+ if (await deleteCurriculumCardForUser(
18511
+ tx,
18512
+ userId,
18513
+ slug,
18514
+ op.provider,
18515
+ op.topicId
18516
+ )) {
18517
+ removedCount++;
18518
+ }
18519
+ }
18520
+ }
18521
+ });
18522
+ return { createdCount, ensuredCount, removedCount };
18523
+ }
18524
+ bridgeCommand.command("curriculum-list-subtopics").description(
18525
+ "List finer units inside a Lernbereich for chunked import (JSON)"
18526
+ ).requiredOption("--provider <id>", "Curriculum provider id").requiredOption("--topic <json>", "Single topic node JSON").action(async (opts) => {
18527
+ try {
18528
+ const provider = getCurriculumProvider(opts.provider);
18529
+ if (!provider) jsonError(`Unknown curriculum provider: ${opts.provider}`);
18530
+ if (!provider.extractSubTopics) {
18531
+ jsonOut2({ success: true, subTopics: [] });
18532
+ return;
18533
+ }
18534
+ let topic;
18535
+ try {
18536
+ topic = JSON.parse(opts.topic);
18537
+ } catch {
18538
+ jsonError("Invalid --topic JSON");
18539
+ return;
18540
+ }
18541
+ const resolved = provider.resolveTopic(topic);
18542
+ const rawHtml = await fetchRawHtml(resolved.uri);
18543
+ const subTopics = provider.extractSubTopics(rawHtml, resolved.topicId).map(({ id, label, textLength }) => ({ id, label, textLength }));
18544
+ jsonOut2({ success: true, topicId: resolved.topicId, subTopics });
18545
+ } catch (err) {
18546
+ jsonError(err.message || String(err));
18547
+ }
18548
+ });
18549
+ bridgeCommand.command("curriculum-preview-topic").description(
18550
+ "Preview importable cards for one topic (existing + LLM proposals) (JSON)"
18551
+ ).requiredOption("--provider <id>", "Curriculum provider id").requiredOption("--topic <json>", "Single topic node JSON").requiredOption("--domain <domain>", "Subject label for imported card domain").option("--subTopics <json>", "JSON array of sub-topic ids to include").option("--user <id>", "User ID (default: whoami)").option(
18552
+ "--knowledge-context <context>",
18553
+ "Knowledge context for LLM prompt (repeatable)",
18554
+ (val, memo) => {
18555
+ memo.push(val);
18556
+ return memo;
18557
+ },
18558
+ []
18559
+ ).action(async (opts) => {
18560
+ await withDb2(async (db) => {
18561
+ const userId = await resolveUser(opts, db, { json: true });
18562
+ const provider = getCurriculumProvider(opts.provider);
18563
+ if (!provider) jsonError(`Unknown curriculum provider: ${opts.provider}`);
18564
+ let topic;
18565
+ try {
18566
+ topic = JSON.parse(opts.topic);
18567
+ } catch {
18568
+ jsonError("Invalid --topic JSON");
18569
+ return;
18570
+ }
18571
+ const resolved = provider.resolveTopic(topic);
18572
+ const extracted = await extractAndStoreCurriculumTopics(db, provider, [
18573
+ topic
18574
+ ]);
18575
+ const item = extracted[0];
18576
+ if (!item || item.textLength === 0) {
18577
+ jsonError(`No curriculum text extracted for topic "${topic.id}"`);
18578
+ }
18579
+ const contextNames = parseKnowledgeContextNames2(
18580
+ opts.knowledgeContext || []
18581
+ );
18582
+ const contexts = await resolveKnowledgeContexts2(db, contextNames);
18583
+ const firstContext = contexts[0]?.name;
18584
+ let subTopicIds;
18585
+ if (opts.subTopics) {
18586
+ try {
18587
+ subTopicIds = JSON.parse(opts.subTopics);
18588
+ } catch {
18589
+ jsonError("Invalid --subTopics JSON");
18590
+ return;
18591
+ }
18592
+ }
18593
+ const rawHtml = await fetchRawHtml(item.uri);
18594
+ const chunks = [];
18595
+ if (provider.extractSubTopics) {
18596
+ const subTopics = provider.extractSubTopics(rawHtml, resolved.topicId);
18597
+ const selected = subTopicIds && subTopicIds.length > 0 ? subTopics.filter((st) => subTopicIds.includes(st.id)) : subTopics;
18598
+ if (selected.length > 0) {
18599
+ for (const st of selected) {
18600
+ chunks.push({ subTopicId: st.id, text: st.text });
18601
+ }
18602
+ }
18603
+ }
18604
+ if (chunks.length === 0) {
18605
+ const topicRow = await db.prepare("SELECT content FROM sources WHERE id = ?").get(item.topicSourceId);
18606
+ chunks.push({
18607
+ subTopicId: null,
18608
+ text: topicRow.content ?? ""
18609
+ });
18610
+ }
18611
+ const existingCards = await listUserCardsForCurriculumTopic(
18612
+ db,
18613
+ userId,
18614
+ provider.id,
18615
+ resolved.topicId
18616
+ );
18617
+ const existingSlugs = new Set(existingCards.map((c) => c.slug));
18618
+ const items = [];
18619
+ for (const card of existingCards) {
18620
+ items.push({
18621
+ id: `existing:${card.slug}`,
18622
+ slug: card.slug,
18623
+ question: card.question ?? "",
18624
+ concept: card.concept,
18625
+ domain: card.domain,
18626
+ bloom_level: card.bloomLevel,
18627
+ symbiosis_mode: card.symbiosisMode ?? "none",
18628
+ excerpt: "",
18629
+ isExisting: true,
18630
+ selected: true,
18631
+ subTopicId: card.topicId?.includes("@") ? card.topicId.split("@").pop() ?? null : null,
18632
+ parentTopicId: resolved.topicId
18633
+ });
18634
+ }
18635
+ let proposalIndex = 0;
18636
+ for (const chunk of chunks) {
18637
+ if (!chunk.text.trim()) continue;
18638
+ const cards = await importCurriculumViaLLM(
18639
+ db,
18640
+ chunk.text,
18641
+ opts.domain,
18642
+ item.uri,
18643
+ { knowledgeContext: firstContext }
18644
+ );
18645
+ const scopedTopicId = chunk.subTopicId ? `${resolved.topicId}@${chunk.subTopicId}` : resolved.topicId;
18646
+ for (const card of cards) {
18647
+ const slug = proposalBaseSlug(
18648
+ card.domain,
18649
+ card.question,
18650
+ card.concept
18651
+ );
18652
+ if (existingSlugs.has(slug)) {
18653
+ continue;
18654
+ }
18655
+ const proposal = {
18656
+ question: card.question,
18657
+ concept: card.concept,
18658
+ domain: card.domain,
18659
+ bloom_level: card.bloom_level,
18660
+ symbiosis_mode: card.symbiosis_mode || "none",
18661
+ excerpt: card.context || "",
18662
+ page_number: null,
18663
+ provider: provider.id,
18664
+ topic_id: scopedTopicId
18665
+ };
18666
+ items.push({
18667
+ id: `new:${proposalIndex++}`,
18668
+ slug: null,
18669
+ question: proposal.question,
18670
+ concept: proposal.concept,
18671
+ domain: proposal.domain,
18672
+ bloom_level: proposal.bloom_level,
18673
+ symbiosis_mode: proposal.symbiosis_mode,
18674
+ excerpt: proposal.excerpt,
18675
+ isExisting: false,
18676
+ selected: false,
18677
+ subTopicId: chunk.subTopicId,
18678
+ parentTopicId: resolved.topicId,
18679
+ proposal
17440
18680
  });
17441
18681
  }
17442
18682
  }
18683
+ if (items.length === 0) {
18684
+ jsonError(`No importable cards for topic "${topic.id}"`);
18685
+ }
18686
+ jsonOut2({
18687
+ success: true,
18688
+ topicId: resolved.topicId,
18689
+ sourceId: item.sourceId,
18690
+ items
18691
+ });
18692
+ });
18693
+ });
18694
+ bridgeCommand.command("curriculum-confirm-topic").description(
18695
+ "Create selected new cards and remove deselected existing cards (JSON)"
18696
+ ).requiredOption("--provider <id>", "Curriculum provider id").requiredOption("--topicId <id>", "Resolved curriculum topic id").requiredOption("--sourceId <id>", "Parent page source database ID").requiredOption(
18697
+ "--create <json>",
18698
+ "JSON array of SourceProposalInput objects to import"
18699
+ ).requiredOption(
18700
+ "--removeSlugs <json>",
18701
+ "JSON array of token slugs whose user cards should be deleted"
18702
+ ).option("--user <id>", "User ID (default: whoami)").option(
18703
+ "--knowledge-context <context>",
18704
+ "Assign confirmed tokens to a knowledge context (repeatable)",
18705
+ (val, memo) => {
18706
+ memo.push(val);
18707
+ return memo;
18708
+ },
18709
+ []
18710
+ ).action(async (opts) => {
18711
+ await withDb2(async (db) => {
18712
+ const userId = await resolveUser(opts, db, { json: true });
18713
+ const provider = getCurriculumProvider(opts.provider);
18714
+ if (!provider) jsonError(`Unknown curriculum provider: ${opts.provider}`);
18715
+ let create;
18716
+ let removeSlugs;
18717
+ try {
18718
+ create = JSON.parse(opts.create);
18719
+ removeSlugs = JSON.parse(opts.removeSlugs);
18720
+ } catch {
18721
+ jsonError("Invalid --create or --removeSlugs JSON");
18722
+ return;
18723
+ }
18724
+ const contextNames = parseKnowledgeContextNames2(
18725
+ opts.knowledgeContext || []
18726
+ );
18727
+ const contexts = await resolveKnowledgeContexts2(db, contextNames);
18728
+ try {
18729
+ const result = await executeCurriculumConfirmOperations(
18730
+ db,
18731
+ userId,
18732
+ [
18733
+ {
18734
+ sourceId: opts.sourceId,
18735
+ provider: provider.id,
18736
+ topicId: opts.topicId,
18737
+ create,
18738
+ removeSlugs
18739
+ }
18740
+ ],
18741
+ contexts
18742
+ );
18743
+ jsonOut2({ success: true, ...result });
18744
+ } catch (err) {
18745
+ jsonError(err.message || String(err));
18746
+ }
18747
+ });
18748
+ });
18749
+ bridgeCommand.command("curriculum-confirm-batch").description("Atomically confirm multiple curriculum topic selections (JSON)").requiredOption(
18750
+ "--operations <json>",
18751
+ "JSON array of {sourceId, provider, topicId, create, removeSlugs}"
18752
+ ).option("--user <id>", "User ID (default: whoami)").option(
18753
+ "--knowledge-context <context>",
18754
+ "Assign confirmed tokens to a knowledge context (repeatable)",
18755
+ (val, memo) => {
18756
+ memo.push(val);
18757
+ return memo;
18758
+ },
18759
+ []
18760
+ ).action(async (opts) => {
18761
+ await withDb2(async (db) => {
18762
+ const userId = await resolveUser(opts, db, { json: true });
18763
+ let operations;
18764
+ try {
18765
+ operations = JSON.parse(opts.operations);
18766
+ } catch {
18767
+ jsonError("Invalid --operations JSON");
18768
+ return;
18769
+ }
18770
+ if (!Array.isArray(operations) || operations.length === 0) {
18771
+ jsonError("--operations must be a non-empty JSON array");
18772
+ }
18773
+ for (const op of operations) {
18774
+ if (!op?.sourceId || !op?.provider || !op?.topicId) {
18775
+ jsonError("Each operation requires sourceId, provider, and topicId");
18776
+ }
18777
+ if (!getCurriculumProvider(op.provider)) {
18778
+ jsonError(`Unknown curriculum provider: ${op.provider}`);
18779
+ }
18780
+ if (!Array.isArray(op.create) || !Array.isArray(op.removeSlugs)) {
18781
+ jsonError("Each operation requires create and removeSlugs arrays");
18782
+ }
18783
+ }
18784
+ const contextNames = parseKnowledgeContextNames2(
18785
+ opts.knowledgeContext || []
18786
+ );
18787
+ const contexts = await resolveKnowledgeContexts2(db, contextNames);
18788
+ try {
18789
+ const result = await executeCurriculumConfirmOperations(
18790
+ db,
18791
+ userId,
18792
+ operations,
18793
+ contexts
18794
+ );
18795
+ jsonOut2({ success: true, ...result });
18796
+ } catch (err) {
18797
+ jsonError(err.message || String(err));
18798
+ }
18799
+ });
18800
+ });
18801
+ bridgeCommand.command("curriculum-extract-topics").description(
18802
+ "Fetch and extract specific texts for selected curriculum topics (JSON)"
18803
+ ).requiredOption("--provider <id>", "Curriculum provider id").requiredOption("--topics <json>", "JSON array of selected topic nodes").action(async (opts) => {
18804
+ await withDb2(async (db) => {
18805
+ const provider = getCurriculumProvider(opts.provider);
18806
+ if (!provider) jsonError(`Unknown curriculum provider: ${opts.provider}`);
18807
+ let topics;
18808
+ try {
18809
+ topics = JSON.parse(opts.topics);
18810
+ } catch {
18811
+ jsonError("Invalid --topics JSON");
18812
+ return;
18813
+ }
18814
+ const extracted = await extractAndStoreCurriculumTopics(
18815
+ db,
18816
+ provider,
18817
+ topics
18818
+ );
17443
18819
  jsonOut2({ success: true, extracted });
17444
18820
  });
17445
18821
  });
@@ -17673,6 +19049,9 @@ bridgeCommand.command("serve").description("Start the persistent JSON-RPC stdin/
17673
19049
  output: process.stdout,
17674
19050
  terminal: false
17675
19051
  });
19052
+ process.on("unhandledRejection", (reason) => {
19053
+ logDiag(`unhandledRejection: ${String(reason)}`);
19054
+ });
17676
19055
  let pending = Promise.resolve();
17677
19056
  rl.on("line", (line) => {
17678
19057
  if (!line.trim()) return;
@@ -17680,6 +19059,15 @@ bridgeCommand.command("serve").description("Start the persistent JSON-RPC stdin/
17680
19059
  const response = await processRequest(line);
17681
19060
  process.stdout.write(`${response}
17682
19061
  `);
19062
+ }).catch((err) => {
19063
+ logDiag(`serve request failed: ${err.message || err}`);
19064
+ try {
19065
+ process.stdout.write(
19066
+ `${JSON.stringify({ id: null, error: err.message || String(err) })}
19067
+ `
19068
+ );
19069
+ } catch {
19070
+ }
17683
19071
  });
17684
19072
  });
17685
19073
  });