zam-core 0.6.3 → 0.7.1

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
@@ -656,6 +656,18 @@ CREATE TABLE IF NOT EXISTS user_config (
656
656
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
657
657
  );
658
658
 
659
+ -- Token embeddings: one vector per token for semantic search (ADR 2026-07-03).
660
+ -- Not a column on tokens: hot paths do SELECT * FROM tokens and a ~3KB blob
661
+ -- per row would ride along on all of them.
662
+ CREATE TABLE IF NOT EXISTS token_embeddings (
663
+ token_id TEXT PRIMARY KEY REFERENCES tokens(id) ON DELETE CASCADE,
664
+ embedding BLOB NOT NULL,
665
+ model TEXT NOT NULL,
666
+ dims INTEGER NOT NULL,
667
+ content_hash TEXT NOT NULL,
668
+ embedded_at TEXT NOT NULL DEFAULT (datetime('now'))
669
+ );
670
+
659
671
  -- Agent skills: task recipes the agent learns from user guidance
660
672
  CREATE TABLE IF NOT EXISTS agent_skills (
661
673
  id TEXT PRIMARY KEY,
@@ -1002,6 +1014,16 @@ async function runMigrations(db) {
1002
1014
  await db.exec(`ALTER TABLE tokens ADD COLUMN topic_id TEXT`);
1003
1015
  }
1004
1016
  }
1017
+ await db.exec(`
1018
+ CREATE TABLE IF NOT EXISTS token_embeddings (
1019
+ token_id TEXT PRIMARY KEY REFERENCES tokens(id) ON DELETE CASCADE,
1020
+ embedding BLOB NOT NULL,
1021
+ model TEXT NOT NULL,
1022
+ dims INTEGER NOT NULL,
1023
+ content_hash TEXT NOT NULL,
1024
+ embedded_at TEXT NOT NULL DEFAULT (datetime('now'))
1025
+ )
1026
+ `);
1005
1027
  }
1006
1028
 
1007
1029
  // src/kernel/db/snapshot.ts
@@ -2197,9 +2219,9 @@ async function buildAncestorMap(db) {
2197
2219
  }
2198
2220
  return map;
2199
2221
  }
