zam-core 0.9.3 → 0.9.4

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.
@@ -585,7 +585,12 @@ CREATE TABLE IF NOT EXISTS tokens (
585
585
  deprecated_at TEXT,
586
586
  question TEXT,
587
587
  provider TEXT,
588
- topic_id TEXT
588
+ topic_id TEXT,
589
+ -- Who authored the current question ('manual' | 'llm' | 'template');
590
+ -- validated in code, not via CHECK. Column default is 'llm' so unlabeled
591
+ -- writes (pre-M013 rows, old snapshot restores) count as LLM-era content;
592
+ -- createToken() defaults to 'manual' for API callers instead.
593
+ question_source TEXT NOT NULL DEFAULT 'llm'
589
594
  );
590
595
 
591
596
  -- Prerequisite dependency graph: "to learn A, first know B"
@@ -1109,6 +1114,11 @@ async function runMigrations(db) {
1109
1114
  await db.exec(`
1110
1115
  CREATE INDEX IF NOT EXISTS idx_token_contexts_context ON token_contexts(context_id)
1111
1116
  `);
1117
+ if (tokenCols.length > 0 && !tokenCols.some((c) => c.name === "question_source")) {
1118
+ await db.exec(
1119
+ `ALTER TABLE tokens ADD COLUMN question_source TEXT NOT NULL DEFAULT 'llm'`
1120
+ );
1121
+ }
1112
1122
  }
1113
1123
  var DEFAULT_DB_DIR, DEFAULT_DB_PATH, require2;
1114
1124
  var init_connection = __esm({
@@ -1269,7 +1279,11 @@ var init_snapshot = __esm({
1269
1279
  "review_logs",
1270
1280
  "session_syntheses",
1271
1281
  "user_config",
1272
- "agent_skills"
1282
+ "agent_skills",
1283
+ "sources",
1284
+ "token_sources",
1285
+ "contexts",
1286
+ "token_contexts"
1273
1287
  ];
1274
1288
  }
1275
1289
  });
