zam-core 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { readFileSync as readFileSync16 } from "fs";
5
5
  import { dirname as dirname10, join as join25 } from "path";
6
6
  import { fileURLToPath as fileURLToPath5 } from "url";
7
- import { Command as Command25 } from "commander";
7
+ import { Command as Command26 } from "commander";
8
8
 
9
9
  // src/cli/commands/agent.ts
10
10
  import { existsSync as existsSync13 } from "fs";
@@ -543,6 +543,7 @@ var SCHEMA = `
543
543
  CREATE TABLE IF NOT EXISTS tokens (
544
544
  id TEXT PRIMARY KEY,
545
545
  slug TEXT UNIQUE NOT NULL,
546
+ title TEXT NOT NULL DEFAULT '',
546
547
  concept TEXT NOT NULL,
547
548
  domain TEXT NOT NULL DEFAULT '',
548
549
  bloom_level INTEGER NOT NULL DEFAULT 1 CHECK (bloom_level BETWEEN 1 AND 5),
@@ -691,6 +692,8 @@ CREATE INDEX IF NOT EXISTS idx_cards_token_user ON cards(token_id, user_id);
691
692
  CREATE INDEX IF NOT EXISTS idx_review_logs_card ON review_logs(card_id);
692
693
  CREATE INDEX IF NOT EXISTS idx_review_logs_user ON review_logs(user_id, reviewed_at);
693
694
  CREATE INDEX IF NOT EXISTS idx_session_steps_session ON session_steps(session_id);
695
+ CREATE INDEX IF NOT EXISTS idx_tokens_title ON tokens(title);
696
+ CREATE INDEX IF NOT EXISTS idx_tokens_domain ON tokens(domain);
694
697
  `;
695
698
 
696
699
  // src/kernel/db/sync-adapter.ts
@@ -1024,6 +1027,15 @@ async function runMigrations(db) {
1024
1027
  embedded_at TEXT NOT NULL DEFAULT (datetime('now'))
1025
1028
  )
1026
1029
  `);
1030
+ if (!tokenCols.some((c) => c.name === "title")) {
1031
+ await db.exec(
1032
+ `ALTER TABLE tokens ADD COLUMN title TEXT NOT NULL DEFAULT ''`
1033
+ );
1034
+ }
1035
+ await db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_title ON tokens(title)`);
1036
+ await db.exec(
1037
+ `CREATE INDEX IF NOT EXISTS idx_tokens_domain ON tokens(domain)`
1038
+ );
1027
1039
  }
1028
1040
 
1029
1041
  // src/kernel/db/snapshot.ts
