zam-core 0.9.2 → 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.
package/dist/cli/app.js CHANGED
@@ -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, input8) {
1758
1779
  const id = ulid4();
1759
1780
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1762,9 +1783,11 @@ async function createToken(db, input8) {
1762
1783
  throw new Error(`bloom_level must be between 1 and 5, got ${bloom}`);
1763
1784
  }
1764
1785
  const title = input8.title ?? "";
1786
+ const questionSource = input8.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
  input8.slug,
@@ -1776,6 +1799,7 @@ async function createToken(db, input8) {
1776
1799
  input8.symbiosis_mode ?? null,
1777
1800
  input8.source_link ?? null,
1778
1801
  input8.question ?? null,
1802
+ questionSource,
1779
1803
  input8.provider ?? null,
1780
1804
  input8.topic_id ?? null,
1781
1805
  now,
@@ -1799,6 +1823,18 @@ async function getTokenBySlug(db, slug) {
1799
1823
  parseTokenFallback(token);
1800
1824
  return token;
1801
1825
  }
1826
+ async function getTokensBySlugs(db, slugs) {
1827
+ const tokens = /* @__PURE__ */ new Map();
1828
+ const unique = [...new Set(slugs)];
1829
+ if (unique.length === 0) return tokens;
1830
+ const placeholders = unique.map(() => "?").join(",");
1831
+ const rows = await db.prepare(`SELECT * FROM tokens WHERE slug IN (${placeholders})`).all(...unique);
1832
+ for (const token of rows) {
1833
+ parseTokenFallback(token);
1834
+ tokens.set(token.slug, token);
1835
+ }
1836
+ return tokens;
1837
+ }
1802
1838
  async function getTokenById(db, id) {
1803
1839
  const token = await db.prepare("SELECT * FROM tokens WHERE id = ?").get(id);
1804
1840
  parseTokenFallback(token);
@@ -1852,6 +1888,12 @@ async function updateToken(db, slug, updates) {
1852
1888
  fields.push("question = ?");
1853
1889
  values.push(updates.question);
1854
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
+ }
1855
1897
  if (updates.provider !== void 0) {
1856
1898
  fields.push("provider = ?");
1857
1899
  values.push(updates.provider);
@@ -2180,6 +2222,9 @@ async function importCurriculumCards(db, userId, cards) {
2180
2222
  symbiosis_mode: symbiosisMode,
2181
2223
  source_link: card.source_link || null,
2182
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",
2183
2228
  provider: card.provider || null,
2184
2229
  topic_id: card.topic_id || null
2185
2230
  });
@@ -2503,10 +2548,12 @@ async function assertPrerequisiteDoesNotCreateCycle(db, tokenId, prerequisiteId)
2503
2548
  throw new Error("Cannot add prerequisite: would create a cycle");
2504
2549
  }
2505
2550
  }
2551
+ var VALID_QUESTION_SOURCES;
2506
2552
  var init_token = __esm({
2507
2553
  "src/kernel/models/token.ts"() {
2508
2554
  "use strict";
2509
2555
  init_card();
2556
+ VALID_QUESTION_SOURCES = ["manual", "llm", "template"];
2510
2557
  }
2511
2558
  });
2512
2559
 
@@ -3942,25 +3989,29 @@ async function cascadeBlock(db, userId, tokenSlug) {
3942
3989
  };
3943
3990
  }