@@ -1754,6 +1768,13 @@ var init_knowledge_context = __esm({
1754
1768
 
1755
1769
  // src/kernel/models/token.ts
1756
1770
  import { ulid as ulid4 } from "ulid";
1771
+ function validateQuestionSource(value) {
1772
+ if (!VALID_QUESTION_SOURCES.includes(value)) {
1773
+ throw new Error(
1774
+ `Invalid question_source: ${value} (expected one of ${VALID_QUESTION_SOURCES.join(", ")})`
1775
+ );
1776
+ }
1777
+ }
1757
1778
  async function createToken(db, input) {
1758
1779
  const id = ulid4();
1759
1780
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1762,9 +1783,11 @@ async function createToken(db, input) {
1762
1783
  throw new Error(`bloom_level must be between 1 and 5, got ${bloom}`);
1763
1784
  }
1764
1785
  const title = input.title ?? "";
1786
+ const questionSource = input.question_source ?? "manual";
1787
+ validateQuestionSource(questionSource);
1765
1788
  await db.prepare(`
1766
- INSERT INTO tokens (id, slug, title, concept, domain, bloom_level, context, symbiosis_mode, source_link, question, provider, topic_id, created_at, updated_at)
1767
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1789
+ INSERT INTO tokens (id, slug, title, concept, domain, bloom_level, context, symbiosis_mode, source_link, question, question_source, provider, topic_id, created_at, updated_at)
1790
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1768
1791
  `).run(
1769
1792
  id,
1770
1793
  input.slug,
@@ -1776,6 +1799,7 @@ async function createToken(db, input) {
1776
1799
  input.symbiosis_mode ?? null,
1777
1800
  input.source_link ?? null,
1778
1801
  input.question ?? null,
1802
+ questionSource,
1779
1803
  input.provider ?? null,
1780
1804
  input.topic_id ?? null,
1781
1805
  now,
@@ -1864,6 +1888,12 @@ async function updateToken(db, slug, updates) {
1864
1888
  fields.push("question = ?");
1865
1889
  values.push(updates.question);
1866
1890
  }
1891
+ const questionSource = updates.question_source ?? (updates.question !== void 0 ? "manual" : void 0);
1892
+ if (questionSource !== void 0) {
1893
+ validateQuestionSource(questionSource);
1894
+ fields.push("question_source = ?");
1895
+ values.push(questionSource);
1896
+ }
1867
1897
  if (updates.provider !== void 0) {
1868
1898
  fields.push("provider = ?");
1869
1899
  values.push(updates.provider);
@@ -2192,6 +2222,9 @@ async function importCurriculumCards(db, userId, cards) {
2192
2222
  symbiosis_mode: symbiosisMode,
2193
2223
  source_link: card.source_link || null,
2194
2224
  question: card.question || null,
2225
+ // Curriculum cards are LLM-extracted; their questions are LLM
2226
+ // inventions, not human-authored content.
2227
+ question_source: "llm",
2195
2228
  provider: card.provider || null,
2196
2229
  topic_id: card.topic_id || null
2197
2230
  });
@@ -2515,10 +2548,12 @@ async function assertPrerequisiteDoesNotCreateCycle(db, tokenId, prerequisiteId)
2515
2548
  throw new Error("Cannot add prerequisite: would create a cycle");
2516
2549
  }
2517
2550
  }
2551
+ var VALID_QUESTION_SOURCES;
2518
2552
  var init_token = __esm({
2519
2553
  "src/kernel/models/token.ts"() {
2520
2554
  "use strict";
2521
2555
  init_card();
2556
+ VALID_QUESTION_SOURCES = ["manual", "llm", "template"];
2522
2557
  }
2523
2558
  });
2524
2559
 
@@ -5142,7 +5177,8 @@ async function buildReviewQueue(db, options) {
5142
5177
  c.state AS state,
5143
5178
  c.due_at AS due_at,
5144
5179
  t.source_link AS source_link,
5145
- t.question AS question
5180
+ t.question AS question,
5181
+ t.question_source AS question_source
5146
5182
  FROM cards c
5147
5183
  JOIN tokens t ON t.id = c.token_id
5148
5184
  WHERE c.user_id = ?
@@ -5172,7 +5208,8 @@ async function buildReviewQueue(db, options) {
5172
5208
  c.state AS state,
5173
5209
  c.due_at AS due_at,
5174
5210
  t.source_link AS source_link,
5175
- t.question AS question
5211
+ t.question AS question,
5212
+ t.question_source AS question_source
5176
5213
  FROM cards c
5177
5214
  JOIN tokens t ON t.id = c.token_id
5178
5215
  WHERE c.user_id = ?
@@ -5241,7 +5278,8 @@ function rowToItem(row) {
5241
5278
  state: row.state,
5242
5279
  dueAt: row.due_at,
5243
5280
  sourceLink: row.source_link,
5244
- question: row.question
5281
+ question: row.question,
5282
+ questionSource: row.question_source
5245
5283
  };
5246
5284
  }
5247
5285
  function intersperseNew(reviews, newCards, interval) {
@@ -7043,6 +7081,9 @@ async function generateQuestionViaLLM(db, input) {
7043
7081
  const bloom = input.bloomLevel >= 1 && input.bloomLevel <= 5 ? input.bloomLevel : 1;
7044
7082
  const verb = BLOOM_VERBS2[bloom];
7045
7083
  const langName = LANGUAGE_NAMES[cfg.locale] || "English";
7084
+ const existingQuestion = input.existingQuestion?.trim();
7085
+ const variationGuideline = existingQuestion ? `
7086
+ 5. A canonical question for this token already exists. Generate a fresh VARIATION of it: test the same knowledge, but with different wording or from a different angle, so the learner cannot memorize the exact phrasing. Never repeat the canonical question verbatim.` : "";
7046
7087
  const systemPrompt = `You are ZAM, a highly precise agentic skills trainer.
7047
7088
  Your task is to generate a single, clear, conceptual active-recall question (flashcard front) in ${langName} for a knowledge token.
7048
7089
 
@@ -7050,12 +7091,13 @@ Guidelines:
7050
7091
  1. The question MUST match the Bloom level: ${verb} (Level ${bloom}).
7051
7092
  2. CRITICAL: The question MUST NOT contain or reveal the concept text itself! The concept is the answer (flashcard back) that the learner needs to recall.
7052
7093
  3. Keep the question concise, highly specific, and clear. Avoid generic prompts like "What is the concept of..." if possible, and ask about the core mechanism, function, or purpose of the slug/concept without giving the answer away.
7053
- 4. Output ONLY the raw question text in ${langName}. Do not include any preamble, headers, markdown fences, or conversational filler.`;
7094
+ 4. Output ONLY the raw question text in ${langName}. Do not include any preamble, headers, markdown fences, or conversational filler.${variationGuideline}`;
7054
7095
  const userPrompt = `Domain: ${input.domain}
7055
7096
  Slug: ${input.slug}
7056
7097
  Concept to Recall (DO NOT REVEAL IN QUESTION): ${input.concept}
7057
7098
  Context: ${input.context || "(none)"}
7058
- ${input.sourceLinkContent ? `Source Reference:
7099
+ ${existingQuestion ? `Canonical Question (vary, do not repeat verbatim): ${existingQuestion}
7100
+ ` : ""}${input.sourceLinkContent ? `Source Reference:
7059
7101
  ${input.sourceLinkContent}` : ""}
7060
7102
 
7061
7103
  Active-Recall Question:`;
@@ -7248,7 +7290,8 @@ async function fetchWithInteractiveTimeout(url, options = {}) {
7248
7290
  }
7249
7291
  async function ensureHighQualityQuestion(db, token) {
7250
7292
  const { enabled } = await getLlmConfig(db);
7251
- if (enabled) {
7293
+ const variationEnabled = await getSetting(db, "llm.dynamic_questions") !== "false";
7294
+ if (enabled && variationEnabled) {
7252
7295
  try {
7253
7296
  let sourceLinkContent = null;
7254
7297
  if (token.sourceLink) {
@@ -7264,10 +7307,10 @@ async function ensureHighQualityQuestion(db, token) {
7264
7307
  concept: token.concept,
7265
7308
  domain: token.domain,
7266
7309
  bloomLevel: token.bloomLevel,
7267
- sourceLinkContent
7310
+ sourceLinkContent,
7311
+ existingQuestion: token.question
7268
7312
  });
7269
7313
  if (generated.text.trim().length > 0) {
7270
- await updateToken(db, token.slug, { question: generated.text });
7271
7314
  return {
7272
7315
  question: generated.text,
7273
7316
  source: "llm",
@@ -7870,7 +7913,10 @@ async function addToken(db, params) {
7870
7913
  context: params.context,
7871
7914
  symbiosis_mode: params.symbiosisMode,
7872
7915
  source_link: params.sourceLink ?? null,
7873
- question: params.question ?? null
7916
+ question: params.question ?? null,
7917
+ // Bridge/MCP callers are agents: their questions are LLM-authored and
7918
+ // stay refreshable. Humans author questions via the token CLI instead.
7919
+ question_source: params.question ? "llm" : void 0
7874
7920
  });
7875
7921
  for (const context of assignedContexts) {
7876
7922
  await assignTokenToContext(db, token.id, context.id);