zam-core 0.6.3 → 0.7.0

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
@@ -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,82 @@ 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
+
4549
4792
  // src/kernel/system/hooks.ts
4550
4793
  import {
4551
4794
  appendFileSync as appendFileSync3,
@@ -6092,6 +6335,21 @@ async function getLegacyRoleConfig(db, role, enabled) {
6092
6335
  maxFrames: Number.isNaN(parsed) ? 100 : parsed
6093
6336
  };
6094
6337
  }
6338
+ if (role === "embedding") {
6339
+ const url = await getSetting(db, "llm.embedding.url") || base.url;
6340
+ return {
6341
+ enabled,
6342
+ url,
6343
+ // Kept as a literal (not imported) to avoid a client.ts <-> embedder.ts
6344
+ // import cycle; must match DEFAULT_EMBEDDING_MODEL in embedder.ts.
6345
+ model: await getSetting(db, "llm.embedding.model") || "embeddinggemma",
6346
+ apiKey: await getSetting(db, "llm.embedding.api_key") || base.apiKey,
6347
+ apiFlavor: inferApiFlavor(url),
6348
+ locale: base.locale,
6349
+ source: "legacy",
6350
+ local: isLocalEndpoint(url)
6351
+ };
6352
+ }
6095
6353
  return {
6096
6354
  enabled,
6097
6355
  url: base.url,
@@ -10110,6 +10368,219 @@ function getCurriculumProvider(id) {
10110
10368
  return CURRICULUM_PROVIDERS.find((provider) => provider.id === id);
10111
10369
  }
10112
10370
 
10371
+ // src/cli/llm/embedder.ts
10372
+ var CANONICAL_EMBEDDING_MODEL_ID = "embeddinggemma-300m";
10373
+ var EMBEDDINGGEMMA_ALIASES = /* @__PURE__ */ new Set([
10374
+ "embeddinggemma",
10375
+ "embeddinggemma:300m",
10376
+ "embeddinggemma-300m",
10377
+ "embed-gemma",
10378
+ "embed-gemma:300m",
10379
+ "google/embeddinggemma-300m"
10380
+ ]);
10381
+ function canonicalEmbeddingModelId(model) {
10382
+ const lowered = model.trim().toLowerCase();
10383
+ if (EMBEDDINGGEMMA_ALIASES.has(lowered)) {
10384
+ return CANONICAL_EMBEDDING_MODEL_ID;
10385
+ }
10386
+ return lowered;
10387
+ }
10388
+ function embeddingTextForToken(t2, model) {
10389
+ const isGemma = EMBEDDINGGEMMA_ALIASES.has(model.trim().toLowerCase());
10390
+ const content = embeddingContentForToken(t2);
10391
+ return isGemma ? `title: none | text: ${content}` : content;
10392
+ }
10393
+ function embeddingTextForQuery(text, model) {
10394
+ const isGemma = EMBEDDINGGEMMA_ALIASES.has(model.trim().toLowerCase());
10395
+ return isGemma ? `task: search result | query: ${text}` : text;
10396
+ }
10397
+ async function resolveUsableEmbeddingEndpoint(db) {
10398
+ const cfg = await getProviderForRole(db, "embedding");
10399
+ if (!cfg.enabled) return null;
10400
+ for (const endpoint of [cfg, ...cfg.fallback ? [cfg.fallback] : []]) {
10401
+ if (endpoint.apiFlavor !== "chat-completions") continue;
10402
+ const online = await isLlmOnline(endpoint.url);
10403
+ if (!online) continue;
10404
+ const availableModels = await getAvailableModels(
10405
+ endpoint.url,
10406
+ endpoint.apiKey
10407
+ );
10408
+ const modelAvailable = availableModels.length === 0 || availableModels.some(
10409
+ (candidate) => candidate.toLowerCase() === endpoint.model.toLowerCase()
10410
+ );
10411
+ if (modelAvailable) return endpoint;
10412
+ }
10413
+ return null;
10414
+ }
10415
+ async function embedTexts(endpoint, texts) {
10416
+ const controller = new AbortController();
10417
+ const timeoutId = setTimeout(() => controller.abort(), 6e4);
10418
+ let res;
10419
+ try {
10420
+ res = await fetch(`${endpoint.url}/embeddings`, {
10421
+ method: "POST",
10422
+ headers: {
10423
+ "Content-Type": "application/json",
10424
+ Authorization: `Bearer ${endpoint.apiKey || DEFAULT_LLM_API_KEY}`
10425
+ },
10426
+ body: JSON.stringify({ model: endpoint.model, input: texts }),
10427
+ signal: controller.signal
10428
+ });
10429
+ } finally {
10430
+ clearTimeout(timeoutId);
10431
+ }
10432
+ if (!res.ok) {
10433
+ const body = await res.text().catch(() => "");
10434
+ throw new Error(
10435
+ `Embedding request failed: ${res.statusText} (${res.status}) - ${body}`
10436
+ );
10437
+ }
10438
+ const data = await res.json();
10439
+ const items = data.data ?? [];
10440
+ if (items.length !== texts.length) {
10441
+ throw new Error(
10442
+ `Embedding response returned ${items.length} vectors for ${texts.length} inputs`
10443
+ );
10444
+ }
10445
+ const ordered = new Array(texts.length);
10446
+ for (let i = 0; i < items.length; i++) {
10447
+ const item = items[i];
10448
+ const index = typeof item.index === "number" ? item.index : i;
10449
+ if (!Number.isInteger(index)) {
10450
+ throw new Error("Embedding response item is missing a valid index");
10451
+ }
10452
+ if (index < 0 || index >= texts.length) {
10453
+ throw new Error(`Embedding response index out of range: ${index}`);
10454
+ }
10455
+ if (!Array.isArray(item.embedding) || item.embedding.length === 0 || !item.embedding.every((n) => typeof n === "number" && Number.isFinite(n))) {
10456
+ throw new Error(
10457
+ `Embedding response at index ${index} is not a non-empty array of finite numbers`
10458
+ );
10459
+ }
10460
+ ordered[index] = item.embedding;
10461
+ }
10462
+ const firstLength = ordered[0]?.length;
10463
+ if (ordered.some((vec) => vec === void 0 || vec.length !== firstLength)) {
10464
+ throw new Error("Embedding response vectors have inconsistent lengths");
10465
+ }
10466
+ return ordered;
10467
+ }
10468
+ var EMBED_BATCH_SIZE = 16;
10469
+ async function ensureTokenEmbeddings(db, opts) {
10470
+ const endpoint = await resolveUsableEmbeddingEndpoint(db);
10471
+ if (!endpoint) {
10472
+ const reason = await describeUnavailableReason(db);
10473
+ return { status: "unavailable", embedded: 0, remaining: 0, reason };
10474
+ }
10475
+ const model = canonicalEmbeddingModelId(endpoint.model);
10476
+ const pending = await listTokensNeedingEmbedding(db, model, {
10477
+ limit: opts?.limit ?? 64,
10478
+ force: opts?.force,
10479
+ dims: opts?.dims
10480
+ });
10481
+ let embedded = 0;
10482
+ let failure;
10483
+ try {
10484
+ for (let i = 0; i < pending.length; i += EMBED_BATCH_SIZE) {
10485
+ const batch = pending.slice(i, i + EMBED_BATCH_SIZE);
10486
+ const formattedTexts = batch.map(
10487
+ (item) => embeddingTextForToken(item.token, endpoint.model)
10488
+ );
10489
+ const vectors = await embedTexts(
10490
+ { url: endpoint.url, model: endpoint.model, apiKey: endpoint.apiKey },
10491
+ formattedTexts
10492
+ );
10493
+ for (const [index, item] of batch.entries()) {
10494
+ await upsertTokenEmbedding(db, {
10495
+ tokenId: item.token.id,
10496
+ embedding: vectors[index],
10497
+ model,
10498
+ contentHash: computeContentHash(item.text)
10499
+ });
10500
+ embedded++;
10501
+ }
10502
+ }
10503
+ } catch (err) {
10504
+ failure = err instanceof Error ? err.message : String(err);
10505
+ }
10506
+ const coverage = await getEmbeddingCoverage(db, model, { dims: opts?.dims });
10507
+ const remaining = coverage.missing + coverage.stale;
10508
+ if (failure !== void 0) {
10509
+ return { status: "unavailable", embedded, remaining, reason: failure };
10510
+ }
10511
+ return { status: "ok", embedded, remaining };
10512
+ }
10513
+ async function describeUnavailableReason(db) {
10514
+ const cfg = await getProviderForRole(db, "embedding");
10515
+ if (!cfg.enabled) {
10516
+ return "embedding role is disabled in settings (llm.enabled)";
10517
+ }
10518
+ const endpoints = [cfg, ...cfg.fallback ? [cfg.fallback] : []];
10519
+ const online = await Promise.all(
10520
+ endpoints.map((endpoint) => isLlmOnline(endpoint.url))
10521
+ );
10522
+ if (!online.some(Boolean)) {
10523
+ return `embedding endpoints offline (${endpoints.map((endpoint) => endpoint.url).join(", ")})`;
10524
+ }
10525
+ return `no configured embedding model is available (${endpoints.map((endpoint) => endpoint.model).join(", ")})`;
10526
+ }
10527
+ async function embedQuery(db, text) {
10528
+ const endpoint = await resolveUsableEmbeddingEndpoint(db);
10529
+ if (!endpoint) return null;
10530
+ try {
10531
+ const [vector] = await embedTexts(
10532
+ { url: endpoint.url, model: endpoint.model, apiKey: endpoint.apiKey },
10533
+ [embeddingTextForQuery(text, endpoint.model)]
10534
+ );
10535
+ return { vector, model: canonicalEmbeddingModelId(endpoint.model) };
10536
+ } catch {
10537
+ return null;
10538
+ }
10539
+ }
10540
+ async function findPossibleDuplicates(db, candidate, embed = embedQuery) {
10541
+ const queryText = embeddingContentForToken({
10542
+ concept: candidate.concept,
10543
+ question: candidate.question ?? null,
10544
+ domain: candidate.domain ?? ""
10545
+ });
10546
+ const q2 = await embed(db, queryText);
10547
+ if (!q2) {
10548
+ return [];
10549
+ }
10550
+ const coverage = await getEmbeddingCoverage(db, q2.model, {
10551
+ dims: q2.vector.length
10552
+ });
10553
+ const totalNeeding = coverage.missing + coverage.stale;
10554
+ if (totalNeeding > 0) {
10555
+ await ensureTokenEmbeddings(db, { limit: 100, dims: q2.vector.length });
10556
+ if (totalNeeding > 100) {
10557
+ console.warn(
10558
+ `Warning: Duplicate check is incomplete. ${totalNeeding - 100} tokens are still missing embeddings. Run 'zam token reembed' to complete indexing.`
10559
+ );
10560
+ }
10561
+ }
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;
10565
+ const hits = await searchTokensHybrid(db, queryText, {
10566
+ queryEmbedding: q2.vector,
10567
+ model: q2.model,
10568
+ limit: 1e3,
10569
+ vectorTopK: 1e3
10570
+ });
10571
+ const results = [];
10572
+ for (const hit of hits) {
10573
+ if (hit.similarity !== null && hit.similarity >= threshold) {
10574
+ results.push({
10575
+ slug: hit.slug,
10576
+ concept: hit.concept,
10577
+ similarity: hit.similarity
10578
+ });
10579
+ }
10580
+ }
10581
+ return results;
10582
+ }
10583
+
10113
10584
  // src/cli/llm/vision.ts
10114
10585
  import { randomBytes } from "crypto";
10115
10586
  import { readFileSync as readFileSync10 } from "fs";
@@ -10549,7 +11020,7 @@ var VALID_API_FLAVORS = [
10549
11020
  "chat-completions",
10550
11021
  "anthropic-messages"
10551
11022
  ];
10552
- var VALID_ROLES = ["vision", "recall", "text"];
11023
+ var VALID_ROLES = ["vision", "recall", "text", "embedding"];
10553
11024
  function upsertProviderRecord(providers, name, patch) {
10554
11025
  const merged = { ...providers[name] ?? {} };
10555
11026
  if (patch.url !== void 0) merged.url = patch.url;
@@ -11182,14 +11653,23 @@ async function backupDatabaseTo(db, targetDir) {
11182
11653
 
11183
11654
  // src/cli/commands/bridge.ts
11184
11655
  var isServeMode = false;
11656
+ var serveStdinPayload;
11185
11657
  function jsonOut2(data) {
11186
11658
  console.log(JSON.stringify(data, null, 2));
11187
11659
  }
11188
11660
  function jsonError(message) {
11661
+ let msg = message;
11662
+ if (message.startsWith('{"error":')) {
11663
+ try {
11664
+ const parsed = JSON.parse(message);
11665
+ msg = parsed.error;
11666
+ } catch {
11667
+ }
11668
+ }
11189
11669
  if (isServeMode) {
11190
- throw new Error(JSON.stringify({ error: message }));
11670
+ throw new Error(JSON.stringify({ error: msg }));
11191
11671
  }
11192
- console.log(JSON.stringify({ error: message }, null, 2));
11672
+ console.log(JSON.stringify({ error: msg }, null, 2));
11193
11673
  process.exit(1);
11194
11674
  }
11195
11675
  function parseNonNegativeIntegerOption(name, value) {
@@ -11512,7 +11992,10 @@ bridgeCommand.command("agent-open").description("Launch an agent harness in the
11512
11992
  });
11513
11993
  });
11514
11994
  });
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) => {
11995
+ 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(
11996
+ "--no-dynamic-question",
11997
+ "Use the stored question without generating a fresh LLM question"
11998
+ ).action(async (opts) => {
11516
11999
  await withDb2(async (db) => {
11517
12000
  const userId = await resolveUser(opts, db, { json: true });
11518
12001
  const queue = await buildReviewQueue(db, {
@@ -11536,7 +12019,7 @@ bridgeCommand.command("get-review").description("Get next review card with promp
11536
12019
  let resolvedQuestion = item.question;
11537
12020
  let questionSource = "original";
11538
12021
  let questionModel;
11539
- if (isLlmEnabled) {
12022
+ if (isLlmEnabled && opts.dynamicQuestion !== false) {
11540
12023
  try {
11541
12024
  const healed = await ensureHighQualityQuestion(db, {
11542
12025
  id: item.tokenId,
@@ -11783,11 +12266,16 @@ bridgeCommand.command("analyze-monitor").description("Analyze monitor log with t
11783
12266
  });
11784
12267
  return;
11785
12268
  }
11786
- const chunks = [];
11787
- for await (const chunk of process.stdin) {
11788
- chunks.push(chunk);
12269
+ let raw;
12270
+ if (isServeMode) {
12271
+ raw = serveStdinPayload ?? "";
12272
+ } else {
12273
+ const chunks = [];
12274
+ for await (const chunk of process.stdin) {
12275
+ chunks.push(chunk);
12276
+ }
12277
+ raw = Buffer.concat(chunks).toString("utf-8").trim();
11789
12278
  }
11790
- const raw = Buffer.concat(chunks).toString("utf-8").trim();
11791
12279
  if (!raw) {
11792
12280
  jsonError("No input received on stdin. Pipe JSON with token patterns.");
11793
12281
  }
@@ -11812,13 +12300,17 @@ bridgeCommand.command("analyze-monitor").description("Analyze monitor log with t
11812
12300
  }
11813
12301
  });
11814
12302
  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);
12303
+ await withDb2(async (db) => {
12304
+ let raw;
12305
+ if (isServeMode) {
12306
+ raw = serveStdinPayload ?? "";
12307
+ } else {
12308
+ const chunks = [];
12309
+ for await (const chunk of process.stdin) {
12310
+ chunks.push(chunk);
12311
+ }
12312
+ raw = Buffer.concat(chunks).toString("utf-8").trim();
11820
12313
  }
11821
- const raw = Buffer.concat(chunks).toString("utf-8").trim();
11822
12314
  if (!raw) {
11823
12315
  jsonError("No input received on stdin. Pipe JSON with token data.");
11824
12316
  }
@@ -11831,8 +12323,12 @@ bridgeCommand.command("add-token").description("Create a token + card from JSON
11831
12323
  if (!data?.slug || !data?.concept) {
11832
12324
  jsonError("JSON must include 'slug' and 'concept' fields");
11833
12325
  }
11834
- db = await openDatabase();
11835
12326
  const userId = await resolveUser(opts, db, { json: true });
12327
+ const possibleDuplicates = await findPossibleDuplicates(db, {
12328
+ concept: data?.concept,
12329
+ question: data?.question ?? null,
12330
+ domain: data?.domain
12331
+ });
11836
12332
  const token = await createToken(db, {
11837
12333
  slug: data?.slug,
11838
12334
  concept: data?.concept,
@@ -11844,6 +12340,10 @@ bridgeCommand.command("add-token").description("Create a token + card from JSON
11844
12340
  question: data?.question ?? null
11845
12341
  });
11846
12342
  const card = await ensureCard(db, token.id, userId);
12343
+ try {
12344
+ await ensureTokenEmbeddings(db, { limit: 8 });
12345
+ } catch {
12346
+ }
11847
12347
  jsonOut2({
11848
12348
  success: true,
11849
12349
  token,
@@ -11854,15 +12354,79 @@ bridgeCommand.command("add-token").description("Create a token + card from JSON
11854
12354
  state: card.state,
11855
12355
  dueAt: card.due_at,
11856
12356
  blocked: card.blocked
12357
+ },
12358
+ possible_duplicates: possibleDuplicates
12359
+ });
12360
+ });
12361
+ });
12362
+ bridgeCommand.command("relevant-tokens").description("Find tokens relevant to a given context").option("--user <id>", "User ID (default: whoami)").action(async (opts) => {
12363
+ await withDb2(async (db) => {
12364
+ let raw;
12365
+ if (isServeMode) {
12366
+ raw = serveStdinPayload ?? "";
12367
+ } else {
12368
+ const chunks = [];
12369
+ for await (const chunk of process.stdin) {
12370
+ chunks.push(chunk);
11857
12371
  }
12372
+ raw = Buffer.concat(chunks).toString("utf-8").trim();
12373
+ }
12374
+ if (!raw) {
12375
+ jsonError("No input received on stdin. Pipe JSON with context.");
12376
+ }
12377
+ let data;
12378
+ try {
12379
+ data = JSON.parse(raw);
12380
+ } catch {
12381
+ jsonError("Invalid JSON input");
12382
+ }
12383
+ if (!data?.context || data.context.trim() === "") {
12384
+ jsonError("JSON must include a non-empty 'context' field");
12385
+ }
12386
+ const userId = await resolveUser(opts, db, { json: true });
12387
+ const truncatedContext = data.context.slice(0, 2e3);
12388
+ const q2 = await embedQuery(db, truncatedContext);
12389
+ try {
12390
+ await ensureTokenEmbeddings(db, {
12391
+ limit: 32,
12392
+ dims: q2?.vector.length
12393
+ });
12394
+ } catch {
12395
+ }
12396
+ let limit = data.limit ?? 10;
12397
+ if (typeof limit !== "number" || limit <= 0 || !Number.isInteger(limit)) {
12398
+ limit = 10;
12399
+ }
12400
+ if (limit > 100) {
12401
+ limit = 100;
12402
+ }
12403
+ const results = await searchTokensHybrid(db, truncatedContext, {
12404
+ queryEmbedding: q2?.vector,
12405
+ model: q2?.model,
12406
+ limit
11858
12407
  });
11859
- await db.close();
11860
- } catch (err) {
11861
- await db?.close();
11862
- if (err.message) {
11863
- jsonError(err.message);
12408
+ const tokens = [];
12409
+ for (const t2 of results) {
12410
+ const card = await getCard(db, t2.id, userId);
12411
+ tokens.push({
12412
+ slug: t2.slug,
12413
+ concept: t2.concept,
12414
+ domain: t2.domain,
12415
+ bloom_level: t2.bloom_level,
12416
+ score: t2.score,
12417
+ similarity: t2.similarity,
12418
+ card: card ? {
12419
+ state: card.state,
12420
+ due_at: card.due_at,
12421
+ blocked: card.blocked
12422
+ } : null
12423
+ });
11864
12424
  }
11865
- }
12425
+ jsonOut2({
12426
+ semantic: q2 !== null,
12427
+ tokens
12428
+ });
12429
+ });
11866
12430
  });
11867
12431
  bridgeCommand.command("discover-skills").description(
11868
12432
  "Analyze monitor logs across sessions to discover recurring patterns"
@@ -12672,12 +13236,13 @@ bridgeCommand.command("check-llm").description("Check if LLM is enabled and onli
12672
13236
  });
12673
13237
  bridgeCommand.command("provider-status").description("Show secret-safe provider status for LLM roles (JSON)").action(async () => {
12674
13238
  await withDb2(async (db) => {
12675
- const [recall, vision, text] = await Promise.all([
13239
+ const [recall, vision, text, embedding] = await Promise.all([
12676
13240
  getProviderRoleStatus(db, "recall"),
12677
13241
  getProviderRoleStatus(db, "vision"),
12678
- getProviderRoleStatus(db, "text")
13242
+ getProviderRoleStatus(db, "text"),
13243
+ getProviderRoleStatus(db, "embedding")
12679
13244
  ]);
12680
- jsonOut2({ roles: { recall, vision, text } });
13245
+ jsonOut2({ roles: { recall, vision, text, embedding } });
12681
13246
  });
12682
13247
  });
12683
13248
  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 +13548,46 @@ bridgeCommand.command("get-settings").description("Get active ZAM settings (JSON
12983
13548
  });
12984
13549
  });
12985
13550
  });
13551
+ async function readDatabaseUserSummaries(db) {
13552
+ return await db.prepare(
13553
+ `SELECT user_id AS id, COUNT(*) AS cardCount
13554
+ FROM cards
13555
+ GROUP BY user_id
13556
+ ORDER BY user_id`
13557
+ ).all();
13558
+ }
13559
+ bridgeCommand.command("database-status").description("Show the active database target and learning profiles (JSON)").action(async () => {
13560
+ const target = getDatabaseTargetInfo();
13561
+ await withDb2(async (db) => {
13562
+ const userId = await getSetting(db, "user.id") ?? null;
13563
+ const users = await readDatabaseUserSummaries(db);
13564
+ jsonOut2({
13565
+ success: true,
13566
+ connected: true,
13567
+ target,
13568
+ userId,
13569
+ cardCount: users.find((user) => user.id === userId)?.cardCount ?? 0,
13570
+ users
13571
+ });
13572
+ });
13573
+ });
13574
+ bridgeCommand.command("database-select-user").description("Select an existing learning profile for this database (JSON)").requiredOption("--user <id>", "Existing user ID").action(async (opts) => {
13575
+ await withDb2(async (db) => {
13576
+ const userId = String(opts.user ?? "").trim();
13577
+ const users = await readDatabaseUserSummaries(db);
13578
+ const selected = users.find((user) => user.id === userId);
13579
+ if (!selected) {
13580
+ jsonError(`Learning profile not found: ${userId}`);
13581
+ return;
13582
+ }
13583
+ await setSetting(db, "user.id", userId);
13584
+ jsonOut2({
13585
+ success: true,
13586
+ userId,
13587
+ cardCount: selected.cardCount
13588
+ });
13589
+ });
13590
+ });
12986
13591
  bridgeCommand.command("list-tokens").description(
12987
13592
  "List tokens (optionally enriched with user card state for viz) (JSON)"
12988
13593
  ).option(
@@ -13707,6 +14312,13 @@ bridgeCommand.command("serve").description("Start the persistent JSON-RPC stdin/
13707
14312
  requestId = req.id ?? null;
13708
14313
  const cmd = req.cmd;
13709
14314
  const args = req.args ?? [];
14315
+ if (typeof req.stdin === "string") {
14316
+ serveStdinPayload = req.stdin;
14317
+ } else if (typeof req.stdin === "object" && req.stdin !== null) {
14318
+ serveStdinPayload = JSON.stringify(req.stdin);
14319
+ } else {
14320
+ serveStdinPayload = void 0;
14321
+ }
13710
14322
  if (!cmd) {
13711
14323
  return JSON.stringify({
13712
14324
  id: requestId,
@@ -13761,6 +14373,8 @@ bridgeCommand.command("serve").description("Start the persistent JSON-RPC stdin/
13761
14373
  id: requestId,
13762
14374
  error: `Invalid JSON request: ${err.message}`
13763
14375
  });
14376
+ } finally {
14377
+ serveStdinPayload = void 0;
13764
14378
  }
13765
14379
  };
13766
14380
  const readline = await import("readline");
@@ -16766,7 +17380,7 @@ import { Command as Command20 } from "commander";
16766
17380
  var tokenCommand = new Command20("token").description(
16767
17381
  "Manage knowledge tokens"
16768
17382
  );
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) => {
17383
+ 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
17384
  await withDb(async (db) => {
16771
17385
  let question = opts.question || null;
16772
17386
  if (!question) {
@@ -16776,6 +17390,11 @@ tokenCommand.command("register").description("Register a new knowledge token").r
16776
17390
  opts.domain
16777
17391
  );
16778
17392
  }
17393
+ const possibleDuplicates = await findPossibleDuplicates(db, {
17394
+ concept: opts.concept,
17395
+ question,
17396
+ domain: opts.domain
17397
+ });
16779
17398
  const token = await createToken(db, {
16780
17399
  slug: opts.slug,
16781
17400
  concept: opts.concept,
@@ -16784,9 +17403,30 @@ tokenCommand.command("register").description("Register a new knowledge token").r
16784
17403
  source_link: opts.sourceLink || null,
16785
17404
  question
16786
17405
  });
17406
+ let cardUserId = null;
17407
+ if (opts.card !== false) {
17408
+ cardUserId = opts.user ?? await getSetting(db, "user.id");
17409
+ if (cardUserId) {
17410
+ await ensureCard(db, token.id, cardUserId);
17411
+ }
17412
+ }
17413
+ try {
17414
+ await ensureTokenEmbeddings(db, { limit: 8 });
17415
+ } catch {
17416
+ }
16787
17417
  if (opts.quiet) return;
16788
17418
  if (opts.json) {
16789
- console.log(JSON.stringify(token, null, 2));
17419
+ console.log(
17420
+ JSON.stringify(
17421
+ {
17422
+ ...token,
17423
+ card: cardUserId ? { userId: cardUserId } : null,
17424
+ possible_duplicates: possibleDuplicates
17425
+ },
17426
+ null,
17427
+ 2
17428
+ )
17429
+ );
16790
17430
  } else {
16791
17431
  console.log(`Registered token: ${token.slug} (${token.id})`);
16792
17432
  console.log(` Concept: ${token.concept}`);
@@ -16796,12 +17436,41 @@ tokenCommand.command("register").description("Register a new knowledge token").r
16796
17436
  if (token.source_link) {
16797
17437
  console.log(` Source: ${token.source_link}`);
16798
17438
  }
17439
+ if (cardUserId) {
17440
+ console.log(` Card: created for ${cardUserId}`);
17441
+ } else if (opts.card === false) {
17442
+ console.log(` Card: skipped (--no-card)`);
17443
+ } else {
17444
+ console.log(` Card: skipped (no default user set)`);
17445
+ }
17446
+ if (possibleDuplicates.length > 0) {
17447
+ console.log(`
17448
+ WARNING: Possible duplicate tokens found:`);
17449
+ for (const dup of possibleDuplicates) {
17450
+ console.log(
17451
+ ` - ${dup.slug} (similarity: ${dup.similarity.toFixed(2)})`
17452
+ );
17453
+ }
17454
+ }
16799
17455
  }
16800
17456
  });
16801
17457
  });
16802
17458
  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
17459
  await withDb(async (db) => {
16804
- const results = await findTokens(db, opts.query);
17460
+ const q2 = await embedQuery(db, opts.query);
17461
+ const embRes = await ensureTokenEmbeddings(db, {
17462
+ limit: 32,
17463
+ dims: q2?.vector.length
17464
+ });
17465
+ if (embRes.status === "unavailable" && !opts.json && !opts.quiet) {
17466
+ console.error(
17467
+ `Note: semantic search unavailable (${embRes.reason}) \u2014 lexical matches only.`
17468
+ );
17469
+ }
17470
+ const results = await searchTokensHybrid(db, opts.query, {
17471
+ queryEmbedding: q2?.vector,
17472
+ model: q2?.model
17473
+ });
16805
17474
  if (opts.quiet) return;
16806
17475
  if (opts.json) {
16807
17476
  console.log(JSON.stringify(results, null, 2));
@@ -16814,12 +17483,14 @@ tokenCommand.command("find").description("Fuzzy search for tokens").requiredOpti
16814
17483
  console.log(`Found ${results.length} token(s):
16815
17484
  `);
16816
17485
  console.log(
16817
- "Score Slug Concept Domain Bloom"
17486
+ "Score Sim Slug Concept Domain Bloom"
16818
17487
  );
16819
- console.log("\u2500".repeat(90));
17488
+ console.log("\u2500".repeat(95));
16820
17489
  for (const t2 of results) {
17490
+ const scoreStr = t2.score.toFixed(3).padEnd(6);
17491
+ const simStr = (t2.similarity?.toFixed(2) ?? "-").padEnd(4);
16821
17492
  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}`
17493
+ `${scoreStr} ${simStr} ${t2.slug.padEnd(21)} ${t2.concept.slice(0, 31).padEnd(31)} ${(t2.domain || "-").padEnd(11)} ${t2.bloom_level}`
16823
17494
  );
16824
17495
  }
16825
17496
  });
@@ -17047,6 +17718,56 @@ tokenCommand.command("status").description("Show full status of a token for a us
17047
17718
  }
17048
17719
  });
17049
17720
  });
17721
+ 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) => {
17722
+ await withDb(async (db) => {
17723
+ const probe = await embedQuery(db, "ZAM embedding dimension probe");
17724
+ const model = probe?.model ?? null;
17725
+ const dims = probe?.vector.length;
17726
+ const before = model ? await getEmbeddingCoverage(db, model, { dims }) : { tokens: 0, embedded: 0, missing: 0, stale: 0 };
17727
+ let embedded = 0;
17728
+ let reason;
17729
+ let force = Boolean(opts.all);
17730
+ while (true) {
17731
+ const result = await ensureTokenEmbeddings(
17732
+ db,
17733
+ force ? { force: true, limit: Number.MAX_SAFE_INTEGER, dims } : { dims }
17734
+ );
17735
+ force = false;
17736
+ embedded += result.embedded;
17737
+ if (result.status === "unavailable") {
17738
+ reason = result.reason;
17739
+ break;
17740
+ }
17741
+ if (result.remaining === 0 || result.embedded === 0) break;
17742
+ }
17743
+ if (reason !== void 0) {
17744
+ if (opts.quiet) {
17745
+ process.exit(1);
17746
+ }
17747
+ if (opts.json) {
17748
+ jsonOut({ error: reason, embedded });
17749
+ } else {
17750
+ console.error(`Error: ${reason}`);
17751
+ if (embedded > 0) {
17752
+ console.error(
17753
+ `(${embedded} token(s) were embedded before the failure)`
17754
+ );
17755
+ }
17756
+ }
17757
+ process.exit(1);
17758
+ }
17759
+ const after = await getEmbeddingCoverage(db, model, { dims });
17760
+ if (opts.quiet) return;
17761
+ const staleBefore = before.missing + before.stale;
17762
+ if (opts.json) {
17763
+ jsonOut({ embedded, model, before, after });
17764
+ return;
17765
+ }
17766
+ console.log(
17767
+ `Embedded ${embedded} tokens (${after.tokens} total, ${staleBefore} stale before) with ${model}`
17768
+ );
17769
+ });
17770
+ });
17050
17771
 
17051
17772
  // src/cli/commands/ui.ts
17052
17773
  import { spawn as spawn3, spawnSync } from "child_process";