2200
- async function wouldCreateCycle(db, tokenId, requiresId) {
2222
+ async function wouldCreateCycle(db, tokenId, requiresId, ancestors) {
2201
2223
  if (tokenId === requiresId) return true;
2202
- const ancestors = await buildAncestorMap(db);
2224
+ const map = ancestors ?? await buildAncestorMap(db);
2203
2225
  const visited = /* @__PURE__ */ new Set();
2204
2226
  const queue = [requiresId];
2205
2227
  while (queue.length > 0) {
@@ -2207,7 +2229,7 @@ async function wouldCreateCycle(db, tokenId, requiresId) {
2207
2229
  if (current === tokenId) return true;
2208
2230
  if (visited.has(current)) continue;
2209
2231
  visited.add(current);
2210
- const parents = ancestors.get(current);
2232
+ const parents = map.get(current);
2211
2233
  if (parents) {
2212
2234
  for (const parent of parents) {
2213
2235
  if (!visited.has(parent)) queue.push(parent);
@@ -2410,6 +2432,151 @@ async function deleteSetting(db, key) {
2410
2432
  return result.changes > 0;
2411
2433
  }
2412
2434
 
2435
+ // src/kernel/models/token-embedding.ts
2436
+ import { createHash as createHash2 } from "crypto";
2437
+ function embeddingContentForToken(t2) {
2438
+ return `${t2.concept}
2439
+ ${t2.question ?? ""}
2440
+ ${t2.domain}`;
2441
+ }
2442
+ function computeContentHash(text) {
2443
+ return createHash2("sha256").update(text, "utf8").digest("hex");
2444
+ }
2445
+ function encodeEmbedding(vec) {
2446
+ const f = Float32Array.from(vec);
2447
+ return new Uint8Array(f.buffer);
2448
+ }
2449
+ function decodeEmbedding(blob) {
2450
+ if (blob.byteLength % 4 !== 0) {
2451
+ throw new Error(
2452
+ "Invalid embedding blob size: must be a multiple of 4 bytes"
2453
+ );
2454
+ }
2455
+ if (blob.byteOffset % 4 === 0) {
2456
+ return new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);
2457
+ }
2458
+ const copy = Uint8Array.prototype.slice.call(blob);
2459
+ return new Float32Array(copy.buffer, 0, copy.byteLength / 4);
2460
+ }
2461
+ async function upsertTokenEmbedding(db, input8) {
2462
+ const encoded = encodeEmbedding(input8.embedding);
2463
+ const dims = input8.embedding.length;
2464
+ const embeddedAt = (/* @__PURE__ */ new Date()).toISOString();
2465
+ await db.prepare(`
2466
+ INSERT INTO token_embeddings (token_id, embedding, model, dims, content_hash, embedded_at)
2467
+ VALUES (?, ?, ?, ?, ?, ?)
2468
+ ON CONFLICT(token_id) DO UPDATE SET
2469
+ embedding = excluded.embedding,
2470
+ model = excluded.model,
2471
+ dims = excluded.dims,
2472
+ content_hash = excluded.content_hash,
2473
+ embedded_at = excluded.embedded_at
2474
+ `).run(
2475
+ input8.tokenId,
2476
+ encoded,
2477
+ input8.model,
2478
+ dims,
2479
+ input8.contentHash,
2480
+ embeddedAt
2481
+ );
2482
+ }
2483
+ async function listTokensNeedingEmbedding(db, model, opts) {
2484
+ const rows = await db.prepare(`
2485
+ SELECT t.*, e.model AS emb_model, e.dims AS emb_dims, e.content_hash AS emb_hash
2486
+ FROM tokens t
2487
+ LEFT JOIN token_embeddings e ON e.token_id = t.id
2488
+ WHERE t.deprecated_at IS NULL
2489
+ `).all();
2490
+ const needing = [];
2491
+ for (const row of rows) {
2492
+ let reason = null;
2493
+ let text = "";
2494
+ if (opts?.force) {
2495
+ reason = "content-changed";
2496
+ } else if (row.emb_model === null) {
2497
+ reason = "missing";
2498
+ } else if (row.emb_model !== model) {
2499
+ reason = "model-changed";
2500
+ } else if (opts?.dims !== void 0 && row.emb_dims !== opts.dims) {
2501
+ reason = "dimension-changed";
2502
+ } else {
2503
+ const computedText = embeddingContentForToken(row);
2504
+ const hash = computeContentHash(computedText);
2505
+ if (row.emb_hash !== hash) {
2506
+ reason = "content-changed";
2507
+ text = computedText;
2508
+ }
2509
+ }
2510
+ if (reason) {
2511
+ if (!text) {
2512
+ text = embeddingContentForToken(row);
2513
+ }
2514
+ needing.push({ token: row, text, reason });
2515
+ }
2516
+ }
2517
+ if (opts?.limit !== void 0) {
2518
+ return needing.slice(0, opts.limit);
2519
+ }
2520
+ return needing;
2521
+ }
2522
+ async function getEmbeddingCoverage(db, model, opts) {
2523
+ const rows = await db.prepare(`
2524
+ SELECT t.*, e.model AS emb_model, e.dims AS emb_dims, e.content_hash AS emb_hash
2525
+ FROM tokens t
2526
+ LEFT JOIN token_embeddings e ON e.token_id = t.id
2527
+ WHERE t.deprecated_at IS NULL
2528
+ `).all();
2529
+ let missing = 0;
2530
+ let stale = 0;
2531
+ for (const row of rows) {
2532
+ if (row.emb_model === null) {
2533
+ missing++;
2534
+ continue;
2535
+ }
2536
+ if (row.emb_model !== model) {
2537
+ stale++;
2538
+ continue;
2539
+ }
2540
+ if (opts?.dims !== void 0 && row.emb_dims !== opts.dims) {
2541
+ stale++;
2542
+ continue;
2543
+ }
2544
+ const hash = computeContentHash(embeddingContentForToken(row));
2545
+ if (row.emb_hash !== hash) {
2546
+ stale++;
2547
+ }
2548
+ }
2549
+ const tokens = rows.length;
2550
+ return {
2551
+ tokens,
2552
+ embedded: tokens - missing - stale,
2553
+ missing,
2554
+ stale
2555
+ };
2556
+ }
2557
+ async function listEmbeddedTokens(db, model) {
2558
+ const rows = await db.prepare(`
2559
+ SELECT t.*, e.embedding AS embedding, e.content_hash AS emb_hash
2560
+ FROM token_embeddings e
2561
+ JOIN tokens t ON t.id = e.token_id
2562
+ WHERE e.model = ? AND t.deprecated_at IS NULL
2563
+ `).all(model);
2564
+ return rows.flatMap((row) => {
2565
+ const { embedding, emb_hash, ...token } = row;
2566
+ if (emb_hash !== computeContentHash(embeddingContentForToken(token))) {
2567
+ return [];
2568
+ }
2569
+ try {
2570
+ return [{ token, embedding: decodeEmbedding(embedding) }];
2571
+ } catch (err) {
2572
+ console.warn(
2573
+ `Warning: Corrupted embedding for token ${token.slug} (${token.id}) ignored: ${err.message}`
2574
+ );
2575
+ return [];
2576
+ }
2577
+ });
2578
+ }
2579
+
2413
2580
  // src/kernel/observation/analyzer.ts
2414
2581
  function parseMonitorLog(jsonl) {
2415
2582
  const events = [];
@@ -4546,6 +4713,147 @@ function intersperseNew(reviews, newCards, interval) {
4546
4713
  return result;
4547
4714
  }
4548
4715
 
4716
+ // src/kernel/search/hybrid.ts
4717
+ function cosineSimilarity(a, b) {
4718
+ if (a.length !== b.length) return 0;
4719
+ let dot = 0;
4720
+ let normA = 0;
4721
+ let normB = 0;
4722
+ const len = a.length;
4723
+ for (let i = 0; i < len; i++) {
4724
+ dot += a[i] * b[i];
4725
+ normA += a[i] * a[i];
4726
+ normB += b[i] * b[i];
4727
+ }
4728
+ if (normA === 0 || normB === 0) return 0;
4729
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
4730
+ }
4731
+ async function searchTokensHybrid(db, query, opts) {
4732
+ const limit = opts?.limit ?? 20;
4733
+ const rrfK = opts?.rrfK ?? 60;
4734
+ const vectorTopK = opts?.vectorTopK ?? 10;
4735
+ const lexicalHits = await findTokens(db, query);
4736
+ let vectorHits = [];
4737
+ if (opts?.queryEmbedding && opts?.model) {
4738
+ const queryVec = Float32Array.from(opts.queryEmbedding);
4739
+ const embedded = await listEmbeddedTokens(db, opts.model);
4740
+ const candidates = [];
4741
+ for (const row of embedded) {
4742
+ if (row.embedding.length !== queryVec.length) {
4743
+ continue;
4744
+ }
4745
+ const similarity = cosineSimilarity(queryVec, row.embedding);
4746
+ if (similarity <= 0) {
4747
+ continue;
4748
+ }
4749
+ candidates.push({ token: row.token, similarity });
4750
+ }
4751
+ candidates.sort((a, b) => b.similarity - a.similarity);
4752
+ vectorHits = candidates.slice(0, vectorTopK);
4753
+ }
4754
+ const tokenMap = /* @__PURE__ */ new Map();
4755
+ const getOrCreateEntry = (token) => {
4756
+ let entry = tokenMap.get(token.id);
4757
+ if (!entry) {
4758
+ entry = {
4759
+ ...token,
4760
+ score: 0,
4761
+ lexicalRank: null,
4762
+ vectorRank: null,
4763
+ similarity: null
4764
+ };
4765
+ tokenMap.set(token.id, entry);
4766
+ }
4767
+ return entry;
4768
+ };
4769
+ for (let i = 0; i < lexicalHits.length; i++) {
4770
+ const hit = lexicalHits[i];
4771
+ const entry = getOrCreateEntry(hit);
4772
+ entry.lexicalRank = i + 1;
4773
+ entry.score += 1 / (rrfK + entry.lexicalRank);
4774
+ }
4775
+ for (let j = 0; j < vectorHits.length; j++) {
4776
+ const hit = vectorHits[j];
4777
+ const entry = getOrCreateEntry(hit.token);
4778
+ entry.vectorRank = j + 1;
4779
+ entry.similarity = hit.similarity;
4780
+ entry.score += 1 / (rrfK + entry.vectorRank);
4781
+ }
4782
+ const results = Array.from(tokenMap.values());
4783
+ results.sort((a, b) => {
4784
+ if (Math.abs(a.score - b.score) > 1e-9) {
4785
+ return b.score - a.score;
4786
+ }
4787
+ return a.slug.localeCompare(b.slug);
4788
+ });
4789
+ return results.slice(0, limit);
4790
+ }
4791
+
4792
+ // src/kernel/search/suggestions.ts
4793
+ async function suggestFoundations(db, opts) {
4794
+ const minSimilarity = opts.minSimilarity ?? 0.45;
4795
+ const maxSimilarity = opts.maxSimilarity ?? 0.85;
4796
+ const limit = opts.limit ?? 5;
4797
+ const targetBloomLevel = opts.targetBloomLevel ?? 5;
4798
+ if (minSimilarity >= maxSimilarity) {
4799
+ return [];
4800
+ }
4801
+ const embedded = await listEmbeddedTokens(db, opts.model);
4802
+ if (embedded.length === 0) {
4803
+ return [];
4804
+ }
4805
+ const queryVec = Float32Array.from(opts.queryEmbedding);
4806
+ const candidates = [];
4807
+ for (const row of embedded) {
4808
+ if (opts.targetTokenId && row.token.id === opts.targetTokenId) {
4809
+ continue;
4810
+ }
4811
+ const similarity = cosineSimilarity(queryVec, row.embedding);
4812
+ if (similarity >= minSimilarity && similarity < maxSimilarity) {
4813
+ candidates.push({ token: row.token, similarity });
4814
+ }
4815
+ }
4816
+ candidates.sort((a, b) => {
4817
+ if (Math.abs(a.similarity - b.similarity) > 1e-9) {
4818
+ return b.similarity - a.similarity;
4819
+ }
4820
+ return a.token.slug.localeCompare(b.token.slug);
4821
+ });
4822
+ const topCandidates = candidates.slice(0, limit);
4823
+ const prereqIds = /* @__PURE__ */ new Set();
4824
+ let ancestors;
4825
+ if (opts.targetTokenId) {
4826
+ const prereqs = await getPrerequisites(db, opts.targetTokenId);
4827
+ for (const p of prereqs) {
4828
+ prereqIds.add(p.requires_id);
4829
+ }
4830
+ ancestors = await buildAncestorMap(db);
4831
+ }
4832
+ const results = [];
4833
+ for (const cand of topCandidates) {
4834
+ let alreadyPrerequisite = false;
4835
+ let wouldCreateCycleFlag = false;
4836
+ const bloomAboveTarget = cand.token.bloom_level > targetBloomLevel;
4837
+ if (opts.targetTokenId) {
4838
+ alreadyPrerequisite = prereqIds.has(cand.token.id);
4839
+ wouldCreateCycleFlag = await wouldCreateCycle(
4840
+ db,
4841
+ opts.targetTokenId,
4842
+ cand.token.id,
4843
+ ancestors
4844
+ );
4845
+ }
4846
+ results.push({
4847
+ token: cand.token,
4848
+ similarity: cand.similarity,
4849
+ alreadyPrerequisite,
4850
+ wouldCreateCycle: wouldCreateCycleFlag,
4851
+ bloomAboveTarget
4852
+ });
4853
+ }
4854
+ return results;
4855
+ }
4856
+
4549
4857
  // src/kernel/system/hooks.ts
4550
4858
  import {
4551
4859
  appendFileSync as appendFileSync3,
@@ -6092,6 +6400,21 @@ async function getLegacyRoleConfig(db, role, enabled) {
6092
6400
  maxFrames: Number.isNaN(parsed) ? 100 : parsed
6093
6401
  };
6094
6402
  }
6403
+ if (role === "embedding") {
6404
+ const url = await getSetting(db, "llm.embedding.url") || base.url;
6405
+ return {
6406
+ enabled,
6407
+ url,
6408
+ // Kept as a literal (not imported) to avoid a client.ts <-> embedder.ts
6409
+ // import cycle; must match DEFAULT_EMBEDDING_MODEL in embedder.ts.
6410
+ model: await getSetting(db, "llm.embedding.model") || "embeddinggemma",
6411
+ apiKey: await getSetting(db, "llm.embedding.api_key") || base.apiKey,
6412
+ apiFlavor: inferApiFlavor(url),
6413
+ locale: base.locale,
6414
+ source: "legacy",
6415
+ local: isLocalEndpoint(url)
6416
+ };
6417
+ }
6095
6418
  return {
6096
6419
  enabled,
6097
6420
  url: base.url,
@@ -10110,6 +10433,227 @@ function getCurriculumProvider(id) {
10110
10433
  return CURRICULUM_PROVIDERS.find((provider) => provider.id === id);
10111
10434
  }
10112
10435
 
10436
+ // src/cli/llm/embedder.ts
10437
+ var CANONICAL_EMBEDDING_MODEL_ID = "embeddinggemma-300m";
10438
+ var EMBEDDINGGEMMA_ALIASES = /* @__PURE__ */ new Set([
10439
+ "embeddinggemma",
10440
+ "embeddinggemma:300m",
10441
+ "embeddinggemma-300m",
10442
+ "embed-gemma",
10443
+ "embed-gemma:300m",
10444
+ "google/embeddinggemma-300m"
10445
+ ]);
10446
+ function canonicalEmbeddingModelId(model) {
10447
+ const lowered = model.trim().toLowerCase();
10448
+ if (EMBEDDINGGEMMA_ALIASES.has(lowered)) {
10449
+ return CANONICAL_EMBEDDING_MODEL_ID;
10450
+ }
10451
+ return lowered;
10452
+ }
10453
+ function embeddingTextForToken(t2, model) {
10454
+ const isGemma = EMBEDDINGGEMMA_ALIASES.has(model.trim().toLowerCase());
10455
+ const content = embeddingContentForToken(t2);
10456
+ return isGemma ? `title: none | text: ${content}` : content;
10457
+ }
10458
+ function embeddingTextForQuery(text, model) {
10459
+ const isGemma = EMBEDDINGGEMMA_ALIASES.has(model.trim().toLowerCase());
10460
+ return isGemma ? `task: search result | query: ${text}` : text;
10461
+ }
10462
+ async function resolveUsableEmbeddingEndpoint(db) {
10463
+ const cfg = await getProviderForRole(db, "embedding");
10464
+ if (!cfg.enabled) return null;
10465
+ for (const endpoint of [cfg, ...cfg.fallback ? [cfg.fallback] : []]) {
10466
+ if (endpoint.apiFlavor !== "chat-completions") continue;
10467
+ const online = await isLlmOnline(endpoint.url);
10468
+ if (!online) continue;
10469
+ const availableModels = await getAvailableModels(
10470
+ endpoint.url,
10471
+ endpoint.apiKey
10472
+ );
10473
+ const modelAvailable = availableModels.length === 0 || availableModels.some(
10474
+ (candidate) => candidate.toLowerCase() === endpoint.model.toLowerCase()
10475
+ );
10476
+ if (modelAvailable) return endpoint;
10477
+ }
10478
+ return null;
10479
+ }
10480
+ async function embedTexts(endpoint, texts) {
10481
+ const controller = new AbortController();
10482
+ const timeoutId = setTimeout(() => controller.abort(), 6e4);
10483
+ let res;
10484
+ try {
10485
+ res = await fetch(`${endpoint.url}/embeddings`, {
10486
+ method: "POST",
10487
+ headers: {
10488
+ "Content-Type": "application/json",
10489
+ Authorization: `Bearer ${endpoint.apiKey || DEFAULT_LLM_API_KEY}`
10490
+ },
10491
+ body: JSON.stringify({ model: endpoint.model, input: texts }),
10492
+ signal: controller.signal
10493
+ });
10494
+ } finally {
10495
+ clearTimeout(timeoutId);
10496
+ }
10497
+ if (!res.ok) {
10498
+ const body = await res.text().catch(() => "");
10499
+ throw new Error(
10500
+ `Embedding request failed: ${res.statusText} (${res.status}) - ${body}`
10501
+ );
10502
+ }
10503
+ const data = await res.json();
10504
+ const items = data.data ?? [];
10505
+ if (items.length !== texts.length) {
10506
+ throw new Error(
10507
+ `Embedding response returned ${items.length} vectors for ${texts.length} inputs`
10508
+ );
10509
+ }
10510
+ const ordered = new Array(texts.length);
10511
+ for (let i = 0; i < items.length; i++) {
10512
+ const item = items[i];
10513
+ const index = typeof item.index === "number" ? item.index : i;
10514
+ if (!Number.isInteger(index)) {
10515
+ throw new Error("Embedding response item is missing a valid index");
10516
+ }
10517
+ if (index < 0 || index >= texts.length) {
10518
+ throw new Error(`Embedding response index out of range: ${index}`);
10519
+ }
10520
+ if (!Array.isArray(item.embedding) || item.embedding.length === 0 || !item.embedding.every((n) => typeof n === "number" && Number.isFinite(n))) {
10521
+ throw new Error(
10522
+ `Embedding response at index ${index} is not a non-empty array of finite numbers`
10523
+ );
10524
+ }
10525
+ ordered[index] = item.embedding;
10526
+ }
10527
+ const firstLength = ordered[0]?.length;
10528
+ if (ordered.some((vec) => vec === void 0 || vec.length !== firstLength)) {
10529
+ throw new Error("Embedding response vectors have inconsistent lengths");
10530
+ }
10531
+ return ordered;
10532
+ }
10533
+ var EMBED_BATCH_SIZE = 16;
10534
+ async function ensureTokenEmbeddings(db, opts) {
10535
+ const endpoint = await resolveUsableEmbeddingEndpoint(db);
10536
+ if (!endpoint) {
10537
+ const reason = await describeUnavailableReason(db);
10538
+ return { status: "unavailable", embedded: 0, remaining: 0, reason };
10539
+ }
10540
+ const model = canonicalEmbeddingModelId(endpoint.model);
10541
+ const pending = await listTokensNeedingEmbedding(db, model, {
10542
+ limit: opts?.limit ?? 64,
10543
+ force: opts?.force,
10544
+ dims: opts?.dims
10545
+ });
10546
+ let embedded = 0;
10547
+ let failure;
10548
+ try {
10549
+ for (let i = 0; i < pending.length; i += EMBED_BATCH_SIZE) {
10550
+ const batch = pending.slice(i, i + EMBED_BATCH_SIZE);
10551
+ const formattedTexts = batch.map(
10552
+ (item) => embeddingTextForToken(item.token, endpoint.model)
10553
+ );
10554
+ const vectors = await embedTexts(
10555
+ { url: endpoint.url, model: endpoint.model, apiKey: endpoint.apiKey },
10556
+ formattedTexts
10557
+ );
10558
+ for (const [index, item] of batch.entries()) {
10559
+ await upsertTokenEmbedding(db, {
10560
+ tokenId: item.token.id,
10561
+ embedding: vectors[index],
10562
+ model,
10563
+ contentHash: computeContentHash(item.text)
10564
+ });
10565
+ embedded++;
10566
+ }
10567
+ }
10568
+ } catch (err) {
10569
+ failure = err instanceof Error ? err.message : String(err);
10570
+ }
10571
+ const coverage = await getEmbeddingCoverage(db, model, { dims: opts?.dims });
10572
+ const remaining = coverage.missing + coverage.stale;
10573
+ if (failure !== void 0) {
10574
+ return { status: "unavailable", embedded, remaining, reason: failure };
10575
+ }
10576
+ return { status: "ok", embedded, remaining };
10577
+ }
10578
+ async function describeUnavailableReason(db) {
10579
+ const cfg = await getProviderForRole(db, "embedding");
10580
+ if (!cfg.enabled) {
10581
+ return "embedding role is disabled in settings (llm.enabled)";
10582
+ }
10583
+ const endpoints = [cfg, ...cfg.fallback ? [cfg.fallback] : []];
10584
+ const online = await Promise.all(
10585
+ endpoints.map((endpoint) => isLlmOnline(endpoint.url))
10586
+ );
10587
+ if (!online.some(Boolean)) {
10588
+ return `embedding endpoints offline (${endpoints.map((endpoint) => endpoint.url).join(", ")})`;
10589
+ }
10590
+ return `no configured embedding model is available (${endpoints.map((endpoint) => endpoint.model).join(", ")})`;
10591
+ }
10592
+ async function embedQuery(db, text) {
10593
+ const endpoint = await resolveUsableEmbeddingEndpoint(db);
10594
+ if (!endpoint) return null;
10595
+ try {
10596
+ const [vector] = await embedTexts(
10597
+ { url: endpoint.url, model: endpoint.model, apiKey: endpoint.apiKey },
10598
+ [embeddingTextForQuery(text, endpoint.model)]
10599
+ );
10600
+ return { vector, model: canonicalEmbeddingModelId(endpoint.model) };
10601
+ } catch {
10602
+ return null;
10603
+ }
10604
+ }
10605
+ async function findPossibleDuplicates(db, candidate, embed = embedQuery) {
10606
+ const queryText = embeddingContentForToken({
10607
+ concept: candidate.concept,
10608
+ question: candidate.question ?? null,
10609
+ domain: candidate.domain ?? ""
10610
+ });
10611
+ const q2 = await embed(db, queryText);
10612
+ if (!q2) {
10613
+ return [];
10614
+ }
10615
+ const coverage = await getEmbeddingCoverage(db, q2.model, {
10616
+ dims: q2.vector.length
10617
+ });
10618
+ const totalNeeding = coverage.missing + coverage.stale;
10619
+ if (totalNeeding > 0) {
10620
+ await ensureTokenEmbeddings(db, { limit: 100, dims: q2.vector.length });
10621
+ if (totalNeeding > 100) {
10622
+ console.warn(
10623
+ `Warning: Duplicate check is incomplete. ${totalNeeding - 100} tokens are still missing embeddings. Run 'zam token reembed' to complete indexing.`
10624
+ );
10625
+ }
10626
+ }
10627
+ const threshold = await resolveDedupThreshold(db);
10628
+ const hits = await searchTokensHybrid(db, queryText, {
10629
+ queryEmbedding: q2.vector,
10630
+ model: q2.model,
10631
+ limit: 1e3,
10632
+ vectorTopK: 1e3
10633
+ });
10634
+ const results = [];
10635
+ for (const hit of hits) {
10636
+ if (hit.similarity !== null && hit.similarity >= threshold) {
10637
+ results.push({
10638
+ slug: hit.slug,
10639
+ concept: hit.concept,
10640
+ similarity: hit.similarity
10641
+ });
10642
+ }
10643
+ }
10644
+ return results;
10645
+ }
10646
+ async function resolveDedupThreshold(db) {
10647
+ const thresholdStr = await getSetting(db, "search.dedup_threshold");
10648
+ const parsed = thresholdStr ? Number.parseFloat(thresholdStr) : Number.NaN;
10649
+ return Number.isFinite(parsed) && parsed > 0 && parsed <= 1 ? parsed : 0.85;
10650
+ }
10651
+ async function resolveSuggestMinSimilarity(db) {
10652
+ const minStr = await getSetting(db, "search.suggest_min_similarity");
10653
+ const parsed = minStr ? Number.parseFloat(minStr) : Number.NaN;
10654
+ return Number.isFinite(parsed) && parsed > 0 && parsed <= 1 ? parsed : 0.45;
10655
+ }
10656
+
10113
10657
  // src/cli/llm/vision.ts
10114
10658
  import { randomBytes } from "crypto";
10115
10659
  import { readFileSync as readFileSync10 } from "fs";
@@ -10549,7 +11093,7 @@ var VALID_API_FLAVORS = [
10549
11093
  "chat-completions",
10550
11094
  "anthropic-messages"
10551
11095
  ];
10552
- var VALID_ROLES = ["vision", "recall", "text"];
11096
+ var VALID_ROLES = ["vision", "recall", "text", "embedding"];
10553
11097
  function upsertProviderRecord(providers, name, patch) {
10554
11098
  const merged = { ...providers[name] ?? {} };
10555
11099
  if (patch.url !== void 0) merged.url = patch.url;
@@ -11182,14 +11726,23 @@ async function backupDatabaseTo(db, targetDir) {
11182
11726
 
11183
11727
  // src/cli/commands/bridge.ts
11184
11728
  var isServeMode = false;
11729
+ var serveStdinPayload;
11185
11730
  function jsonOut2(data) {
11186
11731
  console.log(JSON.stringify(data, null, 2));
11187
11732
  }
11188
11733
  function jsonError(message) {
11734
+ let msg = message;
11735
+ if (message.startsWith('{"error":')) {
11736
+ try {
11737
+ const parsed = JSON.parse(message);
11738
+ msg = parsed.error;
11739
+ } catch {
11740
+ }
11741
+ }
11189
11742
  if (isServeMode) {
11190
- throw new Error(JSON.stringify({ error: message }));
11743
+ throw new Error(JSON.stringify({ error: msg }));
11191
11744
  }
11192
- console.log(JSON.stringify({ error: message }, null, 2));
11745
+ console.log(JSON.stringify({ error: msg }, null, 2));
11193
11746
  process.exit(1);
11194
11747
  }
11195
11748
  function parseNonNegativeIntegerOption(name, value) {
@@ -11512,7 +12065,10 @@ bridgeCommand.command("agent-open").description("Launch an agent harness in the
11512
12065
  });
11513
12066
  });
11514
12067
  });
11515
- bridgeCommand.command("get-review").description("Get next review card with prompt (JSON)").option("--user <id>", "User ID (default: whoami)").option("--no-resolve", "Skip resolving the token's source_link into context").action(async (opts) => {
12068
+ bridgeCommand.command("get-review").description("Get next review card with prompt (JSON)").option("--user <id>", "User ID (default: whoami)").option("--no-resolve", "Skip resolving the token's source_link into context").option(
12069
+ "--no-dynamic-question",
12070
+ "Use the stored question without generating a fresh LLM question"
12071
+ ).action(async (opts) => {
11516
12072
  await withDb2(async (db) => {
11517
12073
  const userId = await resolveUser(opts, db, { json: true });
11518
12074
  const queue = await buildReviewQueue(db, {
@@ -11536,7 +12092,7 @@ bridgeCommand.command("get-review").description("Get next review card with promp
11536
12092
  let resolvedQuestion = item.question;
11537
12093
  let questionSource = "original";
11538
12094
  let questionModel;
11539
- if (isLlmEnabled) {
12095
+ if (isLlmEnabled && opts.dynamicQuestion !== false) {
11540
12096
  try {
11541
12097
  const healed = await ensureHighQualityQuestion(db, {
11542
12098
  id: item.tokenId,
@@ -11783,11 +12339,16 @@ bridgeCommand.command("analyze-monitor").description("Analyze monitor log with t
11783
12339
  });
11784
12340
  return;
11785
12341
  }
11786
- const chunks = [];
11787
- for await (const chunk of process.stdin) {
11788
- chunks.push(chunk);
12342
+ let raw;
12343
+ if (isServeMode) {
12344
+ raw = serveStdinPayload ?? "";
12345
+ } else {
12346
+ const chunks = [];
12347
+ for await (const chunk of process.stdin) {
12348
+ chunks.push(chunk);
12349
+ }
12350
+ raw = Buffer.concat(chunks).toString("utf-8").trim();
11789
12351
  }
11790
- const raw = Buffer.concat(chunks).toString("utf-8").trim();
11791
12352
  if (!raw) {
11792
12353
  jsonError("No input received on stdin. Pipe JSON with token patterns.");
11793
12354
  }
@@ -11812,13 +12373,17 @@ bridgeCommand.command("analyze-monitor").description("Analyze monitor log with t
11812
12373
  }
11813
12374
  });
11814
12375
  bridgeCommand.command("add-token").description("Create a token + card from JSON stdin").option("--user <id>", "User ID (default: whoami)").action(async (opts) => {
11815
- let db;
11816
- try {
11817
- const chunks = [];
11818
- for await (const chunk of process.stdin) {
11819
- chunks.push(chunk);
12376
+ await withDb2(async (db) => {
12377
+ let raw;
12378
+ if (isServeMode) {
12379
+ raw = serveStdinPayload ?? "";
12380
+ } else {
12381
+ const chunks = [];
12382
+ for await (const chunk of process.stdin) {
12383
+ chunks.push(chunk);
12384
+ }
12385
+ raw = Buffer.concat(chunks).toString("utf-8").trim();
11820
12386
  }
11821
- const raw = Buffer.concat(chunks).toString("utf-8").trim();
11822
12387
  if (!raw) {
11823
12388
  jsonError("No input received on stdin. Pipe JSON with token data.");
11824
12389
  }
@@ -11831,8 +12396,12 @@ bridgeCommand.command("add-token").description("Create a token + card from JSON
11831
12396
  if (!data?.slug || !data?.concept) {
11832
12397
  jsonError("JSON must include 'slug' and 'concept' fields");
11833
12398
  }
11834
- db = await openDatabase();
11835
12399
  const userId = await resolveUser(opts, db, { json: true });
12400
+ const possibleDuplicates = await findPossibleDuplicates(db, {
12401
+ concept: data?.concept,
12402
+ question: data?.question ?? null,
12403
+ domain: data?.domain
12404
+ });
11836
12405
  const token = await createToken(db, {
11837
12406
  slug: data?.slug,
11838
12407
  concept: data?.concept,
@@ -11844,6 +12413,10 @@ bridgeCommand.command("add-token").description("Create a token + card from JSON
11844
12413
  question: data?.question ?? null
11845
12414
  });
11846
12415
  const card = await ensureCard(db, token.id, userId);
12416
+ try {
12417
+ await ensureTokenEmbeddings(db, { limit: 8 });
12418
+ } catch {
12419
+ }
11847
12420
  jsonOut2({
11848
12421
  success: true,
11849
12422
  token,
@@ -11854,15 +12427,190 @@ bridgeCommand.command("add-token").description("Create a token + card from JSON
11854
12427
  state: card.state,
11855
12428
  dueAt: card.due_at,
11856
12429
  blocked: card.blocked
12430
+ },
12431
+ possible_duplicates: possibleDuplicates
12432
+ });
12433
+ });
12434
+ });
12435
+ bridgeCommand.command("relevant-tokens").description("Find tokens relevant to a given context").option("--user <id>", "User ID (default: whoami)").action(async (opts) => {
12436
+ await withDb2(async (db) => {
12437
+ let raw;
12438
+ if (isServeMode) {
12439
+ raw = serveStdinPayload ?? "";
12440
+ } else {
12441
+ const chunks = [];
12442
+ for await (const chunk of process.stdin) {
12443
+ chunks.push(chunk);
11857
12444
  }
12445
+ raw = Buffer.concat(chunks).toString("utf-8").trim();
12446
+ }
12447
+ if (!raw) {
12448
+ jsonError("No input received on stdin. Pipe JSON with context.");
12449
+ }
12450
+ let data;
12451
+ try {
12452
+ data = JSON.parse(raw);
12453
+ } catch {
12454
+ jsonError("Invalid JSON input");
12455
+ }
12456
+ if (!data?.context || data.context.trim() === "") {
12457
+ jsonError("JSON must include a non-empty 'context' field");
12458
+ }
12459
+ const userId = await resolveUser(opts, db, { json: true });
12460
+ const truncatedContext = data.context.slice(0, 2e3);
12461
+ const q2 = await embedQuery(db, truncatedContext);
12462
+ try {
12463
+ await ensureTokenEmbeddings(db, {
12464
+ limit: 32,
12465
+ dims: q2?.vector.length
12466
+ });
12467
+ } catch {
12468
+ }
12469
+ let limit = data.limit ?? 10;
12470
+ if (typeof limit !== "number" || limit <= 0 || !Number.isInteger(limit)) {
12471
+ limit = 10;
12472
+ }
12473
+ if (limit > 100) {
12474
+ limit = 100;
12475
+ }
12476
+ const results = await searchTokensHybrid(db, truncatedContext, {
12477
+ queryEmbedding: q2?.vector,
12478
+ model: q2?.model,
12479
+ limit
11858
12480
  });
11859
- await db.close();
11860
- } catch (err) {
11861
- await db?.close();
11862
- if (err.message) {
11863
- jsonError(err.message);
12481
+ const tokens = [];
12482
+ for (const t2 of results) {
12483
+ const card = await getCard(db, t2.id, userId);
12484
+ tokens.push({
12485
+ slug: t2.slug,
12486
+ concept: t2.concept,
12487
+ domain: t2.domain,
12488
+ bloom_level: t2.bloom_level,
12489
+ score: t2.score,
12490
+ similarity: t2.similarity,
12491
+ card: card ? {
12492
+ state: card.state,
12493
+ due_at: card.due_at,
12494
+ blocked: card.blocked
12495
+ } : null
12496
+ });
11864
12497
  }
11865
- }
12498
+ jsonOut2({
12499
+ semantic: q2 !== null,
12500
+ tokens
12501
+ });
12502
+ });
12503
+ });
12504
+ bridgeCommand.command("suggest-foundations").description("Propose existing tokens as foundation/prerequisite candidates").option("--user <id>", "User ID (default: whoami)").action(async (_opts) => {
12505
+ await withDb2(async (db) => {
12506
+ let raw;
12507
+ if (isServeMode) {
12508
+ raw = serveStdinPayload ?? "";
12509
+ } else {
12510
+ const chunks = [];
12511
+ for await (const chunk of process.stdin) {
12512
+ chunks.push(chunk);
12513
+ }
12514
+ raw = Buffer.concat(chunks).toString("utf-8").trim();
12515
+ }
12516
+ if (!raw) {
12517
+ jsonError("No input received on stdin. Pipe JSON.");
12518
+ }
12519
+ let data;
12520
+ try {
12521
+ data = JSON.parse(raw);
12522
+ } catch {
12523
+ jsonError("Invalid JSON input");
12524
+ }
12525
+ let queryText = "";
12526
+ let targetTokenId;
12527
+ let targetBloomLevel;
12528
+ let targetJson = null;
12529
+ if (data?.slug !== void 0) {
12530
+ if (typeof data.slug !== "string" || data.slug.trim() === "") {
12531
+ jsonError("Invalid slug");
12532
+ }
12533
+ const token = await getTokenBySlug(db, data.slug);
12534
+ if (!token) {
12535
+ jsonError(`Token not found: ${data.slug}`);
12536
+ }
12537
+ queryText = embeddingContentForToken(token);
12538
+ targetTokenId = token.id;
12539
+ targetBloomLevel = token.bloom_level;
12540
+ targetJson = { slug: token.slug };
12541
+ } else {
12542
+ if (!data?.concept || typeof data.concept !== "string" || data.concept.trim() === "") {
12543
+ jsonError("JSON must include a non-empty 'slug' or 'concept' field");
12544
+ }
12545
+ queryText = embeddingContentForToken({
12546
+ concept: data.concept,
12547
+ question: typeof data.question === "string" ? data.question : null,
12548
+ domain: typeof data.domain === "string" ? data.domain : ""
12549
+ });
12550
+ if (data.bloom_level !== void 0) {
12551
+ if (typeof data.bloom_level !== "number" || !Number.isInteger(data.bloom_level) || data.bloom_level < 1 || data.bloom_level > 5) {
12552
+ jsonError("bloom_level must be an integer between 1 and 5");
12553
+ }
12554
+ targetBloomLevel = data.bloom_level;
12555
+ }
12556
+ }
12557
+ let limit = data?.limit ?? 5;
12558
+ if (typeof limit !== "number" || limit <= 0 || !Number.isInteger(limit)) {
12559
+ limit = 5;
12560
+ }
12561
+ if (limit > 20) {
12562
+ limit = 20;
12563
+ }
12564
+ const q2 = await embedQuery(db, queryText);
12565
+ if (q2 === null) {
12566
+ jsonOut2({
12567
+ semantic: false,
12568
+ target: targetJson,
12569
+ suggestions: []
12570
+ });
12571
+ return;
12572
+ }
12573
+ try {
12574
+ await ensureTokenEmbeddings(db, {
12575
+ limit: 100,
12576
+ dims: q2.vector.length
12577
+ });
12578
+ } catch {
12579
+ }
12580
+ const maxSimilarity = await resolveDedupThreshold(db);
12581
+ const minSimilarity = await resolveSuggestMinSimilarity(db);
12582
+ if (minSimilarity >= maxSimilarity) {
12583
+ jsonOut2({
12584
+ semantic: true,
12585
+ target: targetJson,
12586
+ suggestions: []
12587
+ });
12588
+ return;
12589
+ }
12590
+ const suggestions = await suggestFoundations(db, {
12591
+ queryEmbedding: q2.vector,
12592
+ model: q2.model,
12593
+ targetTokenId,
12594
+ targetBloomLevel,
12595
+ limit,
12596
+ minSimilarity,
12597
+ maxSimilarity
12598
+ });
12599
+ jsonOut2({
12600
+ semantic: true,
12601
+ target: targetJson,
12602
+ suggestions: suggestions.map((s) => ({
12603
+ slug: s.token.slug,
12604
+ concept: s.token.concept,
12605
+ domain: s.token.domain,
12606
+ bloom_level: s.token.bloom_level,
12607
+ similarity: s.similarity,
12608
+ already_prerequisite: s.alreadyPrerequisite,
12609
+ would_create_cycle: s.wouldCreateCycle,
12610
+ bloom_above_target: s.bloomAboveTarget
12611
+ }))
12612
+ });
12613
+ });
11866
12614
  });
11867
12615
  bridgeCommand.command("discover-skills").description(
11868
12616
  "Analyze monitor logs across sessions to discover recurring patterns"
@@ -12672,12 +13420,13 @@ bridgeCommand.command("check-llm").description("Check if LLM is enabled and onli
12672
13420
  });
12673
13421
  bridgeCommand.command("provider-status").description("Show secret-safe provider status for LLM roles (JSON)").action(async () => {
12674
13422
  await withDb2(async (db) => {
12675
- const [recall, vision, text] = await Promise.all([
13423
+ const [recall, vision, text, embedding] = await Promise.all([
12676
13424
  getProviderRoleStatus(db, "recall"),
12677
13425
  getProviderRoleStatus(db, "vision"),
12678
- getProviderRoleStatus(db, "text")
13426
+ getProviderRoleStatus(db, "text"),
13427
+ getProviderRoleStatus(db, "embedding")
12679
13428
  ]);
12680
- jsonOut2({ roles: { recall, vision, text } });
13429
+ jsonOut2({ roles: { recall, vision, text, embedding } });
12681
13430
  });
12682
13431
  });
12683
13432
  bridgeCommand.command("provider-config-list").description("List provider records and role bindings (JSON)").option("--scope <scope>", "machine (default) or shared", "machine").action(async (opts) => {
@@ -12983,6 +13732,46 @@ bridgeCommand.command("get-settings").description("Get active ZAM settings (JSON
12983
13732
  });
12984
13733
  });
12985
13734
  });
13735
+ async function readDatabaseUserSummaries(db) {
13736
+ return await db.prepare(
13737
+ `SELECT user_id AS id, COUNT(*) AS cardCount
13738
+ FROM cards
13739
+ GROUP BY user_id
13740
+ ORDER BY user_id`
13741
+ ).all();
13742
+ }
13743
+ bridgeCommand.command("database-status").description("Show the active database target and learning profiles (JSON)").action(async () => {
13744
+ const target = getDatabaseTargetInfo();
13745
+ await withDb2(async (db) => {
13746
+ const userId = await getSetting(db, "user.id") ?? null;
13747
+ const users = await readDatabaseUserSummaries(db);
13748
+ jsonOut2({
13749
+ success: true,
13750
+ connected: true,
13751
+ target,
13752
+ userId,
13753
+ cardCount: users.find((user) => user.id === userId)?.cardCount ?? 0,
13754
+ users
13755
+ });
13756
+ });
13757
+ });
13758
+ bridgeCommand.command("database-select-user").description("Select an existing learning profile for this database (JSON)").requiredOption("--user <id>", "Existing user ID").action(async (opts) => {
13759
+ await withDb2(async (db) => {
13760
+ const userId = String(opts.user ?? "").trim();
13761
+ const users = await readDatabaseUserSummaries(db);
13762
+ const selected = users.find((user) => user.id === userId);
13763
+ if (!selected) {
13764
+ jsonError(`Learning profile not found: ${userId}`);
13765
+ return;
13766
+ }
13767
+ await setSetting(db, "user.id", userId);
13768
+ jsonOut2({
13769
+ success: true,
13770
+ userId,
13771
+ cardCount: selected.cardCount
13772
+ });
13773
+ });
13774
+ });
12986
13775
  bridgeCommand.command("list-tokens").description(
12987
13776
  "List tokens (optionally enriched with user card state for viz) (JSON)"
12988
13777
  ).option(
@@ -13707,6 +14496,13 @@ bridgeCommand.command("serve").description("Start the persistent JSON-RPC stdin/
13707
14496
  requestId = req.id ?? null;
13708
14497
  const cmd = req.cmd;
13709
14498
  const args = req.args ?? [];
14499
+ if (typeof req.stdin === "string") {
14500
+ serveStdinPayload = req.stdin;
14501
+ } else if (typeof req.stdin === "object" && req.stdin !== null) {
14502
+ serveStdinPayload = JSON.stringify(req.stdin);
14503
+ } else {
14504
+ serveStdinPayload = void 0;
14505
+ }
13710
14506
  if (!cmd) {
13711
14507
  return JSON.stringify({
13712
14508
  id: requestId,
@@ -13761,6 +14557,8 @@ bridgeCommand.command("serve").description("Start the persistent JSON-RPC stdin/
13761
14557
  id: requestId,
13762
14558
  error: `Invalid JSON request: ${err.message}`
13763
14559
  });
14560
+ } finally {
14561
+ serveStdinPayload = void 0;
13764
14562
  }
13765
14563
  };
13766
14564
  const readline = await import("readline");
@@ -16766,7 +17564,7 @@ import { Command as Command20 } from "commander";
16766
17564
  var tokenCommand = new Command20("token").description(
16767
17565
  "Manage knowledge tokens"
16768
17566
  );
16769
- 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("--json", "Output as JSON").option("--quiet", "Suppress output (exit code only)").action(async (opts) => {
17567
+ 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) => {
16770
17568
  await withDb(async (db) => {
16771
17569
  let question = opts.question || null;
16772
17570
  if (!question) {
@@ -16776,6 +17574,11 @@ tokenCommand.command("register").description("Register a new knowledge token").r
16776
17574
  opts.domain
16777
17575
  );
16778
17576
  }
17577
+ const possibleDuplicates = await findPossibleDuplicates(db, {
17578
+ concept: opts.concept,
17579
+ question,
17580
+ domain: opts.domain
17581
+ });
16779
17582
  const token = await createToken(db, {
16780
17583
  slug: opts.slug,
16781
17584
  concept: opts.concept,
@@ -16784,9 +17587,30 @@ tokenCommand.command("register").description("Register a new knowledge token").r
16784
17587
  source_link: opts.sourceLink || null,
16785
17588
  question
16786
17589
  });
17590
+ let cardUserId = null;
17591
+ if (opts.card !== false) {
17592
+ cardUserId = opts.user ?? await getSetting(db, "user.id");
17593
+ if (cardUserId) {
17594
+ await ensureCard(db, token.id, cardUserId);
17595
+ }
17596
+ }
17597
+ try {
17598
+ await ensureTokenEmbeddings(db, { limit: 8 });
17599
+ } catch {
17600
+ }
16787
17601
  if (opts.quiet) return;
16788
17602
  if (opts.json) {
16789
- console.log(JSON.stringify(token, null, 2));
17603
+ console.log(
17604
+ JSON.stringify(
17605
+ {
17606
+ ...token,
17607
+ card: cardUserId ? { userId: cardUserId } : null,
17608
+ possible_duplicates: possibleDuplicates
17609
+ },
17610
+ null,
17611
+ 2
17612
+ )
17613
+ );
16790
17614
  } else {
16791
17615
  console.log(`Registered token: ${token.slug} (${token.id})`);
16792
17616
  console.log(` Concept: ${token.concept}`);
@@ -16796,12 +17620,81 @@ tokenCommand.command("register").description("Register a new knowledge token").r
16796
17620
  if (token.source_link) {
16797
17621
  console.log(` Source: ${token.source_link}`);
16798
17622
  }
17623
+ if (cardUserId) {
17624
+ console.log(` Card: created for ${cardUserId}`);
17625
+ } else if (opts.card === false) {
17626
+ console.log(` Card: skipped (--no-card)`);
17627
+ } else {
17628
+ console.log(` Card: skipped (no default user set)`);
17629
+ }
17630
+ if (possibleDuplicates.length > 0) {
17631
+ console.log(`
17632
+ WARNING: Possible duplicate tokens found:`);
17633
+ for (const dup of possibleDuplicates) {
17634
+ console.log(
17635
+ ` - ${dup.slug} (similarity: ${dup.similarity.toFixed(2)})`
17636
+ );
17637
+ }
17638
+ }
17639
+ try {
17640
+ const queryText = embeddingContentForToken({
17641
+ concept: opts.concept,
17642
+ question: question ?? null,
17643
+ domain: opts.domain ?? ""
17644
+ });
17645
+ const q2 = await embedQuery(db, queryText);
17646
+ if (q2) {
17647
+ const maxSimilarity = await resolveDedupThreshold(db);
17648
+ const minSimilarity = await resolveSuggestMinSimilarity(db);
17649
+ const suggestions = await suggestFoundations(db, {
17650
+ queryEmbedding: q2.vector,
17651
+ model: q2.model,
17652
+ targetTokenId: token.id,
17653
+ targetBloomLevel: Number(opts.bloom),
17654
+ limit: 3,
17655
+ minSimilarity,
17656
+ maxSimilarity
17657
+ });
17658
+ const filtered = suggestions.filter(
17659
+ (s) => !s.wouldCreateCycle && !s.alreadyPrerequisite
17660
+ );
17661
+ if (filtered.length > 0) {
17662
+ console.log(
17663
+ `
17664
+ Related existing tokens as potential foundations:`
17665
+ );
17666
+ for (const s of filtered) {
17667
+ const note = s.bloomAboveTarget ? " (higher bloom than target)" : "";
17668
+ console.log(
17669
+ ` - ${s.token.slug} (similarity: ${s.similarity.toFixed(2)})${note}`
17670
+ );
17671
+ }
17672
+ console.log(
17673
+ `Link with: zam token prereq --token <slug> --requires <slug> (see zam token prereq --help)`
17674
+ );
17675
+ }
17676
+ }
17677
+ } catch {
17678
+ }
16799
17679
  }
16800
17680
  });
16801
17681
  });
16802
17682
  tokenCommand.command("find").description("Fuzzy search for tokens").requiredOption("--query <query>", "Search query").option("--json", "Output as JSON").option("--quiet", "Suppress output (exit code only)").action(async (opts) => {
16803
17683
  await withDb(async (db) => {
16804
- const results = await findTokens(db, opts.query);
17684
+ const q2 = await embedQuery(db, opts.query);
17685
+ const embRes = await ensureTokenEmbeddings(db, {
17686
+ limit: 32,
17687
+ dims: q2?.vector.length
17688
+ });
17689
+ if (embRes.status === "unavailable" && !opts.json && !opts.quiet) {
17690
+ console.error(
17691
+ `Note: semantic search unavailable (${embRes.reason}) \u2014 lexical matches only.`
17692
+ );
17693
+ }
17694
+ const results = await searchTokensHybrid(db, opts.query, {
17695
+ queryEmbedding: q2?.vector,
17696
+ model: q2?.model
17697
+ });
16805
17698
  if (opts.quiet) return;
16806
17699
  if (opts.json) {
16807
17700
  console.log(JSON.stringify(results, null, 2));
@@ -16814,12 +17707,14 @@ tokenCommand.command("find").description("Fuzzy search for tokens").requiredOpti
16814
17707
  console.log(`Found ${results.length} token(s):
16815
17708
  `);
16816
17709
  console.log(
16817
- "Score Slug Concept Domain Bloom"
17710
+ "Score Sim Slug Concept Domain Bloom"
16818
17711
  );
16819
- console.log("\u2500".repeat(90));
17712
+ console.log("\u2500".repeat(95));
16820
17713
  for (const t2 of results) {
17714
+ const scoreStr = t2.score.toFixed(3).padEnd(6);
17715
+ const simStr = (t2.similarity?.toFixed(2) ?? "-").padEnd(4);
16821
17716
  console.log(
16822
- `${String(t2.score).padEnd(6)} ${t2.slug.padEnd(21)} ${t2.concept.slice(0, 31).padEnd(31)} ${(t2.domain || "-").padEnd(11)} ${t2.bloom_level}`
17717
+ `${scoreStr} ${simStr} ${t2.slug.padEnd(21)} ${t2.concept.slice(0, 31).padEnd(31)} ${(t2.domain || "-").padEnd(11)} ${t2.bloom_level}`
16823
17718
  );
16824
17719
  }
16825
17720
  });
@@ -17047,6 +17942,56 @@ tokenCommand.command("status").description("Show full status of a token for a us
17047
17942
  }
17048
17943
  });
17049
17944
  });
17945
+ tokenCommand.command("reembed").description("Backfill or refresh semantic-search embeddings for tokens").option("--all", "Force re-embed every token, including already-fresh ones").option("--json", "Output as JSON").option("--quiet", "Suppress output (exit code only)").action(async (opts) => {
17946
+ await withDb(async (db) => {
17947
+ const probe = await embedQuery(db, "ZAM embedding dimension probe");
17948
+ const model = probe?.model ?? null;
17949
+ const dims = probe?.vector.length;
17950
+ const before = model ? await getEmbeddingCoverage(db, model, { dims }) : { tokens: 0, embedded: 0, missing: 0, stale: 0 };
17951
+ let embedded = 0;
17952
+ let reason;
17953
+ let force = Boolean(opts.all);
17954
+ while (true) {
17955
+ const result = await ensureTokenEmbeddings(
17956
+ db,
17957
+ force ? { force: true, limit: Number.MAX_SAFE_INTEGER, dims } : { dims }
17958
+ );
17959
+ force = false;
17960
+ embedded += result.embedded;
17961
+ if (result.status === "unavailable") {
17962
+ reason = result.reason;
17963
+ break;
17964
+ }
17965
+ if (result.remaining === 0 || result.embedded === 0) break;
17966
+ }
17967
+ if (reason !== void 0) {
17968
+ if (opts.quiet) {
17969
+ process.exit(1);
17970
+ }
17971
+ if (opts.json) {
17972
+ jsonOut({ error: reason, embedded });
17973
+ } else {
17974
+ console.error(`Error: ${reason}`);
17975
+ if (embedded > 0) {
17976
+ console.error(
17977
+ `(${embedded} token(s) were embedded before the failure)`
17978
+ );
17979
+ }
17980
+ }
17981
+ process.exit(1);
17982
+ }
17983
+ const after = await getEmbeddingCoverage(db, model, { dims });
17984
+ if (opts.quiet) return;
17985
+ const staleBefore = before.missing + before.stale;
17986
+ if (opts.json) {
17987
+ jsonOut({ embedded, model, before, after });
17988
+ return;
17989
+ }
17990
+ console.log(
17991
+ `Embedded ${embedded} tokens (${after.tokens} total, ${staleBefore} stale before) with ${model}`
17992
+ );
17993
+ });
17994
+ });
17050
17995
 
17051
17996
  // src/cli/commands/ui.ts
17052
17997
  import { spawn as spawn3, spawnSync } from "child_process";