3944
3991
  async function unblockReady(db, userId) {
3945
- const blockedCards = await db.prepare(
3946
- `SELECT c.token_id, t.slug, t.concept
3947
- FROM cards c
3948
- JOIN tokens t ON t.id = c.token_id
3949
- WHERE c.user_id = ? AND c.blocked = 1`
3950
- ).all(userId);
3951
3992
  const unblocked = [];
3952
- for (const card of blockedCards) {
3953
- const totalPrereqs = await db.prepare("SELECT COUNT(*) as n FROM prerequisites WHERE token_id = ?").get(card.token_id);
3954
- const metPrereqs = await db.prepare(
3955
- `SELECT COUNT(*) as n FROM cards c
3956
- JOIN prerequisites p ON p.requires_id = c.token_id
3957
- WHERE p.token_id = ? AND c.user_id = ? AND c.reps >= 1 AND c.blocked = 0`
3958
- ).get(card.token_id, userId);
3959
- if (totalPrereqs.n === 0 || metPrereqs.n === totalPrereqs.n) {
3960
- const now = (/* @__PURE__ */ new Date()).toISOString();
3961
- await db.prepare(
3962
- "UPDATE cards SET blocked = 0, due_at = ? WHERE token_id = ? AND user_id = ?"
3963
- ).run(now, card.token_id, userId);
3993
+ for (; ; ) {
3994
+ const readyCards = await db.prepare(
3995
+ `SELECT c.token_id, t.slug, t.concept
3996
+ FROM cards c
3997
+ JOIN tokens t ON t.id = c.token_id
3998
+ WHERE c.user_id = ? AND c.blocked = 1
3999
+ AND (SELECT COUNT(*) FROM prerequisites p
4000
+ WHERE p.token_id = c.token_id) =
4001
+ (SELECT COUNT(*) FROM prerequisites p
4002
+ JOIN cards pc ON pc.token_id = p.requires_id
4003
+ AND pc.user_id = c.user_id
4004
+ WHERE p.token_id = c.token_id
4005
+ AND pc.reps >= 1 AND pc.blocked = 0)`
4006
+ ).all(userId);
4007
+ if (readyCards.length === 0) break;
4008
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4009
+ const placeholders = readyCards.map(() => "?").join(",");
4010
+ await db.prepare(
4011
+ `UPDATE cards SET blocked = 0, due_at = ?
4012
+ WHERE user_id = ? AND token_id IN (${placeholders})`
4013
+ ).run(now, userId, ...readyCards.map((card) => card.token_id));
4014
+ for (const card of readyCards) {
3964
4015
  unblocked.push({ slug: card.slug, concept: card.concept });
3965
4016
  }
3966
4017
  }
@@ -4137,10 +4188,14 @@ async function prepareSessionSynthesis(db, input8) {
4137
4188
  await buildSkillPatterns(db),
4138
4189
  input8.explicitPatterns ?? []
4139
4190
  );
4191
+ const patternTokens = await getTokensBySlugs(
4192
+ db,
4193
+ patterns.map((pattern) => pattern.slug)
4194
+ );
4140
4195
  const validPatterns = [];
4141
4196
  const tokens = /* @__PURE__ */ new Map();
4142
4197
  for (const pattern of patterns) {
4143
- const token = await getTokenBySlug(db, pattern.slug);
4198
+ const token = patternTokens.get(pattern.slug);
4144
4199
  if (!token || token.deprecated_at) continue;
4145
4200
  validPatterns.push(pattern);
4146
4201
  tokens.set(pattern.slug, token);
@@ -4153,13 +4208,13 @@ async function prepareSessionSynthesis(db, input8) {
4153
4208
  const minConfidence = input8.minConfidence ?? "medium";
4154
4209
  if (session.execution_context === "ui") {
4155
4210
  const reports = readUiObservationLog(input8.sessionId);
4156
- for (const report of reports) {
4157
- for (const candidate of report.candidateTokens) {
4158
- if (tokens.has(candidate.slug)) continue;
4159
- const token = await getTokenBySlug(db, candidate.slug);
4160
- if (token && !token.deprecated_at) {
4161
- tokens.set(candidate.slug, token);
4162
- }
4211
+ const candidateTokens = await getTokensBySlugs(
4212
+ db,
4213
+ reports.flatMap((report) => report.candidateTokens).map((candidate) => candidate.slug).filter((slug) => !tokens.has(slug))
4214
+ );
4215
+ for (const [slug, token] of candidateTokens) {
4216
+ if (!token.deprecated_at) {
4217
+ tokens.set(slug, token);
4163
4218
  }
4164
4219
  }
4165
4220
  const { candidates: candidates2, skippedLowConfidence: skippedLowConfidence2 } = buildUiSynthesisCandidates(
@@ -5122,7 +5177,8 @@ async function buildReviewQueue(db, options) {
5122
5177
  c.state AS state,
5123
5178
  c.due_at AS due_at,
5124
5179
  t.source_link AS source_link,
5125
- t.question AS question
5180
+ t.question AS question,
5181
+ t.question_source AS question_source
5126
5182
  FROM cards c
5127
5183
  JOIN tokens t ON t.id = c.token_id
5128
5184
  WHERE c.user_id = ?
@@ -5152,7 +5208,8 @@ async function buildReviewQueue(db, options) {
5152
5208
  c.state AS state,
5153
5209
  c.due_at AS due_at,
5154
5210
  t.source_link AS source_link,
5155
- t.question AS question
5211
+ t.question AS question,
5212
+ t.question_source AS question_source
5156
5213
  FROM cards c
5157
5214
  JOIN tokens t ON t.id = c.token_id
5158
5215
  WHERE c.user_id = ?
@@ -5221,7 +5278,8 @@ function rowToItem(row) {
5221
5278
  state: row.state,
5222
5279
  dueAt: row.due_at,
5223
5280
  sourceLink: row.source_link,
5224
- question: row.question
5281
+ question: row.question,
5282
+ questionSource: row.question_source
5225
5283
  };
5226
5284
  }
5227
5285
  function intersperseNew(reviews, newCards, interval) {
@@ -7700,6 +7758,9 @@ async function generateQuestionViaLLM(db, input8) {
7700
7758
  const bloom = input8.bloomLevel >= 1 && input8.bloomLevel <= 5 ? input8.bloomLevel : 1;
7701
7759
  const verb = BLOOM_VERBS2[bloom];
7702
7760
  const langName = LANGUAGE_NAMES[cfg.locale] || "English";
7761
+ const existingQuestion = input8.existingQuestion?.trim();
7762
+ const variationGuideline = existingQuestion ? `
7763
+ 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.` : "";
7703
7764
  const systemPrompt = `You are ZAM, a highly precise agentic skills trainer.
7704
7765
  Your task is to generate a single, clear, conceptual active-recall question (flashcard front) in ${langName} for a knowledge token.
7705
7766
 
@@ -7707,12 +7768,13 @@ Guidelines:
7707
7768
  1. The question MUST match the Bloom level: ${verb} (Level ${bloom}).
7708
7769
  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.
7709
7770
  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.
7710
- 4. Output ONLY the raw question text in ${langName}. Do not include any preamble, headers, markdown fences, or conversational filler.`;
7771
+ 4. Output ONLY the raw question text in ${langName}. Do not include any preamble, headers, markdown fences, or conversational filler.${variationGuideline}`;
7711
7772
  const userPrompt = `Domain: ${input8.domain}
7712
7773
  Slug: ${input8.slug}
7713
7774
  Concept to Recall (DO NOT REVEAL IN QUESTION): ${input8.concept}
7714
7775
  Context: ${input8.context || "(none)"}
7715
- ${input8.sourceLinkContent ? `Source Reference:
7776
+ ${existingQuestion ? `Canonical Question (vary, do not repeat verbatim): ${existingQuestion}
7777
+ ` : ""}${input8.sourceLinkContent ? `Source Reference:
7716
7778
  ${input8.sourceLinkContent}` : ""}
7717
7779
 
7718
7780
  Active-Recall Question:`;
@@ -8811,7 +8873,8 @@ async function fetchWithInteractiveTimeout(url, options = {}) {
8811
8873
  }
8812
8874
  async function ensureHighQualityQuestion(db, token) {
8813
8875
  const { enabled } = await getLlmConfig(db);
8814
- if (enabled) {
8876
+ const variationEnabled = await getSetting(db, "llm.dynamic_questions") !== "false";
8877
+ if (enabled && variationEnabled) {
8815
8878
  try {
8816
8879
  let sourceLinkContent = null;
8817
8880
  if (token.sourceLink) {
@@ -8827,10 +8890,10 @@ async function ensureHighQualityQuestion(db, token) {
8827
8890
  concept: token.concept,
8828
8891
  domain: token.domain,
8829
8892
  bloomLevel: token.bloomLevel,
8830
- sourceLinkContent
8893
+ sourceLinkContent,
8894
+ existingQuestion: token.question
8831
8895
  });
8832
8896
  if (generated.text.trim().length > 0) {
8833
- await updateToken(db, token.slug, { question: generated.text });
8834
8897
  return {
8835
8898
  question: generated.text,
8836
8899
  source: "llm",
@@ -9038,7 +9101,7 @@ async function readWebLink(url) {
9038
9101
  redirect: "manual",
9039
9102
  signal: controller.signal,
9040
9103
  headers: {
9041
- "User-Agent": "ZAM-Content-Studio/0.9.2"
9104
+ "User-Agent": "ZAM-Content-Studio/0.9.4"
9042
9105
  }
9043
9106
  });
9044
9107
  if (res.status >= 300 && res.status < 400) {
@@ -9783,7 +9846,10 @@ async function addToken(db, params) {
9783
9846
  context: params.context,
9784
9847
  symbiosis_mode: params.symbiosisMode,
9785
9848
  source_link: params.sourceLink ?? null,
9786
- question: params.question ?? null
9849
+ question: params.question ?? null,
9850
+ // Bridge/MCP callers are agents: their questions are LLM-authored and
9851
+ // stay refreshable. Humans author questions via the token CLI instead.
9852
+ question_source: params.question ? "llm" : void 0
9787
9853
  });
9788
9854
  for (const context of assignedContexts) {
9789
9855
  await assignTokenToContext(db, token.id, context.id);