create-egregore 0.21.0-runtime-mvp.3 → 0.21.0-runtime-mvp.5

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // egregore-launcher-version: 0.21.0-runtime-mvp.3
2
+ // egregore-launcher-version: 0.21.0-runtime-mvp.5
3
3
  //
4
4
  // egregore — launch and browse Egregore instances from your terminal.
5
5
  // Installed by create-egregore. Reads ~/.egregore/instances.json.
@@ -35,7 +35,7 @@ const crypto = require("node:crypto");
35
35
  const { spawnSync, spawn } = require("node:child_process");
36
36
  const readline = require("node:readline");
37
37
 
38
- const VERSION = "0.21.0-runtime-mvp.3";
38
+ const VERSION = "0.21.0-runtime-mvp.5";
39
39
  const REGISTRY = path.join(os.homedir(), ".egregore", "instances.json");
40
40
  const DEFAULT_FILE = path.join(os.homedir(), ".egregore", "default");
41
41
  const UPDATE_CACHE_FILE = path.join(os.homedir(), ".egregore", "update-check.json");
@@ -2505,6 +2505,40 @@ function upgradeStatusAsync(inst, cb) {
2505
2505
  } catch { cb(null); }
2506
2506
  }
2507
2507
 
2508
+ // Local retrieval health for the settings surface. Package-update state,
2509
+ // Runtime activation state, and retrieval health are three different truths;
2510
+ // the settings row must not let one stand in for another. Repair-needed means
2511
+ // readiness names a concrete repair command (e.g. the embedding model is
2512
+ // missing while stored vectors are intact).
2513
+ function retrievalHealthAsync(inst, cb) {
2514
+ const script = path.join(inst.path, "bin", "search.sh");
2515
+ if (!fs.existsSync(script)) return cb(null);
2516
+ let buf = "";
2517
+ try {
2518
+ const child = spawn("bash", [script, "readiness", "--json"], {
2519
+ cwd: inst.path,
2520
+ stdio: ["ignore", "pipe", "ignore"],
2521
+ env: { ...process.env },
2522
+ });
2523
+ child.stdout.on("data", (d) => { buf += d; });
2524
+ child.on("close", () => {
2525
+ try {
2526
+ const report = JSON.parse(buf);
2527
+ const items = Array.isArray(report.items) ? report.items : [];
2528
+ const byKey = Object.fromEntries(items.map((item) => [item.key, item]));
2529
+ const repair = items.some((item) => /repair: bin\/search\.sh/.test(item.detail || ""));
2530
+ cb({
2531
+ repair,
2532
+ searchStage: byKey.search?.stage || "unknown",
2533
+ embeddingsDetail: byKey.embeddings?.detail || "",
2534
+ semanticDetail: byKey.semantic?.detail || "",
2535
+ });
2536
+ } catch { cb(null); }
2537
+ });
2538
+ child.on("error", () => cb(null));
2539
+ } catch { cb(null); }
2540
+ }
2541
+
2508
2542
  // Exact candidate resolution: the runtime-mvp dist-tag only, validated
2509
2543
  // against the prerelease scheme — never latest, never a retired scheme.
2510
2544
  // EGREGORE_RUNTIME_MVP_CANDIDATE is the offline/test seam.
@@ -2542,6 +2576,26 @@ const UPGRADE_STATUS_LABEL = {
2542
2576
  "rolled-back": "rolled back",
2543
2577
  };
2544
2578
 