@@ -1516,12 +1528,14 @@ async function createToken(db, input8) {
1516
1528
  if (bloom < 1 || bloom > 5) {
1517
1529
  throw new Error(`bloom_level must be between 1 and 5, got ${bloom}`);
1518
1530
  }
1531
+ const title = input8.title ?? "";
1519
1532
  await db.prepare(`
1520
- INSERT INTO tokens (id, slug, concept, domain, bloom_level, context, symbiosis_mode, source_link, question, provider, topic_id, created_at, updated_at)
1521
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1533
+ INSERT INTO tokens (id, slug, title, concept, domain, bloom_level, context, symbiosis_mode, source_link, question, provider, topic_id, created_at, updated_at)
1534
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1522
1535
  `).run(
1523
1536
  id,
1524
1537
  input8.slug,
1538
+ title,
1525
1539
  input8.concept,
1526
1540
  input8.domain ?? "",
1527
1541
  bloom,
@@ -1564,6 +1578,10 @@ async function updateToken(db, slug, updates) {
1564
1578
  }
1565
1579
  const fields = [];
1566
1580
  const values = [];
1581
+ if (updates.title !== void 0) {
1582
+ fields.push("title = ?");
1583
+ values.push(updates.title ?? "");
1584
+ }
1567
1585
  if (updates.concept !== void 0) {
1568
1586
  fields.push("concept = ?");
1569
1587
  values.push(updates.concept);
@@ -1669,13 +1687,14 @@ async function deleteToken(db, slug) {
1669
1687
  await db.transaction(async (tx) => {
1670
1688
  const now = (/* @__PURE__ */ new Date()).toISOString();
1671
1689
  const skillRows = await tx.prepare("SELECT id, token_slugs FROM agent_skills").all();
1690
+ const skillUpdateStmt = tx.prepare(
1691
+ "UPDATE agent_skills SET token_slugs = ?, updated_at = ? WHERE id = ?"
1692
+ );
1672
1693
  for (const row of skillRows) {
1673
1694
  const tokenSlugs = JSON.parse(row.token_slugs);
1674
1695
  const filtered = tokenSlugs.filter((tokenSlug) => tokenSlug !== slug);
1675
1696
  if (filtered.length !== tokenSlugs.length) {
1676
- await tx.prepare(
1677
- "UPDATE agent_skills SET token_slugs = ?, updated_at = ? WHERE id = ?"
1678
- ).run(JSON.stringify(filtered), now, row.id);
1697
+ await skillUpdateStmt.run(JSON.stringify(filtered), now, row.id);
1679
1698
  }
1680
1699
  }
1681
1700
  await tx.prepare("DELETE FROM tokens WHERE id = ?").run(token.id);
@@ -1689,10 +1708,16 @@ async function findTokens(db, query) {
1689
1708
  const shortTerms = searchTokens.filter((t2) => t2.length <= 2);
1690
1709
  const longTerms = searchTokens.filter((t2) => t2.length > 2);
1691
1710
  const scoreMap = /* @__PURE__ */ new Map();
1692
- const likeSQL = `SELECT * FROM tokens WHERE deprecated_at IS NULL AND (lower(slug) LIKE ? OR lower(concept) LIKE ? OR lower(domain) LIKE ?)`;
1711
+ const likeSQL = `SELECT * FROM tokens WHERE deprecated_at IS NULL AND (lower(slug) LIKE ? OR lower(title) LIKE ? OR lower(concept) LIKE ? OR lower(domain) LIKE ?)`;
1712
+ const likeStmt = db.prepare(likeSQL);
1693
1713
  for (const term of longTerms) {
1694
1714
  const pattern = `%${term}%`;
1695
- const rows = await db.prepare(likeSQL).all(pattern, pattern, pattern);
1715
+ const rows = await likeStmt.all(
1716
+ pattern,
1717
+ pattern,
1718
+ pattern,
1719
+ pattern
1720
+ );
1696
1721
  for (const row of rows) {
1697
1722
  const entry = scoreMap.get(row.id);
1698
1723
  if (entry) {
@@ -1705,7 +1730,7 @@ async function findTokens(db, query) {
1705
1730
  if (shortTerms.length > 0 || longTerms.length === 0) {
1706
1731
  const allTokens = await db.prepare("SELECT * FROM tokens WHERE deprecated_at IS NULL").all();
1707
1732
  for (const token of allTokens) {
1708
- const words = `${token.slug} ${token.concept} ${token.domain}`.toLowerCase().split(/[\s,.\-_/\\:;!?()[\]{}]+/).filter(Boolean);
1733
+ const words = `${token.slug} ${token.title} ${token.concept} ${token.domain}`.toLowerCase().split(/[\s,.\-_/\\:;!?()[\]{}]+/).filter(Boolean);
1709
1734
  let matchCount = 0;
1710
1735
  for (const term of shortTerms.length > 0 ? shortTerms : searchTokens) {
1711
1736
  for (const w of words) {
@@ -1739,6 +1764,11 @@ async function listTokens(db, options) {
1739
1764
  tokens = await db.prepare(
1740
1765
  "SELECT * FROM tokens WHERE domain = ? AND deprecated_at IS NULL ORDER BY bloom_level, slug"
1741
1766
  ).all(options.domain);
1767
+ } else if (options?.domainPrefix) {
1768
+ const prefix = options.domainPrefix;
1769
+ tokens = await db.prepare(
1770
+ `SELECT * FROM tokens WHERE (domain = ? OR domain LIKE ?) AND deprecated_at IS NULL ORDER BY bloom_level, slug`
1771
+ ).all(prefix, `${prefix}/%`);
1742
1772
  } else {
1743
1773
  tokens = await db.prepare(
1744
1774
  "SELECT * FROM tokens WHERE deprecated_at IS NULL ORDER BY bloom_level, domain, slug"
@@ -1750,7 +1780,20 @@ async function listTokens(db, options) {
1750
1780
  return tokens;
1751
1781
  }
1752
1782
  function slugify(text) {
1753
- return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
1783
+ return text.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
1784
+ }
1785
+ function getShortSlug(slug, domainPrefix) {
1786
+ if (domainPrefix) {
1787
+ const folded = domainPrefix.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1788
+ if (folded && slug.startsWith(`${folded}-`)) {
1789
+ return slug.substring(folded.length + 1);
1790
+ }
1791
+ }
1792
+ return slug;
1793
+ }
1794
+ function getDisplayTitle(t2, activeDomainScope) {
1795
+ if (t2.title?.trim()) return t2.title.trim();
1796
+ return getShortSlug(t2.slug, activeDomainScope);
1754
1797
  }
1755
1798
  async function generateTokenSlug(db, domain, concept, question) {
1756
1799
  const baseText = question && question.trim().length > 0 ? question : concept;
@@ -1780,6 +1823,7 @@ async function listPersonalCards(db, userId, options) {
1780
1823
  SELECT
1781
1824
  t.id AS tokenId,
1782
1825
  t.slug,
1826
+ t.title,
1783
1827
  t.concept,
1784
1828
  t.domain,
1785
1829
  t.bloom_level AS bloomLevel,
@@ -1881,6 +1925,7 @@ async function importCurriculumCards(db, userId, cards) {
1881
1925
  );
1882
1926
  token = await createToken(tx, {
1883
1927
  slug: finalSlug,
1928
+ title: card.title,
1884
1929
  concept: card.concept,
1885
1930
  domain: card.domain,
1886
1931
  bloom_level: bloom,
@@ -1998,23 +2043,28 @@ async function confirmCardSplit(db, userId, originalSlug, action, originalQuesti
1998
2043
  (/* @__PURE__ */ new Date()).toISOString(),
1999
2044
  originalToken.id
2000
2045
  );
2046
+ const insertPrereqStmt = tx.prepare(
2047
+ "INSERT OR IGNORE INTO prerequisites (token_id, requires_id) VALUES (?, ?)"
2048
+ );
2001
2049
  for (const propToken of proposalTokens) {
2002
- await tx.prepare(
2003
- "INSERT OR IGNORE INTO prerequisites (token_id, requires_id) VALUES (?, ?)"
2004
- ).run(originalToken.id, propToken.id);
2050
+ await insertPrereqStmt.run(originalToken.id, propToken.id);
2005
2051
  }
2006
2052
  await tx.prepare(
2007
2053
  "UPDATE cards SET blocked = 1 WHERE token_id = ? AND user_id = ?"
2008
2054
  ).run(originalToken.id, userId);
2055
+ const checkPrereqsStmt = tx.prepare(
2056
+ "SELECT COUNT(*) as n FROM prerequisites WHERE token_id = ?"
2057
+ );
2058
+ const unblockCardStmt = tx.prepare(
2059
+ "UPDATE cards SET blocked = 0, due_at = ? WHERE id = ?"
2060
+ );
2009
2061
  for (const propToken of proposalTokens) {
2010
2062
  const card = await ensureCard(tx, propToken.id, userId);
2011
2063
  if (card.blocked === 1) {
2012
- const prereqOfPrereq = await tx.prepare(
2013
- "SELECT COUNT(*) as n FROM prerequisites WHERE token_id = ?"
2014
- ).get(propToken.id);
2064
+ const prereqOfPrereq = await checkPrereqsStmt.get(propToken.id);
2015
2065
  if (prereqOfPrereq.n === 0) {
2016
2066
  const now = (/* @__PURE__ */ new Date()).toISOString();
2017
- await tx.prepare("UPDATE cards SET blocked = 0, due_at = ? WHERE id = ?").run(now, card.id);
2067
+ await unblockCardStmt.run(now, card.id);
2018
2068
  }
2019
2069
  }
2020
2070
  }
@@ -2085,6 +2135,7 @@ async function confirmFoundations(db, userId, originalSlug, proposals) {
2085
2135
  );
2086
2136
  token = await createToken(tx, {
2087
2137
  slug: finalSlug,
2138
+ title: card.title,
2088
2139
  concept: card.concept,
2089
2140
  domain: card.domain,
2090
2141
  bloom_level: bloom,
@@ -2151,6 +2202,7 @@ async function confirmSourceImport(db, userId, sourceId, proposals) {
2151
2202
  }
2152
2203
  token = await createToken(tx, {
2153
2204
  slug: finalSlug,
2205
+ title: card.title,
2154
2206
  concept: card.concept,
2155
2207
  domain: card.domain,
2156
2208
  bloom_level: bloom,
@@ -2219,9 +2271,9 @@ async function buildAncestorMap(db) {
2219
2271
  }
2220
2272
  return map;
2221
2273
  }
2222
- async function wouldCreateCycle(db, tokenId, requiresId) {
2274
+ async function wouldCreateCycle(db, tokenId, requiresId, ancestors) {
2223
2275
  if (tokenId === requiresId) return true;
2224
- const ancestors = await buildAncestorMap(db);
2276
+ const map = ancestors ?? await buildAncestorMap(db);
2225
2277
  const visited = /* @__PURE__ */ new Set();
2226
2278
  const queue = [requiresId];
2227
2279
  while (queue.length > 0) {
@@ -2229,7 +2281,7 @@ async function wouldCreateCycle(db, tokenId, requiresId) {
2229
2281
  if (current === tokenId) return true;
2230
2282
  if (visited.has(current)) continue;
2231
2283
  visited.add(current);
2232
- const parents = ancestors.get(current);
2284
+ const parents = map.get(current);
2233
2285
  if (parents) {
2234
2286
  for (const parent of parents) {
2235
2287
  if (!visited.has(parent)) queue.push(parent);
@@ -2253,7 +2305,7 @@ async function addPrerequisite(db, tokenId, requiresId) {
2253
2305
  }
2254
2306
  async function getPrerequisites(db, tokenId) {
2255
2307
  return await db.prepare(
2256
- `SELECT p.token_id, p.requires_id, t.slug, t.concept, t.domain, t.bloom_level
2308
+ `SELECT p.token_id, p.requires_id, t.slug, t.title, t.concept, t.domain, t.bloom_level
2257
2309
  FROM prerequisites p
2258
2310
  JOIN tokens t ON t.id = p.requires_id
2259
2311
  WHERE p.token_id = ?`
@@ -2261,7 +2313,7 @@ async function getPrerequisites(db, tokenId) {
2261
2313
  }
2262
2314
  async function getDependents(db, tokenId) {
2263
2315
  return await db.prepare(
2264
- `SELECT p.token_id, p.requires_id, t.slug, t.concept, t.domain, t.bloom_level
2316
+ `SELECT p.token_id, p.requires_id, t.slug, t.title, t.concept, t.domain, t.bloom_level
2265
2317
  FROM prerequisites p
2266
2318
  JOIN tokens t ON t.id = p.token_id
2267
2319
  WHERE p.requires_id = ?`
@@ -2292,6 +2344,7 @@ async function getTokenNeighborhood(db, tokenId, userId) {
2292
2344
  const toNode = (t2, card) => ({
2293
2345
  id: t2.id,
2294
2346
  slug: t2.slug,
2347
+ title: t2.title ?? t2.slug,
2295
2348
  concept: t2.concept,
2296
2349
  domain: t2.domain,
2297
2350
  bloom_level: t2.bloom_level,
@@ -2311,6 +2364,7 @@ async function getTokenNeighborhood(db, tokenId, userId) {
2311
2364
  {
2312
2365
  id: p.requires_id,
2313
2366
  slug: p.slug,
2367
+ title: p.title,
2314
2368
  concept: p.concept,
2315
2369
  domain: p.domain,
2316
2370
  bloom_level: p.bloom_level
@@ -2323,6 +2377,7 @@ async function getTokenNeighborhood(db, tokenId, userId) {
2323
2377
  {
2324
2378
  id: d.token_id,
2325
2379
  slug: d.slug,
2380
+ title: d.title,
2326
2381
  concept: d.concept,
2327
2382
  domain: d.domain,
2328
2383
  bloom_level: d.bloom_level
@@ -2437,7 +2492,8 @@ import { createHash as createHash2 } from "crypto";
2437
2492
  function embeddingContentForToken(t2) {
2438
2493
  return `${t2.concept}
2439
2494
  ${t2.question ?? ""}
2440
- ${t2.domain}`;
2495
+ ${t2.domain}
2496
+ ${t2.title ?? ""}`;
2441
2497
  }
2442
2498
  function computeContentHash(text) {
2443
2499
  return createHash2("sha256").update(text, "utf8").digest("hex");
@@ -4601,6 +4657,7 @@ async function buildReviewQueue(db, options) {
4601
4657
  c.id AS card_id,
4602
4658
  c.token_id AS token_id,
4603
4659
  t.slug AS slug,
4660
+ t.title AS title,
4604
4661
  t.concept AS concept,
4605
4662
  t.domain AS domain,
4606
4663
  t.bloom_level AS bloom_level,
@@ -4622,6 +4679,7 @@ async function buildReviewQueue(db, options) {
4622
4679
  c.id AS card_id,
4623
4680
  c.token_id AS token_id,
4624
4681
  t.slug AS slug,
4682
+ t.title AS title,
4625
4683
  t.concept AS concept,
4626
4684
  t.domain AS domain,
4627
4685
  t.bloom_level AS bloom_level,
@@ -4681,6 +4739,7 @@ function rowToItem(row) {
4681
4739
  cardId: row.card_id,
4682
4740
  tokenId: row.token_id,
4683
4741
  slug: row.slug,
4742
+ title: getDisplayTitle(row),
4684
4743
  concept: row.concept,
4685
4744
  domain: row.domain,
4686
4745
  bloomLevel: row.bloom_level,
@@ -4789,6 +4848,71 @@ async function searchTokensHybrid(db, query, opts) {
4789
4848
  return results.slice(0, limit);
4790
4849
  }
4791
4850
 
4851
+ // src/kernel/search/suggestions.ts
4852
+ async function suggestFoundations(db, opts) {
4853
+ const minSimilarity = opts.minSimilarity ?? 0.45;
4854
+ const maxSimilarity = opts.maxSimilarity ?? 0.85;
4855
+ const limit = opts.limit ?? 5;
4856
+ const targetBloomLevel = opts.targetBloomLevel ?? 5;
4857
+ if (minSimilarity >= maxSimilarity) {
4858
+ return [];
4859
+ }
4860
+ const embedded = await listEmbeddedTokens(db, opts.model);
4861
+ if (embedded.length === 0) {
4862
+ return [];
4863
+ }
4864
+ const queryVec = Float32Array.from(opts.queryEmbedding);
4865
+ const candidates = [];
4866
+ for (const row of embedded) {
4867
+ if (opts.targetTokenId && row.token.id === opts.targetTokenId) {
4868
+ continue;
4869
+ }
4870
+ const similarity = cosineSimilarity(queryVec, row.embedding);
4871
+ if (similarity >= minSimilarity && similarity < maxSimilarity) {
4872
+ candidates.push({ token: row.token, similarity });
4873
+ }
4874
+ }
4875
+ candidates.sort((a, b) => {
4876
+ if (Math.abs(a.similarity - b.similarity) > 1e-9) {
4877
+ return b.similarity - a.similarity;
4878
+ }
4879
+ return a.token.slug.localeCompare(b.token.slug);
4880
+ });
4881
+ const topCandidates = candidates.slice(0, limit);
4882
+ const prereqIds = /* @__PURE__ */ new Set();
4883
+ let ancestors;
4884
+ if (opts.targetTokenId) {
4885
+ const prereqs = await getPrerequisites(db, opts.targetTokenId);
4886
+ for (const p of prereqs) {
4887
+ prereqIds.add(p.requires_id);
4888
+ }
4889
+ ancestors = await buildAncestorMap(db);
4890
+ }
4891
+ const results = [];
4892
+ for (const cand of topCandidates) {
4893
+ let alreadyPrerequisite = false;
4894
+ let wouldCreateCycleFlag = false;
4895
+ const bloomAboveTarget = cand.token.bloom_level > targetBloomLevel;
4896
+ if (opts.targetTokenId) {
4897
+ alreadyPrerequisite = prereqIds.has(cand.token.id);
4898
+ wouldCreateCycleFlag = await wouldCreateCycle(
4899
+ db,
4900
+ opts.targetTokenId,
4901
+ cand.token.id,
4902
+ ancestors
4903
+ );
4904
+ }
4905
+ results.push({
4906
+ token: cand.token,
4907
+ similarity: cand.similarity,
4908
+ alreadyPrerequisite,
4909
+ wouldCreateCycle: wouldCreateCycleFlag,
4910
+ bloomAboveTarget
4911
+ });
4912
+ }
4913
+ return results;
4914
+ }
4915
+
4792
4916
  // src/kernel/system/hooks.ts
4793
4917
  import {
4794
4918
  appendFileSync as appendFileSync3,
@@ -6575,6 +6699,11 @@ function parseGeneratedCardArray(responseText, label, limits) {
6575
6699
  );
6576
6700
  }
6577
6701
  }
6702
+ if (card.title !== void 0 && (typeof card.title !== "string" || card.title.trim().length === 0)) {
6703
+ throw new Error(
6704
+ `Invalid ${label} card at index ${index}: title must be a non-empty string if provided`
6705
+ );
6706
+ }
6578
6707
  if (typeof card.bloom_level !== "number" || !Number.isInteger(card.bloom_level) || card.bloom_level < 1 || card.bloom_level > 5) {
6579
6708
  throw new Error(
6580
6709
  `Invalid ${label} card at index ${index}: bloom_level must be an integer from 1 to 5`
@@ -6588,6 +6717,7 @@ function parseGeneratedCardArray(responseText, label, limits) {
6588
6717
  return {
6589
6718
  question: card.question,
6590
6719
  concept: card.concept,
6720
+ title: card.title ? card.title : void 0,
6591
6721
  domain: card.domain,
6592
6722
  context: card.context,
6593
6723
  bloom_level: card.bloom_level,
@@ -6638,10 +6768,11 @@ Your task is to analyze curriculum objectives, syllabus requirements, or textboo
6638
6768
  For each extracted learning card, you MUST generate:
6639
6769
  1. "question": A clear, concise active-recall question testing the concept. The question must not reveal the answer itself.
6640
6770
  2. "concept": The reference answer, core fact, or target conceptual explanation.
6641
- 3. "domain": The category of the card. Use "${targetCategory}" as the default, but you may refine it if a specific sub-topic is evident.
6642
- 4. "context": The exact sentence or short excerpt from the source text that this card is based on.
6643
- 5. "bloom_level": Initial Bloom cognitive level (1 = Remember, 2 = Understand, 3 = Apply, 4 = Analyze, 5 = Synthesize).
6644
- 6. "symbiosis_mode": ZAM agent symbiosis level: "shadowing" (reading/monitoring), "copilot" (interactive helper), or "autonomy" (autonomous tasks).
6771
+ 3. "title": A short, human-friendly title for the knowledge graph node. Concise name for the concept. No domain prefix. Human-readable, natural language. Support Unicode (umlauts, Chinese, Japanese, Arabic, etc.). Name the core idea as well as possible.
6772
+ 4. "domain": The category of the card. Use "${targetCategory}" as the default, but you may refine it if a specific sub-topic is evident.
6773
+ 5. "context": The exact sentence or short excerpt from the source text that this card is based on.
6774
+ 6. "bloom_level": Initial Bloom cognitive level (1 = Remember, 2 = Understand, 3 = Apply, 4 = Analyze, 5 = Synthesize).
6775
+ 7. "symbiosis_mode": ZAM agent symbiosis level: "shadowing" (reading/monitoring), "copilot" (interactive helper), or "autonomy" (autonomous tasks).
6645
6776
 
6646
6777
  Guidelines:
6647
6778
  - Break complex requirements into multiple separate, atomic cards.
@@ -7570,6 +7701,114 @@ async function ensureHighQualityQuestion(db, token) {
7570
7701
  }
7571
7702
  return null;
7572
7703
  }
7704
+ async function generateTitleViaLLM(db, input8, opts = {}) {
7705
+ const cfg = await getProviderForRole(db, "text");
7706
+ const endpoint = await resolveUsableTextEndpoint(db);
7707
+ const systemPrompt = `You are an expert at naming knowledge items for a personal knowledge graph.
7708
+ Your task: given a knowledge token's concept (the full reference answer), question, domain, and optional context, produce a short, descriptive, human-friendly TITLE.
7709
+
7710
+ Strict rules from the project ADR:
7711
+ - Concise name for the concept (\u2264 80 chars ideal).
7712
+ - Thoughtful, memorable name \u2014 NOT a definition or the first sentence of the concept.
7713
+ - NEVER echo the domain name (e.g. do not say "Node Drain" if domain is "axon-ivy"; just "Node Drain Protection").
7714
+ - Prefer a name over spoiling the full concept.
7715
+ - Support Unicode (umlauts, etc.).
7716
+ - Output the title in the same language as the concept/content (e.g., German if the concept is German, English if the concept is English).
7717
+ - Output ONLY the raw title text. No preamble, quotes, explanations, or markdown.
7718
+
7719
+ Example good titles:
7720
+ - "RAG Retrieval Quality Evaluation" (for RAG failure modes focused on retrieval)
7721
+ - "Pythagorean Theorem Converse Proof"
7722
+ - "macOS Applications Directory Layout"
7723
+ - "Chronotype Sleep Preference"`;
7724
+ const userPrompt = `Domain: ${input8.domain}
7725
+ Slug: ${input8.slug}
7726
+ Question: ${input8.question || "(none)"}
7727
+ Concept: ${input8.concept}
7728
+ Context: ${input8.context || "(none)"}
7729
+
7730
+ Title:`;
7731
+ const url = `${endpoint.url}/chat/completions`;
7732
+ const apiKey = endpoint.apiKey;
7733
+ const model = endpoint.model;
7734
+ try {
7735
+ const res = await fetchWithInteractiveTimeout(url, {
7736
+ method: "POST",
7737
+ headers: {
7738
+ "Content-Type": "application/json",
7739
+ Authorization: `Bearer ${apiKey}`
7740
+ },
7741
+ body: JSON.stringify({
7742
+ model,
7743
+ messages: [
7744
+ { role: "system", content: systemPrompt },
7745
+ { role: "user", content: userPrompt }
7746
+ ],
7747
+ temperature: 0.2,
7748
+ max_tokens: 100
7749
+ }),
7750
+ locale: cfg.locale,
7751
+ timeoutMs: opts.timeoutMs,
7752
+ hardTimeoutMs: opts.timeoutMs
7753
+ });
7754
+ const text = await readChatContent(res, "title generation");
7755
+ return {
7756
+ text: text.trim(),
7757
+ model,
7758
+ providerName: endpoint.providerName
7759
+ };
7760
+ } catch (_e) {
7761
+ return {
7762
+ text: input8.concept.split(/[.;]/)[0].trim().substring(0, 80),
7763
+ model: "fallback",
7764
+ providerName: "heuristic"
7765
+ };
7766
+ }
7767
+ }
7768
+ async function repairUmlautsViaLLM(db, input8, opts = {}) {
7769
+ const cfg = await getProviderForRole(db, "text");
7770
+ const endpoint = await resolveUsableTextEndpoint(db);
7771
+ const systemPrompt = `You are an expert editor specializing in German language orthography.
7772
+ Your task: given a text that may contain legacy ASCII-folded German umlauts (like 'ue' instead of '\xFC', 'ae' instead of '\xE4', 'oe' instead of '\xF6', 'Ue' instead of '\xDC', etc.), repair them back to proper German umlauts (\xE4, \xF6, \xFC, \xC4, \xD6, \xDC, \xDF) where appropriate.
7773
+
7774
+ Strict rules:
7775
+ - ONLY change character sequences that are clearly meant to be German umlauts (e.g. change "Ueber" to "\xDCber", "fuer" to "f\xFCr", "moeglich" to "m\xF6glich").
7776
+ - DO NOT change words that are correct in their current form (e.g. do NOT change "nahe" to "n\xE4he", do NOT change English words or technical terms, do NOT change names like "Ueda").
7777
+ - Output ONLY the repaired text. No preamble, no explanation, no markdown formatting.`;
7778
+ const userPrompt = `Text to repair:
7779
+ """
7780
+ ${input8.text}
7781
+ """
7782
+
7783
+ Repaired text:`;
7784
+ const url = `${endpoint.url}/chat/completions`;
7785
+ const apiKey = endpoint.apiKey;
7786
+ const model = endpoint.model;
7787
+ try {
7788
+ const res = await fetchWithInteractiveTimeout(url, {
7789
+ method: "POST",
7790
+ headers: {
7791
+ "Content-Type": "application/json",
7792
+ Authorization: `Bearer ${apiKey}`
7793
+ },
7794
+ body: JSON.stringify({
7795
+ model,
7796
+ messages: [
7797
+ { role: "system", content: systemPrompt },
7798
+ { role: "user", content: userPrompt }
7799
+ ],
7800
+ temperature: 0.1
7801
+ }),
7802
+ locale: cfg.locale,
7803
+ timeoutMs: opts.timeoutMs,
7804
+ hardTimeoutMs: opts.timeoutMs
7805
+ });
7806
+ const text = await readChatContent(res, "umlaut repair");
7807
+ return text.trim();
7808
+ } catch (_e) {
7809
+ return input8.text;
7810
+ }
7811
+ }
7573
7812
 
7574
7813
  // src/cli/adapters/source-reader.ts
7575
7814
  var MAX_SOURCE_BYTES = 2 * 1024 * 1024;
@@ -7881,7 +8120,7 @@ function cleanHtmlText(html) {
7881
8120
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
7882
8121
  }
7883
8122
  function normalizeForComparison(str) {
7884
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
8123
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
7885
8124
  }
7886
8125
 
7887
8126
  // src/cli/curriculum/providers/bildungsplan-bw/manifest.ts
@@ -8052,7 +8291,7 @@ function cleanHtmlText2(html) {
8052
8291
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
8053
8292
  }
8054
8293
  function normalizeForComparison2(str) {
8055
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
8294
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
8056
8295
  }
8057
8296
 
8058
8297
  // src/cli/curriculum/providers/bildungsplan-hamburg/manifest.ts
@@ -8210,7 +8449,7 @@ function cleanHtmlText3(html) {
8210
8449
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
8211
8450
  }
8212
8451
  function normalizeForComparison3(str) {
8213
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
8452
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
8214
8453
  }
8215
8454
 
8216
8455
  // src/cli/curriculum/providers/fachanforderungen-sh/manifest.ts
@@ -8367,7 +8606,7 @@ function cleanHtmlText4(html) {
8367
8606
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
8368
8607
  }
8369
8608
  function normalizeForComparison4(str) {
8370
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
8609
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
8371
8610
  }
8372
8611
 
8373
8612
  // src/cli/curriculum/providers/kerncurriculum-hessen/manifest.ts
@@ -8561,7 +8800,7 @@ function cleanHtmlText5(html) {
8561
8800
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
8562
8801
  }
8563
8802
  function normalizeForComparison5(str) {
8564
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
8803
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
8565
8804
  }
8566
8805
 
8567
8806
  // src/cli/curriculum/providers/kerncurriculum-niedersachsen/manifest.ts
@@ -8733,7 +8972,7 @@ function cleanHtmlText6(html) {
8733
8972
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
8734
8973
  }
8735
8974
  function normalizeForComparison6(str) {
8736
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
8975
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
8737
8976
  }
8738
8977
 
8739
8978
  // src/cli/curriculum/providers/kernlehrplan-nrw/manifest.ts
@@ -8948,7 +9187,7 @@ function cleanHtmlText7(html) {
8948
9187
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
8949
9188
  }
8950
9189
  function normalizeForComparison7(str) {
8951
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
9190
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
8952
9191
  }
8953
9192
 
8954
9193
  // src/cli/curriculum/providers/lehrplaene-rp/manifest.ts
@@ -9105,7 +9344,7 @@ function cleanHtmlText8(html) {
9105
9344
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
9106
9345
  }
9107
9346
  function normalizeForComparison8(str) {
9108
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
9347
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
9109
9348
  }
9110
9349
 
9111
9350
  // src/cli/curriculum/providers/lehrplan-saarland/manifest.ts
@@ -9262,7 +9501,7 @@ function cleanHtmlText9(html) {
9262
9501
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
9263
9502
  }
9264
9503
  function normalizeForComparison9(str) {
9265
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
9504
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
9266
9505
  }
9267
9506
 
9268
9507
  // src/cli/curriculum/providers/lehrplan-sachsen/manifest.ts
@@ -9447,7 +9686,7 @@ function cleanHtmlText10(html) {
9447
9686
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
9448
9687
  }
9449
9688
  function normalizeForComparison10(str) {
9450
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
9689
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
9451
9690
  }
9452
9691
 
9453
9692
  // src/cli/curriculum/providers/lehrplan-thueringen/manifest.ts
@@ -9602,7 +9841,7 @@ function cleanHtmlText11(html) {
9602
9841
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
9603
9842
  }
9604
9843
  function normalizeForComparison11(str) {
9605
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
9844
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
9606
9845
  }
9607
9846
 
9608
9847
  // src/cli/curriculum/providers/lehrplanplus-bayern/manifest.ts
@@ -9846,7 +10085,7 @@ function cleanHtmlText12(html) {
9846
10085
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
9847
10086
  }
9848
10087
  function normalizeForComparison12(str) {
9849
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
10088
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
9850
10089
  }
9851
10090
 
9852
10091
  // src/cli/curriculum/providers/rahmenlehrplan-berlin-brandenburg/manifest.ts
@@ -10031,7 +10270,7 @@ function cleanHtmlText13(html) {
10031
10270
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
10032
10271
  }
10033
10272
  function normalizeForComparison13(str) {
10034
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
10273
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
10035
10274
  }
10036
10275
 
10037
10276
  // src/cli/curriculum/providers/rahmenplan-mv/manifest.ts
@@ -10188,7 +10427,7 @@ function cleanHtmlText14(html) {
10188
10427
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
10189
10428
  }
10190
10429
  function normalizeForComparison14(str) {
10191
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
10430
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
10192
10431
  }
10193
10432
 
10194
10433
  // src/cli/curriculum/providers/rahmenrichtlinien-st/manifest.ts
@@ -10343,7 +10582,7 @@ function cleanHtmlText15(html) {
10343
10582
  return text.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join("\n");
10344
10583
  }
10345
10584
  function normalizeForComparison15(str) {
10346
- return str.toLowerCase().replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
10585
+ return str.toLowerCase().replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss").replace(/&[a-z0-9#]+;/gi, "").replace(/[^a-z0-9]/gi, "");
10347
10586
  }
10348
10587
 
10349
10588
  // src/cli/curriculum/registry.ts
@@ -10541,7 +10780,8 @@ async function findPossibleDuplicates(db, candidate, embed = embedQuery) {
10541
10780
  const queryText = embeddingContentForToken({
10542
10781
  concept: candidate.concept,
10543
10782
  question: candidate.question ?? null,
10544
- domain: candidate.domain ?? ""
10783
+ domain: candidate.domain ?? "",
10784
+ title: candidate.title ?? null
10545
10785
  });
10546
10786
  const q2 = await embed(db, queryText);
10547
10787
  if (!q2) {
@@ -10559,9 +10799,7 @@ async function findPossibleDuplicates(db, candidate, embed = embedQuery) {
10559
10799
  );
10560
10800
  }
10561
10801
  }
10562
- const thresholdStr = await getSetting(db, "search.dedup_threshold");
10563
- const parsed = thresholdStr ? Number.parseFloat(thresholdStr) : Number.NaN;
10564
- const threshold = Number.isFinite(parsed) && parsed > 0 && parsed <= 1 ? parsed : 0.85;
10802
+ const threshold = await resolveDedupThreshold(db);
10565
10803
  const hits = await searchTokensHybrid(db, queryText, {
10566
10804
  queryEmbedding: q2.vector,
10567
10805
  model: q2.model,
@@ -10574,12 +10812,23 @@ async function findPossibleDuplicates(db, candidate, embed = embedQuery) {
10574
10812
  results.push({
10575
10813
  slug: hit.slug,
10576
10814
  concept: hit.concept,
10815
+ title: hit.title,
10577
10816
  similarity: hit.similarity
10578
10817
  });
10579
10818
  }
10580
10819
  }
10581
10820
  return results;
10582
10821
  }
10822
+ async function resolveDedupThreshold(db) {
10823
+ const thresholdStr = await getSetting(db, "search.dedup_threshold");
10824
+ const parsed = thresholdStr ? Number.parseFloat(thresholdStr) : Number.NaN;
10825
+ return Number.isFinite(parsed) && parsed > 0 && parsed <= 1 ? parsed : 0.85;
10826
+ }
10827
+ async function resolveSuggestMinSimilarity(db) {
10828
+ const minStr = await getSetting(db, "search.suggest_min_similarity");
10829
+ const parsed = minStr ? Number.parseFloat(minStr) : Number.NaN;
10830
+ return Number.isFinite(parsed) && parsed > 0 && parsed <= 1 ? parsed : 0.45;
10831
+ }
10583
10832
 
10584
10833
  // src/cli/llm/vision.ts
10585
10834
  import { randomBytes } from "crypto";
@@ -12327,10 +12576,12 @@ bridgeCommand.command("add-token").description("Create a token + card from JSON
12327
12576
  const possibleDuplicates = await findPossibleDuplicates(db, {
12328
12577
  concept: data?.concept,
12329
12578
  question: data?.question ?? null,
12330
- domain: data?.domain
12579
+ domain: data?.domain,
12580
+ title: data?.title ?? null
12331
12581
  });
12332
12582
  const token = await createToken(db, {
12333
12583
  slug: data?.slug,
12584
+ title: data?.title,
12334
12585
  concept: data?.concept,
12335
12586
  domain: data?.domain,
12336
12587
  bloom_level: data?.bloom_level ?? 1,
@@ -12410,6 +12661,8 @@ bridgeCommand.command("relevant-tokens").description("Find tokens relevant to a
12410
12661
  const card = await getCard(db, t2.id, userId);
12411
12662
  tokens.push({
12412
12663
  slug: t2.slug,
12664
+ title: t2.title,
12665
+ display_title: getDisplayTitle(t2),
12413
12666
  concept: t2.concept,
12414
12667
  domain: t2.domain,
12415
12668
  bloom_level: t2.bloom_level,
@@ -12428,6 +12681,118 @@ bridgeCommand.command("relevant-tokens").description("Find tokens relevant to a
12428
12681
  });
12429
12682
  });
12430
12683
  });
12684
+ bridgeCommand.command("suggest-foundations").description("Propose existing tokens as foundation/prerequisite candidates").option("--user <id>", "User ID (default: whoami)").action(async (_opts) => {
12685
+ await withDb2(async (db) => {
12686
+ let raw;
12687
+ if (isServeMode) {
12688
+ raw = serveStdinPayload ?? "";
12689
+ } else {
12690
+ const chunks = [];
12691
+ for await (const chunk of process.stdin) {
12692
+ chunks.push(chunk);
12693
+ }
12694
+ raw = Buffer.concat(chunks).toString("utf-8").trim();
12695
+ }
12696
+ if (!raw) {
12697
+ jsonError("No input received on stdin. Pipe JSON.");
12698
+ }
12699
+ let data;
12700
+ try {
12701
+ data = JSON.parse(raw);
12702
+ } catch {
12703
+ jsonError("Invalid JSON input");
12704
+ }
12705
+ let queryText = "";
12706
+ let targetTokenId;
12707
+ let targetBloomLevel;
12708
+ let targetJson = null;
12709
+ if (data?.slug !== void 0) {
12710
+ if (typeof data.slug !== "string" || data.slug.trim() === "") {
12711
+ jsonError("Invalid slug");
12712
+ }
12713
+ const token = await getTokenBySlug(db, data.slug);
12714
+ if (!token) {
12715
+ jsonError(`Token not found: ${data.slug}`);
12716
+ }
12717
+ queryText = embeddingContentForToken(token);
12718
+ targetTokenId = token.id;
12719
+ targetBloomLevel = token.bloom_level;
12720
+ targetJson = { slug: token.slug };
12721
+ } else {
12722
+ if (!data?.concept || typeof data.concept !== "string" || data.concept.trim() === "") {
12723
+ jsonError("JSON must include a non-empty 'slug' or 'concept' field");
12724
+ }
12725
+ queryText = embeddingContentForToken({
12726
+ concept: data.concept,
12727
+ question: typeof data.question === "string" ? data.question : null,
12728
+ domain: typeof data.domain === "string" ? data.domain : "",
12729
+ title: typeof data.title === "string" ? data.title : null
12730
+ });
12731
+ if (data.bloom_level !== void 0) {
12732
+ if (typeof data.bloom_level !== "number" || !Number.isInteger(data.bloom_level) || data.bloom_level < 1 || data.bloom_level > 5) {
12733
+ jsonError("bloom_level must be an integer between 1 and 5");
12734
+ }
12735
+ targetBloomLevel = data.bloom_level;
12736
+ }
12737
+ }
12738
+ let limit = data?.limit ?? 5;
12739
+ if (typeof limit !== "number" || limit <= 0 || !Number.isInteger(limit)) {
12740
+ limit = 5;
12741
+ }
12742
+ if (limit > 20) {
12743
+ limit = 20;
12744
+ }
12745
+ const q2 = await embedQuery(db, queryText);
12746
+ if (q2 === null) {
12747
+ jsonOut2({
12748
+ semantic: false,
12749
+ target: targetJson,
12750
+ suggestions: []
12751
+ });
12752
+ return;
12753
+ }
12754
+ try {
12755
+ await ensureTokenEmbeddings(db, {
12756
+ limit: 100,
12757
+ dims: q2.vector.length
12758
+ });
12759
+ } catch {
12760
+ }
12761
+ const maxSimilarity = await resolveDedupThreshold(db);
12762
+ const minSimilarity = await resolveSuggestMinSimilarity(db);
12763
+ if (minSimilarity >= maxSimilarity) {
12764
+ jsonOut2({
12765
+ semantic: true,
12766
+ target: targetJson,
12767
+ suggestions: []
12768
+ });
12769
+ return;
12770
+ }
12771
+ const suggestions = await suggestFoundations(db, {
12772
+ queryEmbedding: q2.vector,
12773
+ model: q2.model,
12774
+ targetTokenId,
12775
+ targetBloomLevel,
12776
+ limit,
12777
+ minSimilarity,
12778
+ maxSimilarity
12779
+ });
12780
+ jsonOut2({
12781
+ semantic: true,
12782
+ target: targetJson,
12783
+ suggestions: suggestions.map((s) => ({
12784
+ slug: s.token.slug,
12785
+ concept: s.token.concept,
12786
+ domain: s.token.domain,
12787
+ bloom_level: s.token.bloom_level,
12788
+ similarity: s.similarity,
12789
+ already_prerequisite: s.alreadyPrerequisite,
12790
+ would_create_cycle: s.wouldCreateCycle,
12791
+ bloom_above_target: s.bloomAboveTarget
12792
+ }))
12793
+ });
12794
+ });
12795
+ });
12431
12796
  bridgeCommand.command("discover-skills").description(
12432
12797
  "Analyze monitor logs across sessions to discover recurring patterns"
12433
12798
  ).option(
@@ -13593,12 +13958,18 @@ bridgeCommand.command("list-tokens").description(
13593
13958
  ).option(
13594
13959
  "--user <id>",
13595
13960
  "User ID (default: whoami) \u2014 when provided, includes personal card info"
13596
- ).option("--domain <domain>", "Filter by domain").action(async (opts) => {
13961
+ ).option("--domain <domain>", "Filter by exact domain").option(
13962
+ "--domain-prefix <prefix>",
13963
+ "Filter by domain prefix (e.g. docuware-cops) \u2014 uses / separator for hierarchy"
13964
+ ).action(async (opts) => {
13597
13965
  await withDb2(async (db) => {
13598
13966
  const userId = opts.user ? await resolveUser(opts, db, { json: true }) : void 0;
13967
+ const listOpts = {};
13968
+ if (opts.domain) listOpts.domain = opts.domain;
13969
+ if (opts.domainPrefix) listOpts.domainPrefix = opts.domainPrefix;
13599
13970
  const tokens = await listTokens(
13600
13971
  db,
13601
- opts.domain ? { domain: opts.domain } : void 0
13972
+ Object.keys(listOpts).length ? listOpts : void 0
13602
13973
  );
13603
13974
  const cardMap = /* @__PURE__ */ new Map();
13604
13975
  if (userId && tokens.length > 0) {
@@ -13615,6 +13986,8 @@ bridgeCommand.command("list-tokens").description(
13615
13986
  return {
13616
13987
  id: t2.id,
13617
13988
  slug: t2.slug,
13989
+ title: t2.title,
13990
+ display_title: getDisplayTitle(t2),
13618
13991
  concept: t2.concept,
13619
13992
  domain: t2.domain,
13620
13993
  bloomLevel: t2.bloom_level,
@@ -13648,6 +14021,8 @@ bridgeCommand.command("get-neighborhood").description(
13648
14021
  const mapToken = (nt) => ({
13649
14022
  id: nt.id,
13650
14023
  slug: nt.slug,
14024
+ title: nt.title,
14025
+ display_title: getDisplayTitle(nt),
13651
14026
  concept: nt.concept,
13652
14027
  domain: nt.domain,
13653
14028
  bloomLevel: nt.bloom_level,
@@ -13679,7 +14054,7 @@ bridgeCommand.command("personal-card-list").description("List and search persona
13679
14054
  jsonOut2({ cards });
13680
14055
  });
13681
14056
  });
13682
- bridgeCommand.command("personal-card-create").description("Atomically create a token and its personal card (JSON)").option("--user <id>", "User ID (default: whoami)").requiredOption("--concept <concept>", "Concept description / answer").option("--domain <domain>", "Knowledge category / domain", "").option("--question <question>", "Question prompt for recall").option("--source-link <link>", "Source file path or reference URL").option("--bloom <level>", "Bloom taxonomy level (1-5)", "1").option(
14057
+ bridgeCommand.command("personal-card-create").description("Atomically create a token and its personal card (JSON)").option("--user <id>", "User ID (default: whoami)").requiredOption("--concept <concept>", "Concept description / answer").option("--title <title>", "Human-friendly display title").option("--domain <domain>", "Knowledge category / domain", "").option("--question <question>", "Question prompt for recall").option("--source-link <link>", "Source file path or reference URL").option("--bloom <level>", "Bloom taxonomy level (1-5)", "1").option(
13683
14058
  "--mode <mode>",
13684
14059
  "Symbiosis mode: shadowing | copilot | autonomy | none",
13685
14060
  "none"
@@ -13707,6 +14082,7 @@ bridgeCommand.command("personal-card-create").description("Atomically create a t
13707
14082
  const { token, card } = await db.transaction(async (tx) => {
13708
14083
  const createdToken = await createToken(tx, {
13709
14084
  slug,
14085
+ title: opts.title,
13710
14086
  concept: opts.concept,
13711
14087
  domain: opts.domain,
13712
14088
  bloom_level: bloom,
@@ -13723,6 +14099,8 @@ bridgeCommand.command("personal-card-create").description("Atomically create a t
13723
14099
  token: {
13724
14100
  id: token.id,
13725
14101
  slug: token.slug,
14102
+ title: token.title,
14103
+ display_title: getDisplayTitle(token),
13726
14104
  concept: token.concept,
13727
14105
  domain: token.domain,
13728
14106
  bloomLevel: token.bloom_level,
@@ -13744,13 +14122,14 @@ bridgeCommand.command("personal-card-create").description("Atomically create a t
13744
14122
  });
13745
14123
  });
13746
14124
  });
13747
- bridgeCommand.command("personal-card-update").description("Update the mutable token fields of a personal card (JSON)").option("--user <id>", "User ID (default: whoami)").requiredOption("--slug <slug>", "Token slug to update").option("--concept <concept>", "Updated concept text").option("--domain <domain>", "Updated domain / category").option("--bloom <level>", "Updated Bloom taxonomy level (1-5)").option("--context <context>", "Updated context").option(
14125
+ bridgeCommand.command("personal-card-update").description("Update the mutable token fields of a personal card (JSON)").option("--user <id>", "User ID (default: whoami)").requiredOption("--slug <slug>", "Token slug to update").option("--title <title>", "Updated display title").option("--concept <concept>", "Updated concept text").option("--domain <domain>", "Updated domain / category").option("--bloom <level>", "Updated Bloom taxonomy level (1-5)").option("--context <context>", "Updated context").option(
13748
14126
  "--mode <mode>",
13749
14127
  "Updated symbiosis mode: shadowing | copilot | autonomy | none"
13750
14128
  ).option("--source-link <link>", "Updated source link").option("--question <question>", "Updated question text").action(async (opts) => {
13751
14129
  await withDb2(async (db) => {
13752
14130
  const userId = await resolveUser(opts, db, { json: true });
13753
14131
  const updates = {};
14132
+ if (opts.title !== void 0) updates.title = opts.title;
13754
14133
  if (opts.concept !== void 0) updates.concept = opts.concept;
13755
14134
  if (opts.domain !== void 0) updates.domain = opts.domain;
13756
14135
  if (opts.bloom !== void 0) {
@@ -13792,6 +14171,8 @@ bridgeCommand.command("personal-card-update").description("Update the mutable to
13792
14171
  token: {
13793
14172
  id: token.id,
13794
14173
  slug: token.slug,
14174
+ title: token.title,
14175
+ display_title: getDisplayTitle(token),
13795
14176
  concept: token.concept,
13796
14177
  domain: token.domain,
13797
14178
  bloomLevel: token.bloom_level,
@@ -13824,7 +14205,12 @@ bridgeCommand.command("personal-card-remove").description(
13824
14205
  success: true,
13825
14206
  preview: true,
13826
14207
  requiresConfirmation: true,
13827
- token: { id: token.id, slug: token.slug },
14208
+ token: {
14209
+ id: token.id,
14210
+ slug: token.slug,
14211
+ title: token.title,
14212
+ display_title: getDisplayTitle(token)
14213
+ },
13828
14214
  impact
13829
14215
  });
13830
14216
  return;
@@ -13853,7 +14239,12 @@ bridgeCommand.command("personal-card-delete").description("Hard-delete a token a
13853
14239
  success: true,
13854
14240
  preview: true,
13855
14241
  requiresConfirmation: true,
13856
- token: { id: token.id, slug: token.slug },
14242
+ token: {
14243
+ id: token.id,
14244
+ slug: token.slug,
14245
+ title: token.title,
14246
+ display_title: getDisplayTitle(token)
14247
+ },
13857
14248
  impact
13858
14249
  });
13859
14250
  return;
@@ -14745,11 +15136,567 @@ async function setupTurso(urlArg, tokenArg, mode) {
14745
15136
  }
14746
15137
  }
14747
15138
 
15139
+ // src/cli/commands/doctor.ts
15140
+ import { Command as Command5 } from "commander";
15141
+ function shouldApplyFixes(opts) {
15142
+ return opts.fix === true && opts.dryRun !== true;
15143
+ }
15144
+ async function confirmDeterministicChanges(opts, message) {
15145
+ if (opts.yes) return true;
15146
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
15147
+ console.error(
15148
+ "Cannot request confirmation in a non-interactive terminal. Re-run with --yes to apply deterministic fixes."
15149
+ );
15150
+ return false;
15151
+ }
15152
+ const { confirm: confirm5 } = await import("@inquirer/prompts");
15153
+ return confirm5({ message, default: false });
15154
+ }
15155
+ function canRunInteractiveChoices(opts, taskName) {
15156
+ if (opts.yes) {
15157
+ console.error(
15158
+ `--yes cannot choose how to resolve ${taskName}. Re-run without --yes in an interactive terminal.`
15159
+ );
15160
+ return false;
15161
+ }
15162
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
15163
+ console.error(
15164
+ `${taskName} fixes require an interactive terminal because each change needs an explicit choice.`
15165
+ );
15166
+ return false;
15167
+ }
15168
+ return true;
15169
+ }
15170
+ function getWeakTitleReason(t2) {
15171
+ const title = (t2.title || "").trim();
15172
+ if (!title) {
15173
+ return "Missing or empty title";
15174
+ }
15175
+ if (title === t2.slug) {
15176
+ return "Title is equal to technical slug";
15177
+ }
15178
+ if (title.length < 3) {
15179
+ return "Title is suspiciously short";
15180
+ }
15181
+ if (title.length > 100) {
15182
+ return "Title is too long (> 100 chars)";
15183
+ }
15184
+ if (t2.domain) {
15185
+ const domParts = t2.domain.split(/[-/]/).filter((p) => p.length > 2);
15186
+ const titleLower2 = title.toLowerCase();
15187
+ for (const part of domParts) {
15188
+ if (titleLower2.includes(part.toLowerCase())) {
15189
+ return `Domain echo (contains '${part}' from domain '${t2.domain}')`;
15190
+ }
15191
+ }
15192
+ }
15193
+ const conceptClean = (t2.concept || "").trim().toLowerCase();
15194
+ const titleLower = title.toLowerCase();
15195
+ if (conceptClean.startsWith(titleLower) && t2.concept.length > title.length + 5 && !/[.!?]/.test(title)) {
15196
+ const titleWords = titleLower.split(/\s+/).length;
15197
+ if (titleWords <= 5) {
15198
+ return "Concept-prefix copy";
15199
+ }
15200
+ }
15201
+ if (title.endsWith("?") || /^(what|how|why|who|where|when|which|is|are|was|were|can|do|does|did|ueber|fuer|nahe)\b/i.test(
15202
+ title
15203
+ )) {
15204
+ return "Question stump or interrogative structure";
15205
+ }
15206
+ if (title.length > 3 && /[a-zA-Z]/.test(title)) {
15207
+ if (title === title.toUpperCase()) {
15208
+ return "All uppercase";
15209
+ }
15210
+ if (title === title.toLowerCase()) {
15211
+ return "All lowercase";
15212
+ }
15213
+ }
15214
+ return null;
15215
+ }
15216
+ function stripLeadingDomainWords(value, domain) {
15217
+ let result = value.trim();
15218
+ for (const word of domain.split(/[-/]/).filter(Boolean)) {
15219
+ const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15220
+ result = result.replace(new RegExp(`^${escaped}\\s+`, "i"), "");
15221
+ }
15222
+ return result.trim();
15223
+ }
15224
+ function truncateTitle(value) {
15225
+ const trimmed = value.trim().replace(/^["']+|["']+$/g, "");
15226
+ return trimmed.length <= 80 ? trimmed : `${trimmed.substring(0, 77).trimEnd()}...`;
15227
+ }
15228
+ function titleFromSlug(token) {
15229
+ return getShortSlug(token.slug, token.domain).split(/[-_]+/).filter(Boolean).map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join(" ");
15230
+ }
15231
+ function validatedTitleProposal(token, raw) {
15232
+ const candidates = [
15233
+ raw,
15234
+ (token.concept || "").split(/[.;]/)[0] || "",
15235
+ titleFromSlug(token)
15236
+ ];
15237
+ for (const candidate of candidates) {
15238
+ const normalized = truncateTitle(
15239
+ stripLeadingDomainWords(candidate, token.domain || "")
15240
+ );
15241
+ if (normalized.length > 0 && getWeakTitleReason({ ...token, title: normalized }) === null) {
15242
+ return normalized;
15243
+ }
15244
+ }
15245
+ return null;
15246
+ }
15247
+ var doctorTasks = [
15248
+ {
15249
+ name: "titles",
15250
+ description: "Backfill missing/weak human-friendly titles (no domain echoes, descriptive names).",
15251
+ run: async (db, opts) => {
15252
+ const tokens = await listTokens(db, {});
15253
+ const needing = tokens.map((t2) => {
15254
+ const reason = getWeakTitleReason(t2);
15255
+ return reason ? { token: t2, reason } : null;
15256
+ }).filter((x) => x !== null);
15257
+ console.log(
15258
+ `Found ${needing.length} tokens needing title work (of ${tokens.length}).`
15259
+ );
15260
+ if (needing.length === 0) return;
15261
+ const cfg = await getLlmConfig(db);
15262
+ const useLLM = cfg.enabled && !opts.noLlm;
15263
+ console.log(
15264
+ `Title generation mode: ${useLLM ? "LLM" : "Heuristic fallback"}${opts.timeoutMs ? ` (timeout: ${opts.timeoutMs}ms)` : ""}`
15265
+ );
15266
+ console.log("Generating proposed titles...");
15267
+ const proposals = [];
15268
+ let rejected = 0;
15269
+ for (let i = 0; i < needing.length; i++) {
15270
+ const item = needing[i];
15271
+ process.stdout.write(
15272
+ `\r [${i + 1}/${needing.length}] ${item.token.slug.slice(0, 40)}...`
15273
+ );
15274
+ let rawProposal = "";
15275
+ if (useLLM) {
15276
+ try {
15277
+ const gen = await generateTitleViaLLM(
15278
+ db,
15279
+ {
15280
+ slug: item.token.slug,
15281
+ concept: item.token.concept,
15282
+ domain: item.token.domain,
15283
+ question: item.token.question,
15284
+ context: item.token.context
15285
+ },
15286
+ { timeoutMs: opts.timeoutMs }
15287
+ );
15288
+ rawProposal = gen.text;
15289
+ } catch (_e) {
15290
+ rawProposal = (item.token.concept || "").split(/[.;]/)[0]?.trim() || item.token.slug.replace(/-/g, " ");
15291
+ }
15292
+ } else {
15293
+ rawProposal = (item.token.concept || "").split(/[.;]/)[0]?.trim() || item.token.slug.replace(/-/g, " ");
15294
+ }
15295
+ const proposed = validatedTitleProposal(item.token, rawProposal);
15296
+ if (!proposed) {
15297
+ rejected++;
15298
+ continue;
15299
+ }
15300
+ proposals.push({
15301
+ token: item.token,
15302
+ oldTitle: item.token.title,
15303
+ newTitle: proposed,
15304
+ reason: item.reason
15305
+ });
15306
+ }
15307
+ process.stdout.write("\n");
15308
+ if (rejected > 0) {
15309
+ console.warn(
15310
+ `Skipped ${rejected} token(s) because no proposal passed title quality validation.`
15311
+ );
15312
+ }
15313
+ if (proposals.length === 0) return;
15314
+ if (!shouldApplyFixes(opts)) {
15315
+ console.log("\nProposed changes (Dry run):");
15316
+ for (const prop of proposals) {
15317
+ console.log(
15318
+ ` ${prop.token.slug}: "${prop.oldTitle}" -> "${prop.newTitle}" (Reason: ${prop.reason})`
15319
+ );
15320
+ }
15321
+ console.log("\nRun with --fix to apply these changes.");
15322
+ return;
15323
+ }
15324
+ if (!opts.yes) {
15325
+ console.log("\nProposed changes:");
15326
+ for (const prop of proposals) {
15327
+ console.log(
15328
+ ` ${prop.token.slug}: "${prop.oldTitle}" -> "${prop.newTitle}" (Reason: ${prop.reason})`
15329
+ );
15330
+ }
15331
+ }
15332
+ const confirmed = await confirmDeterministicChanges(
15333
+ opts,
15334
+ `Apply these ${proposals.length} proposed titles?`
15335
+ );
15336
+ if (!confirmed) {
15337
+ console.log("Cancelled. No titles were updated.");
15338
+ return;
15339
+ }
15340
+ let fixed = 0;
15341
+ for (const prop of proposals) {
15342
+ await updateToken(db, prop.token.slug, { title: prop.newTitle });
15343
+ fixed++;
15344
+ console.log(`Set title for ${prop.token.slug} -> ${prop.newTitle}`);
15345
+ }
15346
+ console.log(`Successfully fixed ${fixed} titles.`);
15347
+ }
15348
+ },
15349
+ {
15350
+ name: "texts",
15351
+ description: "Repair legacy ASCII umlauts in prose fields (question/concept/context).",
15352
+ run: async (db, opts) => {
15353
+ const tokens = await listTokens(db, {});
15354
+ const cfg = await getLlmConfig(db);
15355
+ const useLLM = cfg.enabled && !opts.noLlm;
15356
+ const replacements = [
15357
+ [/\bUeber\b/g, "\xDCber"],
15358
+ [/\bueber\b/g, "\xFCber"],
15359
+ [/\bFuer\b/g, "F\xFCr"],
15360
+ [/\bfuer\b/g, "f\xFCr"],
15361
+ [/\bMuehe\b/g, "M\xFChe"],
15362
+ [/\bmuehe\b/g, "m\xFChe"],
15363
+ [/\bKuer\b/g, "K\xFCr"],
15364
+ [/\bkuer\b/g, "k\xFCr"],
15365
+ [/\bUebung\b/g, "\xDCbung"],
15366
+ [/\buebung\b/g, "\xFCbung"],
15367
+ [/\bMoeglich\b/g, "M\xF6glich"],
15368
+ [/\bmoeglich\b/g, "m\xF6glich"],
15369
+ [/\bMoeglichkeiten\b/g, "M\xF6glichkeiten"],
15370
+ [/\bmoeglichkeiten\b/g, "m\xF6glichkeiten"],
15371
+ [/\bSchoen\b/g, "Sch\xF6n"],
15372
+ [/\bschoen\b/g, "sch\xF6n"],
15373
+ [/\bKoennen\b/g, "K\xF6nnen"],
15374
+ [/\bkoennen\b/g, "k\xF6nnen"],
15375
+ [/\bMuessen\b/g, "M\xFCssen"],
15376
+ [/\bmuessen\b/g, "m\xFCssen"],
15377
+ [/\bDuerfen\b/g, "D\xFCrfen"],
15378
+ [/\bduerfen\b/g, "d\xFCrfen"],
15379
+ [/\bAerger\b/g, "\xC4rger"],
15380
+ [/\baerger\b/g, "\xE4rger"],
15381
+ [/\bGruende\b/g, "Gr\xFCnde"],
15382
+ [/\bgruende\b/g, "gr\xFCnde"]
15383
+ ];
15384
+ const toFix = [];
15385
+ console.log("Analyzing prose text fields for legacy umlauts...");
15386
+ for (const t2 of tokens) {
15387
+ const fields = [
15388
+ { key: "question", val: t2.question },
15389
+ { key: "concept", val: t2.concept },
15390
+ { key: "context", val: t2.context },
15391
+ { key: "title", val: t2.title }
15392
+ ];
15393
+ for (const f of fields) {
15394
+ if (!f.val || typeof f.val !== "string") continue;
15395
+ let repaired = f.val;
15396
+ if (useLLM) {
15397
+ const hasPossibleUmlautFold = /[aou]e/i.test(f.val);
15398
+ if (hasPossibleUmlautFold) {
15399
+ try {
15400
+ repaired = await repairUmlautsViaLLM(
15401
+ db,
15402
+ { text: f.val },
15403
+ { timeoutMs: opts.timeoutMs }
15404
+ );
15405
+ if (repaired === f.val) {
15406
+ for (const [from, to] of replacements) {
15407
+ repaired = repaired.replace(from, to);
15408
+ }
15409
+ }
15410
+ } catch (_e) {
15411
+ for (const [from, to] of replacements) {
15412
+ repaired = repaired.replace(from, to);
15413
+ }
15414
+ }
15415
+ }
15416
+ } else {
15417
+ for (const [from, to] of replacements) {
15418
+ repaired = repaired.replace(from, to);
15419
+ }
15420
+ }
15421
+ if (repaired !== f.val) {
15422
+ toFix.push({ token: t2, field: f.key, old: f.val, new: repaired });
15423
+ }
15424
+ }
15425
+ }
15426
+ console.log(`Found ${toFix.length} prose fields with legacy umlauts.`);
15427
+ if (toFix.length === 0) return;
15428
+ if (!shouldApplyFixes(opts)) {
15429
+ console.log("\nProposed changes (Dry run):");
15430
+ for (const fix of toFix) {
15431
+ console.log(` ${fix.token.slug} [${fix.field}]:`);
15432
+ console.log(` Old: "${fix.old}"`);
15433
+ console.log(` New: "${fix.new}"`);
15434
+ }
15435
+ console.log("\nRun with --fix to apply these changes.");
15436
+ return;
15437
+ }
15438
+ if (!opts.yes) {
15439
+ console.log("\nProposed changes:");
15440
+ for (const fix of toFix) {
15441
+ console.log(
15442
+ ` ${fix.token.slug} [${fix.field}]: "${fix.old}" -> "${fix.new}"`
15443
+ );
15444
+ }
15445
+ }
15446
+ const confirmed = await confirmDeterministicChanges(
15447
+ opts,
15448
+ `Apply these ${toFix.length} prose fixes?`
15449
+ );
15450
+ if (!confirmed) {
15451
+ console.log("Cancelled. No changes made.");
15452
+ return;
15453
+ }
15454
+ let fixed = 0;
15455
+ for (const fix of toFix) {
15456
+ const updates = {};
15457
+ updates[fix.field] = fix.new;
15458
+ await updateToken(db, fix.token.slug, updates);
15459
+ fixed++;
15460
+ }
15461
+ console.log(
15462
+ `Fixed ${fixed} fields. (Re-embed will happen automatically for changed content.)`
15463
+ );
15464
+ }
15465
+ },
15466
+ {
15467
+ name: "duplicates",
15468
+ description: "List semantic duplicates for review/merge.",
15469
+ run: async (db, opts) => {
15470
+ const applyingFixes = shouldApplyFixes(opts);
15471
+ if (applyingFixes && !canRunInteractiveChoices(opts, "duplicate fixes")) {
15472
+ return;
15473
+ }
15474
+ const modelSetting = await getSetting(db, "llm.embedding.model");
15475
+ if (!modelSetting) {
15476
+ console.log(
15477
+ "No embedding model configured. Please configure an embedding model to scan duplicates."
15478
+ );
15479
+ return;
15480
+ }
15481
+ const model = canonicalEmbeddingModelId(modelSetting);
15482
+ if (model !== modelSetting.trim().toLowerCase()) {
15483
+ console.log(
15484
+ `Using canonical embedding model "${model}" for configured alias "${modelSetting}".`
15485
+ );
15486
+ }
15487
+ let coverage = await getEmbeddingCoverage(db, model);
15488
+ const initiallyIncomplete = coverage.missing + coverage.stale;
15489
+ if (initiallyIncomplete > 0) {
15490
+ if (applyingFixes) {
15491
+ console.log(
15492
+ `Embedding coverage is incomplete (${coverage.missing} missing, ${coverage.stale} stale). Attempting backfill...`
15493
+ );
15494
+ const result = await ensureTokenEmbeddings(db, {
15495
+ limit: coverage.tokens
15496
+ });
15497
+ coverage = await getEmbeddingCoverage(db, model);
15498
+ const remaining = coverage.missing + coverage.stale;
15499
+ if (remaining > 0) {
15500
+ console.warn(
15501
+ `Warning: Duplicate scan is incomplete: ${remaining} of ${coverage.tokens} active tokens still lack a fresh "${model}" embedding.${result.reason ? ` ${result.reason}` : ""}`
15502
+ );
15503
+ } else {
15504
+ console.log(
15505
+ `Embedding backfill complete (${result.embedded} updated).`
15506
+ );
15507
+ }
15508
+ } else {
15509
+ console.warn(
15510
+ `Warning: Duplicate scan is incomplete: ${initiallyIncomplete} of ${coverage.tokens} active tokens lack a fresh "${model}" embedding. Dry-run will not backfill them; run 'zam token reembed' first for a complete scan.`
15511
+ );
15512
+ }
15513
+ }
15514
+ console.log(`Listing embedded tokens for model "${model}"...`);
15515
+ const embedded = await listEmbeddedTokens(db, model);
15516
+ const threshold = await resolveDedupThreshold(db);
15517
+ console.log(
15518
+ `Scanning ${embedded.length} tokens for semantic duplicates (threshold: ${threshold})...`
15519
+ );
15520
+ const duplicates = [];
15521
+ const seenPairs = /* @__PURE__ */ new Set();
15522
+ for (let i = 0; i < embedded.length; i++) {
15523
+ for (let j = i + 1; j < embedded.length; j++) {
15524
+ const sim = cosineSimilarity(
15525
+ embedded[i].embedding,
15526
+ embedded[j].embedding
15527
+ );
15528
+ if (sim >= threshold) {
15529
+ const idA = embedded[i].token.id;
15530
+ const idB = embedded[j].token.id;
15531
+ const pairKey = idA < idB ? `${idA}:${idB}` : `${idB}:${idA}`;
15532
+ if (!seenPairs.has(pairKey)) {
15533
+ seenPairs.add(pairKey);
15534
+ duplicates.push({
15535
+ a: embedded[i].token,
15536
+ b: embedded[j].token,
15537
+ similarity: sim
15538
+ });
15539
+ }
15540
+ }
15541
+ }
15542
+ }
15543
+ console.log(`Found ${duplicates.length} duplicate pairs.`);
15544
+ if (duplicates.length === 0) return;
15545
+ if (!applyingFixes) {
15546
+ console.log("\nDuplicate pairs found (Dry run):");
15547
+ for (const pair of duplicates) {
15548
+ console.log(
15549
+ ` - "${pair.a.slug}" and "${pair.b.slug}" (similarity: ${pair.similarity.toFixed(3)})`
15550
+ );
15551
+ }
15552
+ console.log("\nRun with --fix to resolve duplicates interactively.");
15553
+ return;
15554
+ }
15555
+ const { select: select3 } = await import("@inquirer/prompts");
15556
+ let deprecatedCount = 0;
15557
+ for (const pair of duplicates) {
15558
+ console.log(
15559
+ `
15560
+ Duplicate pair found (similarity: ${pair.similarity.toFixed(3)}):`
15561
+ );
15562
+ console.log(
15563
+ ` 1. ${pair.a.slug}: "${pair.a.concept.substring(0, 100)}..."`
15564
+ );
15565
+ console.log(
15566
+ ` 2. ${pair.b.slug}: "${pair.b.concept.substring(0, 100)}..."`
15567
+ );
15568
+ const choice = await select3({
15569
+ message: "Select action:",
15570
+ choices: [
15571
+ { name: "Keep both", value: "keep" },
15572
+ { name: `Deprecate ${pair.a.slug}`, value: "deprecate_a" },
15573
+ { name: `Deprecate ${pair.b.slug}`, value: "deprecate_b" }
15574
+ ]
15575
+ });
15576
+ if (choice === "deprecate_a") {
15577
+ await deprecateToken(db, pair.a.slug);
15578
+ console.log(` Deprecated ${pair.a.slug}`);
15579
+ deprecatedCount++;
15580
+ } else if (choice === "deprecate_b") {
15581
+ await deprecateToken(db, pair.b.slug);
15582
+ console.log(` Deprecated ${pair.b.slug}`);
15583
+ deprecatedCount++;
15584
+ }
15585
+ }
15586
+ console.log(
15587
+ `
15588
+ Duplicates scan complete. Deprecated ${deprecatedCount} token(s).`
15589
+ );
15590
+ }
15591
+ },
15592
+ {
15593
+ name: "domains",
15594
+ description: "Help rename/unify domains into / hierarchy (slugs untouched).",
15595
+ run: async (db, opts) => {
15596
+ const tokens = await listTokens(db, {});
15597
+ const domains = Array.from(
15598
+ new Set(tokens.map((t2) => t2.domain).filter(Boolean))
15599
+ ).sort();
15600
+ console.log(`Current domains (${domains.length}):`);
15601
+ for (const d of domains) {
15602
+ const count2 = tokens.filter((t2) => t2.domain === d).length;
15603
+ const status = d.includes("/") ? "(hierarchical)" : "(flat, valid)";
15604
+ console.log(` ${d} \u2014 ${count2} token(s) ${status}`);
15605
+ }
15606
+ if (!shouldApplyFixes(opts)) {
15607
+ console.log("\nUse --fix to interactively rename/unify domains.");
15608
+ return;
15609
+ }
15610
+ if (!canRunInteractiveChoices(opts, "domain renames")) return;
15611
+ const { confirm: confirm5, input: input8 } = await import("@inquirer/prompts");
15612
+ const changes = [];
15613
+ for (const d of domains) {
15614
+ const count2 = tokens.filter((t2) => t2.domain === d).length;
15615
+ const wantRename = await confirm5({
15616
+ message: `Rename domain "${d}" (${count2} tokens)?`,
15617
+ default: false
15618
+ });
15619
+ if (wantRename) {
15620
+ const newDomain = await input8({
15621
+ message: `Enter new domain name for "${d}":`,
15622
+ validate: (val) => val.trim().length > 0 ? true : "Domain name cannot be empty"
15623
+ });
15624
+ const trimmed = newDomain.trim();
15625
+ if (trimmed === d) {
15626
+ console.log(" New name matches old name. Skipped.");
15627
+ continue;
15628
+ }
15629
+ changes.push({ oldDomain: d, newDomain: trimmed });
15630
+ }
15631
+ }
15632
+ if (changes.length > 0) {
15633
+ await db.transaction(async (tx) => {
15634
+ const now = (/* @__PURE__ */ new Date()).toISOString();
15635
+ const stmt = tx.prepare(
15636
+ "UPDATE tokens SET domain = ?, updated_at = ? WHERE domain = ?"
15637
+ );
15638
+ for (const change of changes) {
15639
+ const res = await stmt.run(change.newDomain, now, change.oldDomain);
15640
+ console.log(
15641
+ ` Renamed "${change.oldDomain}" to "${change.newDomain}" for ${res.changes} token(s).`
15642
+ );
15643
+ }
15644
+ });
15645
+ }
15646
+ console.log(
15647
+ `Domains rename complete. Renamed ${changes.length} domain(s).`
15648
+ );
15649
+ }
15650
+ }
15651
+ ];
15652
+ function parseDoctorTimeout(value) {
15653
+ const parsed = Number(value);
15654
+ if (!Number.isInteger(parsed) || parsed <= 0) {
15655
+ throw new Error("--timeout must be a positive integer in milliseconds");
15656
+ }
15657
+ return parsed;
15658
+ }
15659
+ var doctorCommand = new Command5("doctor").description(
15660
+ "Diagnose and repair the knowledge base (titles, legacy data, duplicates, domains)."
15661
+ ).option("--fix", "Apply changes (default is dry-run)").option("--dry-run", "Explicit dry run (default)").option(
15662
+ "--yes",
15663
+ "Auto-confirm deterministic title/text fixes (duplicate/domain choices remain interactive)"
15664
+ ).option("--no-llm", "Skip LLM calls, use heuristic fallback only").option(
15665
+ "--timeout <ms>",
15666
+ "LLM timeout in ms per call (default: 20000)",
15667
+ "20000"
15668
+ ).argument("[task]", "Specific task: titles, texts, duplicates, domains").action(async (taskName, opts) => {
15669
+ await withDb(async (db) => {
15670
+ const fix = !!opts.fix;
15671
+ const dryRun = !fix || !!opts.dryRun;
15672
+ const yes = !!opts.yes;
15673
+ const noLlm = opts.llm === false;
15674
+ const timeoutMs = parseDoctorTimeout(opts.timeout);
15675
+ if (!taskName) {
15676
+ console.log("Available doctor tasks:");
15677
+ for (const t2 of doctorTasks) {
15678
+ console.log(` ${t2.name} \u2014 ${t2.description}`);
15679
+ }
15680
+ console.log("\nRun with a task name, e.g. `zam doctor titles --fix`");
15681
+ return;
15682
+ }
15683
+ const task = doctorTasks.find((t2) => t2.name === taskName);
15684
+ if (!task) {
15685
+ console.error(`Unknown task: ${taskName}`);
15686
+ process.exit(1);
15687
+ }
15688
+ console.log(
15689
+ `Running doctor task: ${task.name} (fix=${fix}, dryRun=${dryRun}${noLlm ? ", noLlm=true" : ""})`
15690
+ );
15691
+ await task.run(db, { fix, dryRun, yes, noLlm, timeoutMs });
15692
+ });
15693
+ });
15694
+
14748
15695
  // src/cli/commands/git-sync.ts
14749
15696
  import { execSync as execSync5 } from "child_process";
14750
15697
  import { chmodSync as chmodSync2, existsSync as existsSync18, writeFileSync as writeFileSync8 } from "fs";
14751
15698
  import { join as join19 } from "path";
14752
- import { Command as Command5 } from "commander";
15699
+ import { Command as Command6 } from "commander";
14753
15700
  function installHook2() {
14754
15701
  const gitDir = join19(process.cwd(), ".git");
14755
15702
  if (!existsSync18(gitDir)) {
@@ -14779,7 +15726,7 @@ zam git-sync --commit HEAD --quiet
14779
15726
  process.exit(1);
14780
15727
  }
14781
15728
  }
14782
- var gitSyncCommand = new Command5("git-sync").description("Sync learning cards with recent Git file modifications").option("--commit <hash>", "Git commit hash to check", "HEAD").option("--user <id>", "User ID (default: whoami)").option("--install", "Install git post-commit hook in current repo").option("--quiet", "Suppress verbose output").action(async (opts) => {
15729
+ var gitSyncCommand = new Command6("git-sync").description("Sync learning cards with recent Git file modifications").option("--commit <hash>", "Git commit hash to check", "HEAD").option("--user <id>", "User ID (default: whoami)").option("--install", "Install git post-commit hook in current repo").option("--quiet", "Suppress verbose output").action(async (opts) => {
14783
15730
  if (opts.install) {
14784
15731
  installHook2();
14785
15732
  return;
@@ -14871,7 +15818,7 @@ ZAM Auto-Stale Complete: Scanned ${changedFiles.length} file(s).`
14871
15818
  import { existsSync as existsSync19, mkdirSync as mkdirSync11 } from "fs";
14872
15819
  import { resolve as resolve5 } from "path";
14873
15820
  import { input as input2 } from "@inquirer/prompts";
14874
- import { Command as Command6 } from "commander";
15821
+ import { Command as Command7 } from "commander";
14875
15822
  async function resolveGoalsDir() {
14876
15823
  let goalsDir;
14877
15824
  let db;
@@ -14884,7 +15831,7 @@ async function resolveGoalsDir() {
14884
15831
  }
14885
15832
  return goalsDir ? resolve5(goalsDir) : resolve5("goals");
14886
15833
  }
14887
- var goalCommand = new Command6("goal").description(
15834
+ var goalCommand = new Command7("goal").description(
14888
15835
  "Manage learning goals (markdown files)"
14889
15836
  );
14890
15837
  goalCommand.command("list").description("List all goals").option(
@@ -15057,7 +16004,7 @@ import { existsSync as existsSync20, mkdirSync as mkdirSync12, writeFileSync as
15057
16004
  import { homedir as homedir11 } from "os";
15058
16005
  import { join as join20, resolve as resolve6 } from "path";
15059
16006
  import { confirm, input as input3 } from "@inquirer/prompts";
15060
- import { Command as Command7 } from "commander";
16007
+ import { Command as Command8 } from "commander";
15061
16008
  var HOME2 = homedir11();
15062
16009
  function printLine(char = "\u2550", len = 60, color = "\x1B[36m") {
15063
16010
  console.log(`${color}${char.repeat(len)}\x1B[0m`);
@@ -15094,7 +16041,7 @@ Here, I declare the core concepts and principles I want to master.
15094
16041
  );
15095
16042
  }
15096
16043
  }
15097
- var initCommand = new Command7("init").description("Launch the guided interactive onboarding wizard").action(async () => {
16044
+ var initCommand = new Command8("init").description("Launch the guided interactive onboarding wizard").action(async () => {
15098
16045
  printLine();
15099
16046
  console.log(
15100
16047
  "\x1B[1m\x1B[32m ZAM \u2014 The Symbiotic Learning Agent Onboarding\x1B[0m"
@@ -15271,7 +16218,7 @@ var initCommand = new Command7("init").description("Launch the guided interactiv
15271
16218
 
15272
16219
  // src/cli/commands/learn.ts
15273
16220
  import { input as input5 } from "@inquirer/prompts";
15274
- import { Command as Command8 } from "commander";
16221
+ import { Command as Command9 } from "commander";
15275
16222
 
15276
16223
  // src/cli/learn-format.ts
15277
16224
  var BLOOM_VERBS3 = {
@@ -15287,16 +16234,22 @@ function clampBloom(n) {
15287
16234
  function formatHeader(input8) {
15288
16235
  const lvl = clampBloom(input8.bloomLevel);
15289
16236
  const parts = [`${BLOOM_VERBS3[lvl]} (Bloom ${lvl})`];
15290
- if (input8.domain?.trim()) {
16237
+ const name = input8.title || input8.slug;
16238
+ if (name) {
16239
+ parts.push(name);
16240
+ } else if (input8.domain?.trim()) {
15291
16241
  parts.push(input8.domain.trim());
15292
16242
  }
15293
16243
  return parts.join(" \xB7 ");
15294
16244
  }
15295
16245
  function formatReveal(input8) {
15296
16246
  const lines = [
15297
- `Token: #${input8.slug}`,
16247
+ `Token: ${input8.title || input8.slug}`,
15298
16248
  `Concept: ${input8.concept}`
15299
16249
  ];
16250
+ if (input8.title) {
16251
+ lines.push(`ID: #${input8.slug}`);
16252
+ }
15300
16253
  if (input8.context?.trim()) {
15301
16254
  lines.push("", `Context: ${input8.context.trim()}`);
15302
16255
  }
@@ -15609,7 +16562,7 @@ var STOP_WORDS = /* @__PURE__ */ new Set(["q", ":q", "quit", "stop"]);
15609
16562
  function isExitPrompt(err) {
15610
16563
  return err instanceof Error && err.name === "ExitPromptError";
15611
16564
  }
15612
- var learnCommand = new Command8("learn").description(
16565
+ var learnCommand = new Command9("learn").description(
15613
16566
  "Run a spoiler-free, in-process learning session (recall \u2192 reveal \u2192 self-rate)"
15614
16567
  ).option("--user <id>", "User ID (default: whoami)").option("--max-new <n>", "Maximum new cards", "10").option("--max-reviews <n>", "Maximum review cards", "50").option("--no-resolve", "Skip resolving source_link into the revealed answer").action(async (opts) => {
15615
16568
  let db;
@@ -15683,7 +16636,7 @@ ${t(locale, "welcome", { count: queue.items.length })}`);
15683
16636
  console.log(`
15684
16637
  ${"\u2500".repeat(50)}`);
15685
16638
  console.log(
15686
- `[${index + 1}/${queue.items.length}] ${formatHeader(item)}`
16639
+ `[${index + 1}/${queue.items.length}] ${formatHeader({ bloomLevel: item.bloomLevel, domain: item.domain })}`
15687
16640
  );
15688
16641
  console.log(`
15689
16642
  ${prompt.question}`);
@@ -15747,6 +16700,7 @@ ${t(locale, "eval_skipped", { reason: err.message })}`
15747
16700
  );
15748
16701
  const reveal = formatReveal({
15749
16702
  slug: item.slug,
16703
+ title: item.title,
15750
16704
  concept: item.concept,
15751
16705
  context: token?.context,
15752
16706
  resolved
@@ -15854,8 +16808,8 @@ learnCommand.command("open").description("Open a new terminal window running zam
15854
16808
  });
15855
16809
 
15856
16810
  // src/cli/commands/monitor.ts
15857
- import { Command as Command9 } from "commander";
15858
- var monitorCommand = new Command9("monitor").description(
16811
+ import { Command as Command10 } from "commander";
16812
+ var monitorCommand = new Command10("monitor").description(
15859
16813
  "Shell observation for real-time task monitoring"
15860
16814
  );
15861
16815
  monitorCommand.command("start").description("Output shell hook code to install monitoring").requiredOption("--session <id>", "Session ID to monitor").option(
@@ -16037,8 +16991,8 @@ monitorCommand.command("open").description("Open a new monitored terminal window
16037
16991
  });
16038
16992
 
16039
16993
  // src/cli/commands/observer.ts
16040
- import { Command as Command10 } from "commander";
16041
- var observerCommand = new Command10("observer").description(
16994
+ import { Command as Command11 } from "commander";
16995
+ var observerCommand = new Command11("observer").description(
16042
16996
  "Configure what the UI observer may capture (Layer 2 policy)"
16043
16997
  );
16044
16998
  function applyObserverListChange(current, entry, op) {
@@ -16101,7 +17055,7 @@ observerCommand.command("revoke <process>").description("Remove a process from o
16101
17055
 
16102
17056
  // src/cli/commands/profile.ts
16103
17057
  import { dirname as dirname6, resolve as resolve7 } from "path";
16104
- import { Command as Command11 } from "commander";
17058
+ import { Command as Command12 } from "commander";
16105
17059
  var C2 = {
16106
17060
  reset: "\x1B[0m",
16107
17061
  bold: "\x1B[1m",
@@ -16118,7 +17072,7 @@ function render(profile) {
16118
17072
  console.log(` data dir: ${C2.cyan}${profile.dataDir}${C2.reset}`);
16119
17073
  console.log(` database: ${C2.cyan}${profile.dbPath}${C2.reset}`);
16120
17074
  }
16121
- var profileCommand = new Command11("profile").description("Show or change this machine's ZAM install profile").option("--mode <mode>", "Set install mode: developer | default").option("--dir <path>", "Set the personal-content folder").option("--json", "Output as JSON").action(async (opts) => {
17075
+ var profileCommand = new Command12("profile").description("Show or change this machine's ZAM install profile").option("--mode <mode>", "Set install mode: developer | default").option("--dir <path>", "Set the personal-content folder").option("--json", "Output as JSON").action(async (opts) => {
16122
17076
  if (opts.mode && opts.mode !== "developer" && opts.mode !== "default") {
16123
17077
  console.error(`Invalid --mode: ${opts.mode}. Use developer or default.`);
16124
17078
  process.exit(1);
@@ -16158,8 +17112,8 @@ var profileCommand = new Command11("profile").description("Show or change this m
16158
17112
 
16159
17113
  // src/cli/commands/provider.ts
16160
17114
  import { password as password2 } from "@inquirer/prompts";
16161
- import { Command as Command12 } from "commander";
16162
- var providerCommand = new Command12("provider").description(
17115
+ import { Command as Command13 } from "commander";
17116
+ var providerCommand = new Command13("provider").description(
16163
17117
  "Configure role-based AI providers (url/model/flavor/key, per role)"
16164
17118
  );
16165
17119
  providerCommand.command("list").description("Show configured providers, role bindings, and key status").option("--json", "Output as JSON").option("--machine", "Read machine-local providers from ~/.zam/config.json").action(async (opts) => {
@@ -16394,8 +17348,8 @@ providerCommand.command("clear-key <ref>").description("Remove a stored API key"
16394
17348
  });
16395
17349
 
16396
17350
  // src/cli/commands/review.ts
16397
- import { Command as Command13 } from "commander";
16398
- var reviewCommand = new Command13("review").description("Start an interactive review session").option("--user <id>", "User ID (default: whoami)").option("--max-new <n>", "Maximum new cards", "10").option("--max-reviews <n>", "Maximum review cards", "50").option("--no-resolve", "Skip resolving source_link into inline context").action(async (opts) => {
17351
+ import { Command as Command14 } from "commander";
17352
+ var reviewCommand = new Command14("review").description("Start an interactive review session").option("--user <id>", "User ID (default: whoami)").option("--max-new <n>", "Maximum new cards", "10").option("--max-reviews <n>", "Maximum review cards", "50").option("--no-resolve", "Skip resolving source_link into inline context").action(async (opts) => {
16399
17353
  let db;
16400
17354
  try {
16401
17355
  db = await openDatabase();
@@ -16433,9 +17387,8 @@ Review session: ${queue.items.length} card(s)`);
16433
17387
  });
16434
17388
  console.log(
16435
17389
  `
16436
- [${index + 1}/${queue.items.length}] ${prompt.bloomVerb} (Bloom ${prompt.bloomLevel})`
17390
+ [${index + 1}/${queue.items.length}] ${formatHeader({ bloomLevel: item.bloomLevel, domain: item.domain })}`
16437
17391
  );
16438
- console.log(`Domain: ${prompt.domain || "(none)"}`);
16439
17392
  if (prompt.sourceLink) {
16440
17393
  console.log(`Source: ${prompt.sourceLink}`);
16441
17394
  if (opts.resolve !== false) {
@@ -16510,8 +17463,8 @@ ${"\u2550".repeat(50)}`);
16510
17463
  // src/cli/commands/session.ts
16511
17464
  import { readFileSync as readFileSync13 } from "fs";
16512
17465
  import { input as input6, select as select2 } from "@inquirer/prompts";
16513
- import { Command as Command14 } from "commander";
16514
- var sessionCommand = new Command14("session").description(
17466
+ import { Command as Command15 } from "commander";
17467
+ var sessionCommand = new Command15("session").description(
16515
17468
  "Manage learning sessions"
16516
17469
  );
16517
17470
  sessionCommand.command("start").description("Start a new learning session (review \u2192 task)").option("--user <id>", "User ID (default: whoami)").option("--task <description>", "Task description (interactive if omitted)").option(
@@ -16635,9 +17588,8 @@ Time limit reached (${maxMinutes} min). Moving to task selection.`
16635
17588
  });
16636
17589
  const elapsed = Math.round((Date.now() - startTime) / 6e4);
16637
17590
  console.log(
16638
- `[${index + 1}/${queue.items.length}] ${prompt.bloomVerb} (Bloom ${prompt.bloomLevel}) \u2014 ${elapsed}/${maxMinutes} min`
17591
+ `[${index + 1}/${queue.items.length}] ${formatHeader({ bloomLevel: item.bloomLevel, domain: item.domain })} (${elapsed}/${maxMinutes} min)`
16639
17592
  );
16640
- console.log(`Domain: ${prompt.domain || "(none)"}`);
16641
17593
  console.log(`
16642
17594
  ${prompt.question}
16643
17595
  `);
@@ -16891,8 +17843,8 @@ sessionCommand.command("end").description("End a session and show summary").requ
16891
17843
 
16892
17844
  // src/cli/commands/settings.ts
16893
17845
  import { existsSync as existsSync21 } from "fs";
16894
- import { Command as Command15 } from "commander";
16895
- var settingsCommand = new Command15("settings").description(
17846
+ import { Command as Command16 } from "commander";
17847
+ var settingsCommand = new Command16("settings").description(
16896
17848
  "Manage user settings"
16897
17849
  );
16898
17850
  var BOOLEAN_SETTING_KEYS = /* @__PURE__ */ new Set(["llm.enabled", "llm.vision.enabled"]);
@@ -17068,7 +18020,7 @@ settingsCommand.command("repos").description("Show or set Personal, Team, and Or
17068
18020
 
17069
18021
  // src/cli/commands/setup.ts
17070
18022
  import { resolve as resolve8 } from "path";
17071
- import { Command as Command16 } from "commander";
18023
+ import { Command as Command17 } from "commander";
17072
18024
  function formatDatabaseInitTarget(target) {
17073
18025
  switch (target.kind) {
17074
18026
  case "local":
@@ -17109,7 +18061,7 @@ async function activateMachineProviderConfig(db) {
17109
18061
  ` activate ${providerCount} machine-local provider(s) from ~/.zam/config.json`
17110
18062
  );
17111
18063
  }
17112
- var setupCommand = new Command16("setup").description(
18064
+ var setupCommand = new Command17("setup").description(
17113
18065
  "Link ZAM skill directories into this workspace and initialize the database"
17114
18066
  ).option("--force", "replace an unmanaged existing ZAM skill directory", false).option("--skip-init", "skip database initialization", false).option("--skip-claude-md", "skip CLAUDE.md generation", false).option("--skip-agents-md", "skip AGENTS.md generation", false).option("--target <path>", "repository/workspace directory to set up").option(
17115
18067
  "--agents <list>",
@@ -17163,8 +18115,8 @@ var setupCommand = new Command16("setup").description(
17163
18115
  );
17164
18116
 
17165
18117
  // src/cli/commands/skill.ts
17166
- import { Command as Command17 } from "commander";
17167
- var skillCommand = new Command17("skill").description(
18118
+ import { Command as Command18 } from "commander";
18119
+ var skillCommand = new Command18("skill").description(
17168
18120
  "Manage agent skill entries (task recipes)"
17169
18121
  );
17170
18122
  skillCommand.command("list").description("List all agent skills").option("--json", "Output as JSON").action(async (opts) => {
@@ -17251,7 +18203,7 @@ skillCommand.command("add").description("Register a new agent skill").requiredOp
17251
18203
  // src/cli/commands/snapshot.ts
17252
18204
  import { existsSync as existsSync22, mkdirSync as mkdirSync13, readFileSync as readFileSync14, writeFileSync as writeFileSync10 } from "fs";
17253
18205
  import { dirname as dirname7, join as join21 } from "path";
17254
- import { Command as Command18 } from "commander";
18206
+ import { Command as Command19 } from "commander";
17255
18207
  function defaultOutName() {
17256
18208
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d+Z$/, "").replace(/:/g, "-");
17257
18209
  return `zam-snapshot-${stamp}.sql`;
@@ -17265,7 +18217,7 @@ function summarize(tables) {
17265
18217
  }
17266
18218
  return { total, nonEmpty };
17267
18219
  }
17268
- var exportCmd = new Command18("export").description("Write a portable SQL-text snapshot of the database").option("--out <file>", "Output file (use - for stdout)").action(async (opts) => {
18220
+ var exportCmd = new Command19("export").description("Write a portable SQL-text snapshot of the database").option("--out <file>", "Output file (use - for stdout)").action(async (opts) => {
17269
18221
  let db;
17270
18222
  try {
17271
18223
  db = await openDatabaseWithSync({ initialize: true });
@@ -17295,7 +18247,7 @@ var exportCmd = new Command18("export").description("Write a portable SQL-text s
17295
18247
  process.exit(1);
17296
18248
  }
17297
18249
  });
17298
- var importCmd = new Command18("import").description("Restore a snapshot into the database").argument("<file>", "Snapshot file to restore").option("--force", "Overwrite a non-empty database", false).action(async (file, opts) => {
18250
+ var importCmd = new Command19("import").description("Restore a snapshot into the database").argument("<file>", "Snapshot file to restore").option("--force", "Overwrite a non-empty database", false).action(async (file, opts) => {
17299
18251
  let db;
17300
18252
  try {
17301
18253
  if (!existsSync22(file)) {
@@ -17318,7 +18270,7 @@ var importCmd = new Command18("import").description("Restore a snapshot into the
17318
18270
  process.exit(1);
17319
18271
  }
17320
18272
  });
17321
- var verifyCmd = new Command18("verify").description("Check a snapshot's manifest and checksum without importing").argument("<file>", "Snapshot file to verify").action((file) => {
18273
+ var verifyCmd = new Command19("verify").description("Check a snapshot's manifest and checksum without importing").argument("<file>", "Snapshot file to verify").action((file) => {
17322
18274
  try {
17323
18275
  if (!existsSync22(file)) {
17324
18276
  console.error(`Error: Snapshot file not found: ${file}`);
@@ -17336,11 +18288,11 @@ var verifyCmd = new Command18("verify").description("Check a snapshot's manifest
17336
18288
  process.exit(1);
17337
18289
  }
17338
18290
  });
17339
- var snapshotCommand = new Command18("snapshot").description("Export, import, or verify a portable database snapshot").addCommand(exportCmd).addCommand(importCmd).addCommand(verifyCmd);
18291
+ var snapshotCommand = new Command19("snapshot").description("Export, import, or verify a portable database snapshot").addCommand(exportCmd).addCommand(importCmd).addCommand(verifyCmd);
17340
18292
 
17341
18293
  // src/cli/commands/stats.ts
17342
- import { Command as Command19 } from "commander";
17343
- var statsCommand = new Command19("stats").description("Show learning dashboard for a user").option("--user <id>", "User ID (default: whoami)").option("--json", "Output as JSON").action(async (opts) => {
18294
+ import { Command as Command20 } from "commander";
18295
+ var statsCommand = new Command20("stats").description("Show learning dashboard for a user").option("--user <id>", "User ID (default: whoami)").option("--json", "Output as JSON").action(async (opts) => {
17344
18296
  await withDb(async (db) => {
17345
18297
  const userId = await resolveUser(opts, db);
17346
18298
  const stats = await getUserStats(db, userId);
@@ -17376,8 +18328,8 @@ var statsCommand = new Command19("stats").description("Show learning dashboard f
17376
18328
  });
17377
18329
 
17378
18330
  // src/cli/commands/token.ts
17379
- import { Command as Command20 } from "commander";
17380
- var tokenCommand = new Command20("token").description(
18331
+ import { Command as Command21 } from "commander";
18332
+ var tokenCommand = new Command21("token").description(
17381
18333
  "Manage knowledge tokens"
17382
18334
  );
17383
18335
  tokenCommand.command("register").description("Register a new knowledge token").requiredOption("--slug <slug>", "Unique token slug").requiredOption("--concept <concept>", "Concept description").option("--domain <domain>", "Knowledge domain", "").option("--bloom <level>", "Bloom taxonomy level (1-5)", "1").option("--source-link <link>", "Source file path or reference URL", "").option("--question <question>", "Specific question prompt for recall", "").option("--user <id>", "Owner of the personal card (default: whoami)").option("--no-card", "Register the token only; do not create a personal card").option("--json", "Output as JSON").option("--quiet", "Suppress output (exit code only)").action(async (opts) => {
@@ -17428,7 +18380,11 @@ tokenCommand.command("register").description("Register a new knowledge token").r
17428
18380
  )
17429
18381
  );
17430
18382
  } else {
17431
- console.log(`Registered token: ${token.slug} (${token.id})`);
18383
+ console.log(`Registered token: ${token.title || token.slug}`);
18384
+ if (token.slug !== (token.title || token.slug)) {
18385
+ console.log(` Slug: ${token.slug}`);
18386
+ }
18387
+ console.log(` ID: ${token.id}`);
17432
18388
  console.log(` Concept: ${token.concept}`);
17433
18389
  console.log(` Domain: ${token.domain || "(none)"}`);
17434
18390
  console.log(` Bloom: ${token.bloom_level}`);
@@ -17447,11 +18403,53 @@ tokenCommand.command("register").description("Register a new knowledge token").r
17447
18403
  console.log(`
17448
18404
  WARNING: Possible duplicate tokens found:`);
17449
18405
  for (const dup of possibleDuplicates) {
18406
+ const name = dup.title || dup.slug;
17450
18407
  console.log(
17451
- ` - ${dup.slug} (similarity: ${dup.similarity.toFixed(2)})`
18408
+ ` - ${name} (slug: ${dup.slug}, similarity: ${dup.similarity.toFixed(2)})`
17452
18409
  );
17453
18410
  }
17454
18411
  }
18412
+ try {
18413
+ const queryText = embeddingContentForToken({
18414
+ concept: opts.concept,
18415
+ question: question ?? null,
18416
+ domain: opts.domain ?? "",
18417
+ title: opts.title ?? null
18418
+ });
18419
+ const q2 = await embedQuery(db, queryText);
18420
+ if (q2) {
18421
+ const maxSimilarity = await resolveDedupThreshold(db);
18422
+ const minSimilarity = await resolveSuggestMinSimilarity(db);
18423
+ const suggestions = await suggestFoundations(db, {
18424
+ queryEmbedding: q2.vector,
18425
+ model: q2.model,
18426
+ targetTokenId: token.id,
18427
+ targetBloomLevel: Number(opts.bloom),
18428
+ limit: 3,
18429
+ minSimilarity,
18430
+ maxSimilarity
18431
+ });
18432
+ const filtered = suggestions.filter(
18433
+ (s) => !s.wouldCreateCycle && !s.alreadyPrerequisite
18434
+ );
18435
+ if (filtered.length > 0) {
18436
+ console.log(
18437
+ `
18438
+ Related existing tokens as potential foundations:`
18439
+ );
18440
+ for (const s of filtered) {
18441
+ const note = s.bloomAboveTarget ? " (higher bloom than target)" : "";
18442
+ console.log(
18443
+ ` - ${s.token.slug} (similarity: ${s.similarity.toFixed(2)})${note}`
18444
+ );
18445
+ }
18446
+ console.log(
18447
+ `Link with: zam token prereq --token <slug> --requires <slug> (see zam token prereq --help)`
18448
+ );
18449
+ }
18450
+ }
18451
+ } catch {
18452
+ }
17455
18453
  }
17456
18454
  });
17457
18455
  });
@@ -17483,16 +18481,18 @@ tokenCommand.command("find").description("Fuzzy search for tokens").requiredOpti
17483
18481
  console.log(`Found ${results.length} token(s):
17484
18482
  `);
17485
18483
  console.log(
17486
- "Score Sim Slug Concept Domain Bloom"
18484
+ "Score Sim Title Concept Domain Bloom"
17487
18485
  );
17488
18486
  console.log("\u2500".repeat(95));
17489
18487
  for (const t2 of results) {
17490
18488
  const scoreStr = t2.score.toFixed(3).padEnd(6);
17491
18489
  const simStr = (t2.similarity?.toFixed(2) ?? "-").padEnd(4);
18490
+ const display = getDisplayTitle(t2);
17492
18491
  console.log(
17493
- `${scoreStr} ${simStr} ${t2.slug.padEnd(21)} ${t2.concept.slice(0, 31).padEnd(31)} ${(t2.domain || "-").padEnd(11)} ${t2.bloom_level}`
18492
+ `${scoreStr} ${simStr} ${display.padEnd(28)} ${t2.concept.slice(0, 31).padEnd(31)} ${(t2.domain || "-").padEnd(11)} ${t2.bloom_level}`
17494
18493
  );
17495
18494
  }
18495
+ console.log("\n(Use slug for CLI commands -- slugs are technical IDs)");
17496
18496
  });
17497
18497
  });
17498
18498
  tokenCommand.command("list").description("List all tokens").option("--domain <domain>", "Filter by domain").option("--json", "Output as JSON").option("--quiet", "Suppress output (exit code only)").action(async (opts) => {
@@ -17511,16 +18511,20 @@ tokenCommand.command("list").description("List all tokens").option("--domain <do
17511
18511
  return;
17512
18512
  }
17513
18513
  console.log(
17514
- "Slug Concept Domain Bloom"
18514
+ "Title Concept Domain Bloom"
17515
18515
  );
17516
- console.log("\u2500".repeat(80));
18516
+ console.log("\u2500".repeat(85));
17517
18517
  for (const t2 of tokens) {
18518
+ const display = getDisplayTitle(t2);
17518
18519
  console.log(
17519
- `${t2.slug.padEnd(21)} ${t2.concept.slice(0, 31).padEnd(31)} ${(t2.domain || "-").padEnd(11)} ${t2.bloom_level}`
18520
+ `${display.padEnd(28)} ${t2.concept.slice(0, 31).padEnd(31)} ${(t2.domain || "-").padEnd(11)} ${t2.bloom_level}`
17520
18521
  );
17521
18522
  }
17522
18523
  console.log(`
17523
18524
  ${tokens.length} token(s) total.`);
18525
+ console.log(
18526
+ "(Use --slug <slug> for commands; slugs are technical identifiers)"
18527
+ );
17524
18528
  });
17525
18529
  });
17526
18530
  tokenCommand.command("edit").description("Edit a token's mutable fields").requiredOption("--slug <slug>", "Token slug").option("--concept <concept>", "Updated concept text").option("--domain <domain>", "Updated domain (blank allowed)").option("--bloom <level>", "Updated Bloom taxonomy level (1-5)").option("--context <context>", "Updated context (blank allowed)").option(
@@ -17529,7 +18533,10 @@ tokenCommand.command("edit").description("Edit a token's mutable fields").requir
17529
18533
  ).option(
17530
18534
  "--source-link <link>",
17531
18535
  "Updated source file path or reference URL (blank allowed)"
17532
- ).option("--question <question>", "Updated question text (blank allowed)").option("--json", "Output as JSON").option("--quiet", "Suppress output (exit code only)").action(async (opts) => {
18536
+ ).option("--question <question>", "Updated question text (blank allowed)").option(
18537
+ "--title <title>",
18538
+ "Updated human-friendly title for graph display (blank allowed)"
18539
+ ).option("--json", "Output as JSON").option("--quiet", "Suppress output (exit code only)").action(async (opts) => {
17533
18540
  await withDb(async (db) => {
17534
18541
  const updates = {};
17535
18542
  if (opts.concept !== void 0) updates.concept = opts.concept;
@@ -17543,6 +18550,9 @@ tokenCommand.command("edit").description("Edit a token's mutable fields").requir
17543
18550
  if (opts.question !== void 0) {
17544
18551
  updates.question = opts.question === "" ? null : opts.question;
17545
18552
  }
18553
+ if (opts.title !== void 0) {
18554
+ updates.title = opts.title === "" ? "" : opts.title;
18555
+ }
17546
18556
  if (opts.mode !== void 0) {
17547
18557
  const validModes = ["shadowing", "copilot", "autonomy", "none"];
17548
18558
  if (!validModes.includes(opts.mode)) {
@@ -17557,11 +18567,15 @@ tokenCommand.command("edit").description("Edit a token's mutable fields").requir
17557
18567
  jsonOut(token);
17558
18568
  return;
17559
18569
  }
17560
- console.log(`Updated token: ${token.slug}`);
18570
+ console.log(`Updated token: ${token.title || token.slug}`);
18571
+ if (token.slug !== (token.title || token.slug)) {
18572
+ console.log(` Slug: ${token.slug}`);
18573
+ }
17561
18574
  console.log(` Concept: ${token.concept}`);
17562
18575
  console.log(` Domain: ${token.domain || "(none)"}`);
17563
18576
  console.log(` Bloom: ${token.bloom_level}`);
17564
18577
  console.log(` Question: ${token.question || "(none)"}`);
18578
+ console.log(` Title: ${token.title || "(none)"}`);
17565
18579
  console.log(` Context: ${token.context || "(none)"}`);
17566
18580
  console.log(` Mode: ${token.symbiosis_mode ?? "none"}`);
17567
18581
  console.log(` Source: ${token.source_link || "(none)"}`);
@@ -17680,7 +18694,11 @@ tokenCommand.command("status").description("Show full status of a token for a us
17680
18694
  console.log(JSON.stringify(status, null, 2));
17681
18695
  return;
17682
18696
  }
17683
- console.log(`Token: ${token.slug} (${token.id})`);
18697
+ console.log(`Token: ${token.title || token.slug}`);
18698
+ if (token.slug !== (token.title || token.slug)) {
18699
+ console.log(` Slug: ${token.slug}`);
18700
+ }
18701
+ console.log(` ID: ${token.id}`);
17684
18702
  console.log(` Concept: ${token.concept}`);
17685
18703
  console.log(` Question: ${token.question || "(none)"}`);
17686
18704
  console.log(` Domain: ${token.domain || "(none)"}`);
@@ -17705,7 +18723,9 @@ tokenCommand.command("status").description("Show full status of a token for a us
17705
18723
  if (prereqs.length > 0) {
17706
18724
  console.log("Prerequisites:");
17707
18725
  for (const p of prereqs) {
17708
- console.log(` - ${p.slug}: ${p.concept} (bloom ${p.bloom_level})`);
18726
+ console.log(
18727
+ ` - ${p.title || p.slug}: ${p.concept} (bloom ${p.bloom_level})`
18728
+ );
17709
18729
  }
17710
18730
  } else {
17711
18731
  console.log("No prerequisites.");
@@ -17713,7 +18733,9 @@ tokenCommand.command("status").description("Show full status of a token for a us
17713
18733
  if (dependents.length > 0) {
17714
18734
  console.log("\nDependents:");
17715
18735
  for (const d of dependents) {
17716
- console.log(` - ${d.slug}: ${d.concept} (bloom ${d.bloom_level})`);
18736
+ console.log(
18737
+ ` - ${d.title || d.slug}: ${d.concept} (bloom ${d.bloom_level})`
18738
+ );
17717
18739
  }
17718
18740
  }
17719
18741
  });
@@ -17775,7 +18797,7 @@ import { existsSync as existsSync23 } from "fs";
17775
18797
  import { homedir as homedir12 } from "os";
17776
18798
  import { dirname as dirname8, join as join22 } from "path";
17777
18799
  import { fileURLToPath as fileURLToPath3 } from "url";
17778
- import { Command as Command21 } from "commander";
18800
+ import { Command as Command22 } from "commander";
17779
18801
  var C3 = {
17780
18802
  reset: "\x1B[0m",
17781
18803
  red: "\x1B[31m",
@@ -17955,7 +18977,7 @@ function createShortcuts(appPath, repoRoot) {
17955
18977
  console.error(`${C3.red}\u2717 Could not create shortcuts.${C3.reset}`);
17956
18978
  }
17957
18979
  }
17958
- var uiCommand = new Command21("ui").description("Launch the ZAM Desktop GUI (Active-Recall Studio)").option("--dev", "Run in hot-reload development mode (needs Rust)").option(
18980
+ var uiCommand = new Command22("ui").description("Launch the ZAM Desktop GUI (Active-Recall Studio)").option("--dev", "Run in hot-reload development mode (needs Rust)").option(
17959
18981
  "--build",
17960
18982
  "Build the native installer for your OS (needs Rust, one-time)"
17961
18983
  ).option("--shortcut", "Create Desktop + Start-menu shortcuts to the GUI").action((opts) => {
@@ -18063,7 +19085,7 @@ import { existsSync as existsSync24, readFileSync as readFileSync15, realpathSyn
18063
19085
  import { dirname as dirname9, join as join23 } from "path";
18064
19086
  import { fileURLToPath as fileURLToPath4 } from "url";
18065
19087
  import { confirm as confirm3 } from "@inquirer/prompts";
18066
- import { Command as Command22 } from "commander";
19088
+ import { Command as Command23 } from "commander";
18067
19089
  var GITHUB_REPO = "zam-os/zam";
18068
19090
  var CHANNELS = [
18069
19091
  "developer",
@@ -18141,7 +19163,7 @@ function render2(decision) {
18141
19163
  );
18142
19164
  }
18143
19165
  }
18144
- var checkCmd = new Command22("check").description("Check whether a newer ZAM has been released").option(
19166
+ var checkCmd = new Command23("check").description("Check whether a newer ZAM has been released").option(
18145
19167
  "--latest <version>",
18146
19168
  "Compare against this version instead of fetching"
18147
19169
  ).option("--channel <channel>", "Override the detected install channel").option("--json", "Output as JSON").action(
@@ -18308,7 +19330,7 @@ ${C4.dim}This install updates through the desktop app's signed updater \u2014 op
18308
19330
  process.exit(1);
18309
19331
  }
18310
19332
  }
18311
- var updateCommand = new Command22("update").description(
19333
+ var updateCommand = new Command23("update").description(
18312
19334
  "Update ZAM to the latest release (use `update check` to only check)"
18313
19335
  ).option("-y, --yes", "Apply without confirmation").option(
18314
19336
  "--force",
@@ -18318,8 +19340,8 @@ var updateCommand = new Command22("update").description(
18318
19340
  }).addCommand(checkCmd);
18319
19341
 
18320
19342
  // src/cli/commands/whoami.ts
18321
- import { Command as Command23 } from "commander";
18322
- var whoamiCommand = new Command23("whoami").description("Show or set the default user identity").option("--set <id>", "Set the default user ID").option("--clear", "Remove the default user ID").option("--json", "Output as JSON").action(async (opts) => {
19343
+ import { Command as Command24 } from "commander";
19344
+ var whoamiCommand = new Command24("whoami").description("Show or set the default user identity").option("--set <id>", "Set the default user ID").option("--clear", "Remove the default user ID").option("--json", "Output as JSON").action(async (opts) => {
18323
19345
  await withDb(async (db) => {
18324
19346
  if (opts.set) {
18325
19347
  await setSetting(db, "user.id", opts.set);
@@ -18360,7 +19382,7 @@ import { existsSync as existsSync25, writeFileSync as writeFileSync11 } from "fs
18360
19382
  import { homedir as homedir13 } from "os";
18361
19383
  import { join as join24, resolve as resolve9 } from "path";
18362
19384
  import { confirm as confirm4, input as input7 } from "@inquirer/prompts";
18363
- import { Command as Command24 } from "commander";
19385
+ import { Command as Command25 } from "commander";
18364
19386
  function runGit2(cwd, args) {
18365
19387
  try {
18366
19388
  return execFileSync4("git", args, {
@@ -18378,7 +19400,7 @@ function ghRepoCreateArgs(repoName, repoVisibility) {
18378
19400
  function gitRemoteArgs(githubUrl, hasOrigin) {
18379
19401
  return hasOrigin ? ["remote", "set-url", "origin", githubUrl] : ["remote", "add", "origin", githubUrl];
18380
19402
  }
18381
- var workspaceCommand = new Command24("workspace").description(
19403
+ var workspaceCommand = new Command25("workspace").description(
18382
19404
  "Manage your ZAM learning workspace"
18383
19405
  );
18384
19406
  var WORKSPACE_KINDS = [
@@ -18733,13 +19755,14 @@ var __dirname = dirname10(fileURLToPath5(import.meta.url));
18733
19755
  var pkg = JSON.parse(
18734
19756
  readFileSync16(join25(__dirname, "..", "..", "package.json"), "utf-8")
18735
19757
  );
18736
- var program = new Command25();
19758
+ var program = new Command26();
18737
19759
  program.name("zam").description(
18738
19760
  "The Symbiotic Learning Kernel: Elevating Human Intelligence through AI Collaboration."
18739
19761
  ).version(pkg.version);
18740
19762
  program.addCommand(initCommand);
18741
19763
  program.addCommand(setupCommand);
18742
19764
  program.addCommand(tokenCommand);
19765
+ program.addCommand(doctorCommand);
18743
19766
  program.addCommand(cardCommand);
18744
19767
  program.addCommand(sessionCommand);
18745
19768
  program.addCommand(statsCommand);