2579
+ // Settings-row truth for the Runtime update line. The row must say outright
2580
+ // whether this egregore runs the new Runtime — "no update staged" is
2581
+ // transaction state, not the condition the user needs to see.
2582
+ function runtimeUpdateRowValue(st, candidate) {
2583
+ if (st === undefined) return `${DIM}checking…${RESET}`;
2584
+ if (st === null) return `${DIM}unavailable${RESET}`;
2585
+ if (st.status === "active") {
2586
+ const v = st.active_version || "?";
2587
+ if (candidate && candidate !== v) return `${v} active ${YELLOW}· update available ${candidate}${RESET}`;
2588
+ return `${GREEN}${v} active${RESET}${candidate ? `${DIM} · up to date${RESET}` : ""}`;
2589
+ }
2590
+ if (st.status === "ready-to-activate") return `${GREEN}${upgradeSummary(st)}${RESET}`;
2591
+ if (st.status === "failed" || st.status === "interrupted") return `${YELLOW}${upgradeSummary(st)}${RESET}`;
2592
+ if (st.status === "preparing" || st.status === "pending") return `${DIM}${upgradeSummary(st)}${RESET}`;
2593
+ // No activation record (or cancelled/rolled back): this egregore is not on
2594
+ // the new Runtime. Say so, and name the candidate when we know it.
2595
+ const base = `${YELLOW}not updated to the new Runtime${RESET}`;
2596
+ return candidate ? `${base}${DIM} · ${candidate} available${RESET}` : base;
2597
+ }
2598
+
2545
2599
  function upgradeSummary(st) {
2546
2600
  // "none" means no activation record — a first-time state, not freshness.
2547
2601
  if (!st || st.status === "none") return "no update staged";
@@ -2560,7 +2614,7 @@ function upgradeElapsed(st) {
2560
2614
  // Plain-language body shared by the non-interactive `settings <slug> runtime`
2561
2615
  // output and the interactive panel. Returns undecorated strings; callers add
2562
2616
  // color. Never JSON, never internal paths beyond the instance's own root.
2563
- function formatUpgradeLines(inst, st, { candidate = "" } = {}) {
2617
+ function formatUpgradeLines(inst, st, { candidate = "", retrieval = null } = {}) {
2564
2618
  const lines = [];
2565
2619
  const d = st || { status: "none" };
2566
2620
  lines.push(`Selected Egregore: ${inst.name || inst.slug} (${inst.slug || "?"})`);
@@ -2581,6 +2635,17 @@ function formatUpgradeLines(inst, st, { candidate = "" } = {}) {
2581
2635
  } else if (d.status !== "none") {
2582
2636
  lines.push("Previous Runtime remains active until activation.");
2583
2637
  }
2638
+ if (retrieval) {
2639
+ if (retrieval.repair) {
2640
+ lines.push("Local retrieval: repair needed");
2641
+ if (retrieval.embeddingsDetail) lines.push(` ✗ ${retrieval.embeddingsDetail}`);
2642
+ lines.push(" → run: bin/search.sh reindex --embed");
2643
+ } else if (retrieval.searchStage && retrieval.searchStage !== "ready") {
2644
+ lines.push(`Local retrieval: ${retrieval.searchStage}`);
2645
+ } else if (retrieval.searchStage === "ready") {
2646
+ lines.push("Local retrieval: ready");
2647
+ }
2648
+ }
2584
2649
  if (Array.isArray(d.stages) && d.status !== "none" && d.status !== "active") {
2585
2650
  lines.push("");
2586
2651
  for (const stage of d.stages) {
@@ -2984,7 +3049,7 @@ const SETTINGS_DOMAIN_NOUN = { repo: "repo name", admin: "github handle", people
2984
3049
  // Runtime-update panel: renders the durable upgrade state from the Python
2985
3050
  // engine. Same geometry as the settings panel; plain-language body from
2986
3051
  // formatUpgradeLines with launcher glyph decoration only.
2987
- function renderUpgradePanel({ inst, data, candidate, mode, notice, width }) {
3052
+ function renderUpgradePanel({ inst, data, candidate, retrieval, mode, notice, width }) {
2988
3053
  const outerWidth = Math.min(Math.max(50, width - 2), 126);
2989
3054
  const innerWidth = Math.max(48, outerWidth - 2);
2990
3055
  const margin = " ".repeat(Math.max(0, Math.floor((width - outerWidth) / 2)));
@@ -2997,7 +3062,7 @@ function renderUpgradePanel({ inst, data, candidate, mode, notice, width }) {
2997
3062
  ? ["Checking this egregore's Runtime state…"]
2998
3063
  : data === null
2999
3064
  ? ["Runtime update state is unavailable for this egregore."]
3000
- : formatUpgradeLines(inst, data, { candidate });
3065
+ : formatUpgradeLines(inst, data, { candidate, retrieval });
3001
3066
  for (const raw of body) {
3002
3067
  let line = raw;
3003
3068
  if (/^ ✓ /.test(raw)) line = ` ${GREEN}✓${RESET} ${raw.slice(4)}`;
@@ -3041,7 +3106,7 @@ function renderUpgradePanel({ inst, data, candidate, mode, notice, width }) {
3041
3106
  return lines;
3042
3107
  }
3043
3108
 
3044
- function renderSettingsPanel({ inst, data, cursor, view, domain, items, listCursor, mode, input, notice, width, upgrade }) {
3109
+ function renderSettingsPanel({ inst, data, cursor, view, domain, items, listCursor, mode, input, notice, width, upgrade, retrieval, candidate }) {
3045
3110
  const title = inst.slug || inst.name || path.basename(inst.path);
3046
3111
  const outerWidth = Math.min(Math.max(50, width - 2), 126);
3047
3112
  const innerWidth = Math.max(48, outerWidth - 2);
@@ -3186,17 +3251,14 @@ function renderSettingsPanel({ inst, data, cursor, view, domain, items, listCurs
3186
3251
  lines.push(boxLine(`${DIM}↑↓ nav · ${RESET}${BOLD}a${RESET}${DIM} add · ${RESET}${BOLD}d${RESET}${DIM} delete · ← back · q quit${RESET}`, innerWidth, margin));
3187
3252
  }
3188
3253
  } else {
3189
- const upgradeValue = upgrade === undefined
3190
- ? `${DIM}checking…${RESET}`
3191
- : upgrade === null
3192
- ? `${DIM}unavailable${RESET}`
3193
- : upgrade.status === "ready-to-activate"
3194
- ? `${GREEN}${upgradeSummary(upgrade)}${RESET}`
3195
- : upgrade.status === "failed" || upgrade.status === "interrupted"
3196
- ? `${YELLOW}${upgradeSummary(upgrade)}${RESET}`
3197
- : `${DIM}${upgradeSummary(upgrade)}${RESET}`;
3254
+ const upgradeValue = runtimeUpdateRowValue(upgrade, candidate);
3198
3255
  const rows = [
3199
- { name: "Runtime update", value: upgradeValue },
3256
+ {
3257
+ name: "Runtime update",
3258
+ value: retrieval && retrieval.repair
3259
+ ? `${upgradeValue} ${YELLOW}· retrieval repair needed${RESET}`
3260
+ : upgradeValue,
3261
+ },
3200
3262
  { name: "Privacy & access", value: `${DIM}identity · boundary · consent${RESET}` },
3201
3263
  { name: "Telemetry", value: `${DIM}personal · opt-out${RESET}` },
3202
3264
  { name: "Hosted Artifacts", value: (d.hosting !== false) ? `${GREEN}ON${RESET}` : `${YELLOW}OFF${RESET}` },
@@ -3350,6 +3412,7 @@ async function browser(args = {}) {
3350
3412
  inst: state.upgradeInst,
3351
3413
  data: state.upgradeData,
3352
3414
  candidate: state.upgradeCandidate,
3415
+ retrieval: state.upgradeRetrieval,
3353
3416
  mode: state.upgradeMode,
3354
3417
  notice: state.upgradeNotice,
3355
3418
  width: w,
@@ -3375,6 +3438,8 @@ async function browser(args = {}) {
3375
3438
  notice: state.settingsNotice,
3376
3439
  width: w,
3377
3440
  upgrade: state.settingsUpgrade,
3441
+ retrieval: state.settingsRetrieval,
3442
+ candidate: state.upgradeCandidate,
3378
3443
  });
3379
3444
  for (const line of settingsLines) out.push(line + ANSI.clearLine + "\n");
3380
3445
  const remainingRows = Math.max(0, h - settingsLines.length - 1);
@@ -3903,8 +3968,13 @@ async function browser(args = {}) {
3903
3968
  if (S.settingsCursor === 0) {
3904
3969
  state.upgradeInst = S.settingsInst;
3905
3970
  state.upgradeData = S.settingsUpgrade;
3971
+ state.upgradeRetrieval = S.settingsRetrieval;
3906
3972
  state.upgradeMode = "view";
3907
3973
  state.upgradeNotice = "";
3974
+ retrievalHealthAsync(S.settingsInst, (health) => {
3975
+ state.upgradeRetrieval = health;
3976
+ if (state.upgradeInst) render();
3977
+ });
3908
3978
  refreshUpgrade();
3909
3979
  if (!state.upgradeCandidate) {
3910
3980
  fetchRuntimeMvpCandidate().then((v) => {
@@ -4190,10 +4260,21 @@ async function browser(args = {}) {
4190
4260
  state.settingsCursor = 0;
4191
4261
  state.settingsView = "menu";
4192
4262
  state.settingsUpgrade = undefined;
4263
+ state.settingsRetrieval = undefined;
4193
4264
  upgradeStatusAsync(pick, (st) => {
4194
4265
  state.settingsUpgrade = st;
4195
4266
  if (state.settingsInst === pick && !state.upgradeInst) render();
4196
4267
  });
4268
+ retrievalHealthAsync(pick, (health) => {
4269
+ state.settingsRetrieval = health;
4270
+ if (state.settingsInst === pick && !state.upgradeInst) render();
4271
+ });
4272
+ if (!state.upgradeCandidate) {
4273
+ fetchRuntimeMvpCandidate().then((v) => {
4274
+ state.upgradeCandidate = v || "";
4275
+ if (state.settingsInst === pick && !state.upgradeInst) render();
4276
+ });
4277
+ }
4197
4278
  state.settingsNotice = "";
4198
4279
  render();
4199
4280
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-egregore",
3
- "version": "0.21.0-runtime-mvp.3",
3
+ "version": "0.21.0-runtime-mvp.5",
4
4
  "description": "Set up Egregore for your team in one command — installs the egregore launcher and your choice of Claude Code, Codex, or Pi.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -353,8 +353,13 @@ class QmdLocalRetriever:
353
353
  and counts["documents"] > 0
354
354
  and self._index_state_usable()
355
355
  )
356
+ # Semantic queries embed the query text at request time, so the model
357
+ # must be present even when every stored vector is current. Stored
358
+ # vectors without the model is a repair state, never "ready".
359
+ model_available = self._embedding_model_available()
356
360
  semantic_ready = bool(
357
361
  bm25_ready
362
+ and model_available
358
363
  and counts["vectors"] > 0
359
364
  and counts["pending"] == 0
360
365
  and self._semantic_state_current()
@@ -362,9 +367,15 @@ class QmdLocalRetriever:
362
367
  )
363
368
  embedding_state = self._embedding_state(status, semantic_ready=semantic_ready)
364
369
  if embedding_state == "missing_model":
365
- warnings.append(
366
- f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
367
- )
370
+ if counts["vectors"] > 0:
371
+ warnings.append(
372
+ f"{EMBEDDING_MODEL} is missing while stored vectors are intact; "
373
+ "semantic queries are unavailable until repair — run: bin/search.sh reindex --embed"
374
+ )
375
+ else:
376
+ warnings.append(
377
+ f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
378
+ )
368
379
 
369
380
  runtime = self._owned_runtime()
370
381
  if not canonical_ready or not qmd_available or not status_ok:
@@ -1568,10 +1579,13 @@ class QmdLocalRetriever:
1568
1579
  def _embedding_state(self, status: str, *, semantic_ready: bool = False) -> str:
1569
1580
  if self._live_lock(self._embed_lock_path()):
1570
1581
  return "running"
1571
- if semantic_ready:
1572
- return "ready"
1582
+ # Model presence outranks stored-vector completeness: query-time
1583
+ # embedding needs the model, so a complete build with a missing model
1584
+ # is a repair state, not "ready".
1573
1585
  if not self._embedding_model_available():
1574
1586
  return "missing_model"
1587
+ if semantic_ready:
1588
+ return "ready"
1575
1589
  counts = self._status_counts(status)
1576
1590
  if counts["pending"] > 0 or counts["documents"] > 0:
1577
1591
  return "pending"
@@ -236,6 +236,16 @@ def _embeddings_item(health: RetrieverHealth) -> ReadinessItem:
236
236
  key="embeddings", label=label, stage=STAGE_BUILDING, detail=detail
237
237
  )
238
238
  if state == "missing_model":
239
+ if health.semantic_source_revision:
240
+ return ReadinessItem(
241
+ key="embeddings",
242
+ label=label,
243
+ stage=STAGE_UNAVAILABLE,
244
+ detail=(
245
+ "embedding model missing — stored vectors intact · "
246
+ "repair: bin/search.sh reindex --embed"
247
+ ),
248
+ )
239
249
  return ReadinessItem(
240
250
  key="embeddings",
241
251
  label=label,
@@ -254,6 +264,16 @@ def _semantic_item(health: RetrieverHealth) -> ReadinessItem:
254
264
  label = "Semantic build"
255
265
  source = _short_revision(health.source_revision)
256
266
  build = _short_revision(health.semantic_source_revision)
267
+ if health.embedding_state == "missing_model":
268
+ return ReadinessItem(
269
+ key="semantic",
270
+ label=label,
271
+ stage=STAGE_UNAVAILABLE,
272
+ detail=(
273
+ "embedding model missing — semantic queries unavailable · "
274
+ "repair: bin/search.sh reindex --embed"
275
+ ),
276
+ )
257
277
  if health.semantic_ready:
258
278
  return ReadinessItem(
259
279
  key="semantic",
@@ -282,6 +302,16 @@ def _search_item(health: RetrieverHealth) -> ReadinessItem:
282
302
  return ReadinessItem(
283
303
  key="search", label=label, stage=STAGE_READY, detail="hybrid current"
284
304
  )
305
+ if health.bm25_ready and health.embedding_state == "missing_model":
306
+ return ReadinessItem(
307
+ key="search",
308
+ label=label,
309
+ stage=STAGE_READY,
310
+ detail=(
311
+ "lexical only — embedding model missing · "
312
+ "repair: bin/search.sh reindex --embed"
313
+ ),
314
+ )
285
315
  if health.bm25_ready and health.semantic_source_revision:
286
316
  return ReadinessItem(
287
317
  key="search",
@@ -353,8 +353,13 @@ class QmdLocalRetriever:
353
353
  and counts["documents"] > 0
354
354
  and self._index_state_usable()
355
355
  )
356
+ # Semantic queries embed the query text at request time, so the model
357
+ # must be present even when every stored vector is current. Stored
358
+ # vectors without the model is a repair state, never "ready".
359
+ model_available = self._embedding_model_available()
356
360
  semantic_ready = bool(
357
361
  bm25_ready
362
+ and model_available
358
363
  and counts["vectors"] > 0
359
364
  and counts["pending"] == 0
360
365
  and self._semantic_state_current()
@@ -362,9 +367,15 @@ class QmdLocalRetriever:
362
367
  )
363
368
  embedding_state = self._embedding_state(status, semantic_ready=semantic_ready)
364
369
  if embedding_state == "missing_model":
365
- warnings.append(
366
- f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
367
- )
370
+ if counts["vectors"] > 0:
371
+ warnings.append(
372
+ f"{EMBEDDING_MODEL} is missing while stored vectors are intact; "
373
+ "semantic queries are unavailable until repair — run: bin/search.sh reindex --embed"
374
+ )
375
+ else:
376
+ warnings.append(
377
+ f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
378
+ )
368
379
 
369
380
  runtime = self._owned_runtime()
370
381
  if not canonical_ready or not qmd_available or not status_ok:
@@ -1568,10 +1579,13 @@ class QmdLocalRetriever:
1568
1579
  def _embedding_state(self, status: str, *, semantic_ready: bool = False) -> str:
1569
1580
  if self._live_lock(self._embed_lock_path()):
1570
1581
  return "running"
1571
- if semantic_ready:
1572
- return "ready"
1582
+ # Model presence outranks stored-vector completeness: query-time
1583
+ # embedding needs the model, so a complete build with a missing model
1584
+ # is a repair state, not "ready".
1573
1585
  if not self._embedding_model_available():
1574
1586
  return "missing_model"
1587
+ if semantic_ready:
1588
+ return "ready"
1575
1589
  counts = self._status_counts(status)
1576
1590
  if counts["pending"] > 0 or counts["documents"] > 0:
1577
1591
  return "pending"
@@ -236,6 +236,16 @@ def _embeddings_item(health: RetrieverHealth) -> ReadinessItem:
236
236
  key="embeddings", label=label, stage=STAGE_BUILDING, detail=detail
237
237
  )
238
238
  if state == "missing_model":
239
+ if health.semantic_source_revision:
240
+ return ReadinessItem(
241
+ key="embeddings",
242
+ label=label,
243
+ stage=STAGE_UNAVAILABLE,
244
+ detail=(
245
+ "embedding model missing — stored vectors intact · "
246
+ "repair: bin/search.sh reindex --embed"
247
+ ),
248
+ )
239
249
  return ReadinessItem(
240
250
  key="embeddings",
241
251
  label=label,
@@ -254,6 +264,16 @@ def _semantic_item(health: RetrieverHealth) -> ReadinessItem:
254
264
  label = "Semantic build"
255
265
  source = _short_revision(health.source_revision)
256
266
  build = _short_revision(health.semantic_source_revision)
267
+ if health.embedding_state == "missing_model":
268
+ return ReadinessItem(
269
+ key="semantic",
270
+ label=label,
271
+ stage=STAGE_UNAVAILABLE,
272
+ detail=(
273
+ "embedding model missing — semantic queries unavailable · "
274
+ "repair: bin/search.sh reindex --embed"
275
+ ),
276
+ )
257
277
  if health.semantic_ready:
258
278
  return ReadinessItem(
259
279
  key="semantic",
@@ -282,6 +302,16 @@ def _search_item(health: RetrieverHealth) -> ReadinessItem:
282
302
  return ReadinessItem(
283
303
  key="search", label=label, stage=STAGE_READY, detail="hybrid current"
284
304
  )
305
+ if health.bm25_ready and health.embedding_state == "missing_model":
306
+ return ReadinessItem(
307
+ key="search",
308
+ label=label,
309
+ stage=STAGE_READY,
310
+ detail=(
311
+ "lexical only — embedding model missing · "
312
+ "repair: bin/search.sh reindex --embed"
313
+ ),
314
+ )
285
315
  if health.bm25_ready and health.semantic_source_revision:
286
316
  return ReadinessItem(
287
317
  key="search",
@@ -353,8 +353,13 @@ class QmdLocalRetriever:
353
353
  and counts["documents"] > 0
354
354
  and self._index_state_usable()
355
355
  )
356
+ # Semantic queries embed the query text at request time, so the model
357
+ # must be present even when every stored vector is current. Stored
358
+ # vectors without the model is a repair state, never "ready".
359
+ model_available = self._embedding_model_available()
356
360
  semantic_ready = bool(
357
361
  bm25_ready
362
+ and model_available
358
363
  and counts["vectors"] > 0
359
364
  and counts["pending"] == 0
360
365
  and self._semantic_state_current()
@@ -362,9 +367,15 @@ class QmdLocalRetriever:
362
367
  )
363
368
  embedding_state = self._embedding_state(status, semantic_ready=semantic_ready)
364
369
  if embedding_state == "missing_model":
365
- warnings.append(
366
- f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
367
- )
370
+ if counts["vectors"] > 0:
371
+ warnings.append(
372
+ f"{EMBEDDING_MODEL} is missing while stored vectors are intact; "
373
+ "semantic queries are unavailable until repair — run: bin/search.sh reindex --embed"
374
+ )
375
+ else:
376
+ warnings.append(
377
+ f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
378
+ )
368
379
 
369
380
  runtime = self._owned_runtime()
370
381
  if not canonical_ready or not qmd_available or not status_ok:
@@ -1568,10 +1579,13 @@ class QmdLocalRetriever:
1568
1579
  def _embedding_state(self, status: str, *, semantic_ready: bool = False) -> str:
1569
1580
  if self._live_lock(self._embed_lock_path()):
1570
1581
  return "running"
1571
- if semantic_ready:
1572
- return "ready"
1582
+ # Model presence outranks stored-vector completeness: query-time
1583
+ # embedding needs the model, so a complete build with a missing model
1584
+ # is a repair state, not "ready".
1573
1585
  if not self._embedding_model_available():
1574
1586
  return "missing_model"
1587
+ if semantic_ready:
1588
+ return "ready"
1575
1589
  counts = self._status_counts(status)
1576
1590
  if counts["pending"] > 0 or counts["documents"] > 0:
1577
1591
  return "pending"
@@ -236,6 +236,16 @@ def _embeddings_item(health: RetrieverHealth) -> ReadinessItem:
236
236
  key="embeddings", label=label, stage=STAGE_BUILDING, detail=detail
237
237
  )
238
238
  if state == "missing_model":
239
+ if health.semantic_source_revision:
240
+ return ReadinessItem(
241
+ key="embeddings",
242
+ label=label,
243
+ stage=STAGE_UNAVAILABLE,
244
+ detail=(
245
+ "embedding model missing — stored vectors intact · "
246
+ "repair: bin/search.sh reindex --embed"
247
+ ),
248
+ )
239
249
  return ReadinessItem(
240
250
  key="embeddings",
241
251
  label=label,
@@ -254,6 +264,16 @@ def _semantic_item(health: RetrieverHealth) -> ReadinessItem:
254
264
  label = "Semantic build"
255
265
  source = _short_revision(health.source_revision)
256
266
  build = _short_revision(health.semantic_source_revision)
267
+ if health.embedding_state == "missing_model":
268
+ return ReadinessItem(
269
+ key="semantic",
270
+ label=label,
271
+ stage=STAGE_UNAVAILABLE,
272
+ detail=(
273
+ "embedding model missing — semantic queries unavailable · "
274
+ "repair: bin/search.sh reindex --embed"
275
+ ),
276
+ )
257
277
  if health.semantic_ready:
258
278
  return ReadinessItem(
259
279
  key="semantic",
@@ -282,6 +302,16 @@ def _search_item(health: RetrieverHealth) -> ReadinessItem:
282
302
  return ReadinessItem(
283
303
  key="search", label=label, stage=STAGE_READY, detail="hybrid current"
284
304
  )
305
+ if health.bm25_ready and health.embedding_state == "missing_model":
306
+ return ReadinessItem(
307
+ key="search",
308
+ label=label,
309
+ stage=STAGE_READY,
310
+ detail=(
311
+ "lexical only — embedding model missing · "
312
+ "repair: bin/search.sh reindex --embed"
313
+ ),
314
+ )
285
315
  if health.bm25_ready and health.semantic_source_revision:
286
316
  return ReadinessItem(
287
317
  key="search",
@@ -353,8 +353,13 @@ class QmdLocalRetriever:
353
353
  and counts["documents"] > 0
354
354
  and self._index_state_usable()
355
355
  )
356
+ # Semantic queries embed the query text at request time, so the model
357
+ # must be present even when every stored vector is current. Stored
358
+ # vectors without the model is a repair state, never "ready".
359
+ model_available = self._embedding_model_available()
356
360
  semantic_ready = bool(
357
361
  bm25_ready
362
+ and model_available
358
363
  and counts["vectors"] > 0
359
364
  and counts["pending"] == 0
360
365
  and self._semantic_state_current()
@@ -362,9 +367,15 @@ class QmdLocalRetriever:
362
367
  )
363
368
  embedding_state = self._embedding_state(status, semantic_ready=semantic_ready)
364
369
  if embedding_state == "missing_model":
365
- warnings.append(
366
- f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
367
- )
370
+ if counts["vectors"] > 0:
371
+ warnings.append(
372
+ f"{EMBEDDING_MODEL} is missing while stored vectors are intact; "
373
+ "semantic queries are unavailable until repair — run: bin/search.sh reindex --embed"
374
+ )
375
+ else:
376
+ warnings.append(
377
+ f"{EMBEDDING_MODEL} is not installed; hybrid requests degrade to lexical retrieval while embedding warms"
378
+ )
368
379
 
369
380
  runtime = self._owned_runtime()
370
381
  if not canonical_ready or not qmd_available or not status_ok:
@@ -1568,10 +1579,13 @@ class QmdLocalRetriever:
1568
1579
  def _embedding_state(self, status: str, *, semantic_ready: bool = False) -> str:
1569
1580
  if self._live_lock(self._embed_lock_path()):
1570
1581
  return "running"
1571
- if semantic_ready:
1572
- return "ready"
1582
+ # Model presence outranks stored-vector completeness: query-time
1583
+ # embedding needs the model, so a complete build with a missing model
1584
+ # is a repair state, not "ready".
1573
1585
  if not self._embedding_model_available():
1574
1586
  return "missing_model"
1587
+ if semantic_ready:
1588
+ return "ready"
1575
1589
  counts = self._status_counts(status)
1576
1590
  if counts["pending"] > 0 or counts["documents"] > 0:
1577
1591
  return "pending"
@@ -236,6 +236,16 @@ def _embeddings_item(health: RetrieverHealth) -> ReadinessItem:
236
236
  key="embeddings", label=label, stage=STAGE_BUILDING, detail=detail
237
237
  )
238
238
  if state == "missing_model":
239
+ if health.semantic_source_revision:
240
+ return ReadinessItem(
241
+ key="embeddings",
242
+ label=label,
243
+ stage=STAGE_UNAVAILABLE,
244
+ detail=(
245
+ "embedding model missing — stored vectors intact · "
246
+ "repair: bin/search.sh reindex --embed"
247
+ ),
248
+ )
239
249
  return ReadinessItem(
240
250
  key="embeddings",
241
251
  label=label,
@@ -254,6 +264,16 @@ def _semantic_item(health: RetrieverHealth) -> ReadinessItem:
254
264
  label = "Semantic build"
255
265
  source = _short_revision(health.source_revision)
256
266
  build = _short_revision(health.semantic_source_revision)
267
+ if health.embedding_state == "missing_model":
268
+ return ReadinessItem(
269
+ key="semantic",
270
+ label=label,
271
+ stage=STAGE_UNAVAILABLE,
272
+ detail=(
273
+ "embedding model missing — semantic queries unavailable · "
274
+ "repair: bin/search.sh reindex --embed"
275
+ ),
276
+ )
257
277
  if health.semantic_ready:
258
278
  return ReadinessItem(
259
279
  key="semantic",
@@ -282,6 +302,16 @@ def _search_item(health: RetrieverHealth) -> ReadinessItem:
282
302
  return ReadinessItem(
283
303
  key="search", label=label, stage=STAGE_READY, detail="hybrid current"
284
304
  )
305
+ if health.bm25_ready and health.embedding_state == "missing_model":
306
+ return ReadinessItem(
307
+ key="search",
308
+ label=label,
309
+ stage=STAGE_READY,
310
+ detail=(
311
+ "lexical only — embedding model missing · "
312
+ "repair: bin/search.sh reindex --embed"
313
+ ),
314
+ )
285
315
  if health.bm25_ready and health.semantic_source_revision:
286
316
  return ReadinessItem(
287
317
  key="search",