nexo-brain 7.25.5 → 7.26.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.
@@ -10,6 +10,7 @@ import shlex
10
10
  import shutil
11
11
  import subprocess
12
12
  import sys
13
+ import tarfile
13
14
  from pathlib import Path
14
15
 
15
16
  from calibration_runtime import load_runtime_calibration
@@ -80,6 +81,7 @@ except Exception:
80
81
 
81
82
 
82
83
  CLAUDE_CODE_NPM_PACKAGE = "@anthropic-ai/claude-code"
84
+ CODEX_NPM_PACKAGE = "@openai/codex"
83
85
  DEFAULT_ASSISTANT_NAME = "Nova"
84
86
  HOOK_TIMEOUTS_BY_EVENT = {
85
87
  "SessionStart": 40,
@@ -235,6 +237,159 @@ def _managed_claude_prefix(user_home: Path | None = None) -> Path:
235
237
  return home / ".nexo" / "runtime" / "bootstrap" / "npm-global"
236
238
 
237
239
 
240
+ def _brain_bundle_root() -> Path:
241
+ return Path(__file__).resolve().parents[1]
242
+
243
+
244
+ def _platform_slug() -> str:
245
+ if sys.platform.startswith("darwin"):
246
+ os_part = "darwin"
247
+ elif sys.platform.startswith("linux"):
248
+ os_part = "linux"
249
+ elif sys.platform.startswith("win"):
250
+ os_part = "win32"
251
+ else:
252
+ os_part = sys.platform
253
+ machine = __import__("platform").machine().lower()
254
+ if machine in {"x86_64", "amd64"}:
255
+ arch = "x64"
256
+ elif machine in {"aarch64", "arm64"}:
257
+ arch = "arm64"
258
+ else:
259
+ arch = machine
260
+ return f"{os_part}-{arch}"
261
+
262
+
263
+ def _install_npm_package_from_bundle(
264
+ *,
265
+ bundle_dir: Path,
266
+ wrapper_pattern: str,
267
+ package_name: str,
268
+ managed_prefix: Path,
269
+ env: dict[str, str],
270
+ attempts: list[str],
271
+ ) -> bool:
272
+ if not bundle_dir.is_dir():
273
+ return False
274
+ all_tgz = sorted(item for item in bundle_dir.iterdir() if item.name.endswith(".tgz"))
275
+ wrapper_re = re.compile(wrapper_pattern)
276
+ wrapper = next((item for item in all_tgz if wrapper_re.match(item.name)), None)
277
+ if not wrapper:
278
+ return False
279
+ slug = _platform_slug()
280
+ native_packs = [
281
+ item
282
+ for item in all_tgz
283
+ if item != wrapper and (f"-{slug}.tgz" in item.name or f"-{slug}-" in item.name)
284
+ ]
285
+ tgz_paths = [wrapper, *native_packs] if native_packs else [wrapper]
286
+ desktop_node, bundled_npm_cli = _bundled_npm_runtime()
287
+ if desktop_node and bundled_npm_cli:
288
+ cmd = [
289
+ desktop_node,
290
+ bundled_npm_cli,
291
+ "install",
292
+ "-g",
293
+ "--prefix",
294
+ str(managed_prefix),
295
+ "--offline",
296
+ "--no-audit",
297
+ "--no-fund",
298
+ *(str(item) for item in tgz_paths),
299
+ ]
300
+ run_env = {**env, "ELECTRON_RUN_AS_NODE": "1"}
301
+ else:
302
+ cmd = [
303
+ "npm",
304
+ "install",
305
+ "-g",
306
+ "--prefix",
307
+ str(managed_prefix),
308
+ "--offline",
309
+ "--no-audit",
310
+ "--no-fund",
311
+ *(str(item) for item in tgz_paths),
312
+ ]
313
+ run_env = env
314
+ try:
315
+ install = subprocess.run(
316
+ cmd,
317
+ capture_output=True,
318
+ text=True,
319
+ timeout=300,
320
+ env=run_env,
321
+ )
322
+ if install.returncode == 0:
323
+ return True
324
+ attempts.append((install.stderr or install.stdout or f"{package_name} bundled install failed").strip())
325
+ except Exception as exc:
326
+ attempts.append(f"{package_name} bundled install failed: {exc}")
327
+ return False
328
+
329
+
330
+ def _install_codex_vendor_from_bundle(*, bundle_dir: Path, managed_prefix: Path, attempts: list[str]) -> bool:
331
+ package_roots = [
332
+ managed_prefix / "lib" / "node_modules" / "@openai" / "codex",
333
+ managed_prefix / "node_modules" / "@openai" / "codex",
334
+ ]
335
+ package_root = next((item for item in package_roots if item.exists()), package_roots[0])
336
+ if not package_root.exists() or not bundle_dir.is_dir():
337
+ return False
338
+ slug = _platform_slug()
339
+ native_packs = sorted(
340
+ item for item in bundle_dir.iterdir()
341
+ if item.name.endswith(".tgz") and (f"-{slug}.tgz" in item.name or f"-{slug}-" in item.name)
342
+ )
343
+ if not native_packs:
344
+ attempts.append(f"no bundled Codex native vendor found for {slug}")
345
+ return False
346
+ vendor_dest = package_root / "vendor"
347
+ vendor_dest.mkdir(parents=True, exist_ok=True)
348
+ for pack in native_packs:
349
+ try:
350
+ with tarfile.open(pack, "r:gz") as archive:
351
+ members = [member for member in archive.getmembers() if member.name.startswith("package/vendor/")]
352
+ for member in members:
353
+ relative = Path(member.name).relative_to("package/vendor")
354
+ target = vendor_dest / relative
355
+ if member.isdir():
356
+ target.mkdir(parents=True, exist_ok=True)
357
+ continue
358
+ if not member.isfile():
359
+ continue
360
+ target.parent.mkdir(parents=True, exist_ok=True)
361
+ source = archive.extractfile(member)
362
+ if source is None:
363
+ continue
364
+ with source, target.open("wb") as out:
365
+ shutil.copyfileobj(source, out)
366
+ try:
367
+ target.chmod(member.mode)
368
+ except Exception:
369
+ pass
370
+ return True
371
+ except Exception as exc:
372
+ attempts.append(f"Codex native vendor extract failed for {pack.name}: {exc}")
373
+ return False
374
+
375
+
376
+ def _codex_vendor_present(managed_prefix: Path) -> bool:
377
+ package_roots = [
378
+ managed_prefix / "lib" / "node_modules" / "@openai" / "codex",
379
+ managed_prefix / "node_modules" / "@openai" / "codex",
380
+ ]
381
+ for package_root in package_roots:
382
+ vendor_root = package_root / "vendor"
383
+ if not vendor_root.exists():
384
+ continue
385
+ try:
386
+ if any(candidate.is_file() for candidate in vendor_root.rglob("bin/codex*")):
387
+ return True
388
+ except Exception:
389
+ continue
390
+ return False
391
+
392
+
238
393
  def _desktop_product_requested(user_home: Path | None = None) -> bool:
239
394
  if str(os.environ.get("NEXO_DESKTOP_MANAGED", "")).strip() == "1":
240
395
  return True
@@ -289,6 +444,13 @@ def _installed_client_path(client_key: str, *, user_home: Path | None = None) ->
289
444
  return ""
290
445
 
291
446
 
447
+ def _sync_codex_binary(home_path: Path) -> str:
448
+ if _desktop_product_requested(home_path):
449
+ info = detect_installed_clients(user_home=home_path).get("codex", {})
450
+ return str(info.get("path") or "") if info.get("installed") else ""
451
+ return shutil.which("codex") or ""
452
+
453
+
292
454
  def ensure_claude_code_installed(*, user_home: str | os.PathLike[str] | None = None) -> dict:
293
455
  home_path = Path(user_home).expanduser() if user_home else _user_home()
294
456
  desktop_managed = _desktop_product_requested(home_path)
@@ -471,6 +633,167 @@ def ensure_claude_code_installed(*, user_home: str | os.PathLike[str] | None = N
471
633
  }
472
634
 
473
635
 
636
+ def ensure_codex_installed(*, user_home: str | os.PathLike[str] | None = None) -> dict:
637
+ home_path = Path(user_home).expanduser() if user_home else _user_home()
638
+ desktop_managed = _desktop_product_requested(home_path)
639
+ managed_prefix = _managed_claude_prefix(home_path)
640
+ existing = _installed_client_path("codex", user_home=home_path)
641
+ if existing and (not desktop_managed or _codex_vendor_present(managed_prefix)):
642
+ return {
643
+ "ok": True,
644
+ "client": "codex",
645
+ "installed": True,
646
+ "changed": False,
647
+ "action": "already_installed_managed" if desktop_managed else "already_installed",
648
+ "path": existing,
649
+ "attempts": [],
650
+ }
651
+
652
+ attempts: list[str] = []
653
+ if existing and desktop_managed:
654
+ attempts.append("managed Codex wrapper exists but native vendor is missing; repairing bundled vendor")
655
+ env = _cli_install_env(home_path)
656
+ env.setdefault("npm_config_prefix", str(managed_prefix))
657
+ env["PATH"] = os.pathsep.join(
658
+ [str(managed_prefix / "bin"), *(item for item in str(env.get("PATH", "")).split(os.pathsep) if item)]
659
+ )
660
+
661
+ desktop_node, bundled_npm_cli = _bundled_npm_runtime()
662
+ bundle_dir = _brain_bundle_root() / "codex"
663
+ if desktop_managed and not (desktop_node and bundled_npm_cli):
664
+ vendor_installed = _install_codex_vendor_from_bundle(
665
+ bundle_dir=bundle_dir,
666
+ managed_prefix=managed_prefix,
667
+ attempts=attempts,
668
+ )
669
+ installed_after_vendor = _installed_client_path("codex", user_home=home_path)
670
+ if installed_after_vendor and vendor_installed:
671
+ return {
672
+ "ok": True,
673
+ "client": "codex",
674
+ "installed": True,
675
+ "changed": True,
676
+ "action": "installed_bundled_vendor",
677
+ "path": installed_after_vendor,
678
+ "attempts": attempts,
679
+ }
680
+ return {
681
+ "ok": False,
682
+ "client": "codex",
683
+ "installed": False,
684
+ "changed": False,
685
+ "action": "failed",
686
+ "path": "",
687
+ "attempts": attempts,
688
+ "error": (
689
+ "Desktop-managed Codex install requires the NEXO Desktop bundled Codex runtime; "
690
+ "global `npm -g` fallbacks are disabled."
691
+ ),
692
+ }
693
+
694
+ wrapper_installed = _install_npm_package_from_bundle(
695
+ bundle_dir=bundle_dir,
696
+ wrapper_pattern=r"^openai-codex-\d+\.\d+\.\d+\.tgz$",
697
+ package_name=CODEX_NPM_PACKAGE,
698
+ managed_prefix=managed_prefix,
699
+ env=env,
700
+ attempts=attempts,
701
+ )
702
+ vendor_installed = _install_codex_vendor_from_bundle(bundle_dir=bundle_dir, managed_prefix=managed_prefix, attempts=attempts)
703
+ installed_after_bundle = _installed_client_path("codex", user_home=home_path)
704
+ if installed_after_bundle and vendor_installed:
705
+ return {
706
+ "ok": True,
707
+ "client": "codex",
708
+ "installed": True,
709
+ "changed": True,
710
+ "action": "installed_via_bundled_tarballs",
711
+ "path": installed_after_bundle,
712
+ "attempts": attempts,
713
+ }
714
+ if wrapper_installed and not vendor_installed:
715
+ attempts.append("bundled Codex wrapper installed but native vendor extraction failed; falling back to online install")
716
+
717
+ if desktop_node and bundled_npm_cli:
718
+ try:
719
+ install = subprocess.run(
720
+ [
721
+ desktop_node,
722
+ bundled_npm_cli,
723
+ "install",
724
+ "-g",
725
+ "--prefix",
726
+ str(managed_prefix),
727
+ CODEX_NPM_PACKAGE,
728
+ ],
729
+ capture_output=True,
730
+ text=True,
731
+ timeout=300,
732
+ env={**env, "ELECTRON_RUN_AS_NODE": "1"},
733
+ )
734
+ if install.returncode != 0:
735
+ attempts.append((install.stderr or install.stdout or "bundled npm install failed").strip())
736
+ except Exception as exc:
737
+ attempts.append(f"bundled npm install failed: {exc}")
738
+ installed_after_npm = _installed_client_path("codex", user_home=home_path)
739
+ if installed_after_npm and (not desktop_managed or _codex_vendor_present(managed_prefix)):
740
+ return {
741
+ "ok": True,
742
+ "client": "codex",
743
+ "installed": True,
744
+ "changed": True,
745
+ "action": "installed_via_bundled_npm",
746
+ "path": installed_after_npm,
747
+ "attempts": attempts,
748
+ }
749
+ if installed_after_npm and desktop_managed:
750
+ attempts.append("managed Codex wrapper installed but native vendor is missing after bundled npm install")
751
+
752
+ if desktop_managed:
753
+ error = (
754
+ "Desktop-managed Codex install did not produce the managed "
755
+ "`~/.nexo/runtime/bootstrap/npm-global/bin/codex` binary."
756
+ )
757
+ return {
758
+ "ok": False,
759
+ "client": "codex",
760
+ "installed": False,
761
+ "changed": False,
762
+ "action": "failed",
763
+ "path": "",
764
+ "attempts": attempts,
765
+ "error": error,
766
+ }
767
+
768
+ install_error = ""
769
+ try:
770
+ install = subprocess.run(
771
+ ["npm", "install", "-g", "--prefix", str(managed_prefix), CODEX_NPM_PACKAGE],
772
+ capture_output=True,
773
+ text=True,
774
+ timeout=180,
775
+ env=env,
776
+ )
777
+ if install.returncode != 0:
778
+ install_error = (install.stderr or install.stdout or "npm install failed").strip()
779
+ attempts.append(install_error)
780
+ except Exception as exc:
781
+ install_error = f"npm install failed: {exc}"
782
+ attempts.append(install_error)
783
+
784
+ installed_path = _installed_client_path("codex", user_home=home_path)
785
+ return {
786
+ "ok": bool(installed_path),
787
+ "client": "codex",
788
+ "installed": bool(installed_path),
789
+ "changed": bool(installed_path),
790
+ "action": "installed" if installed_path else "failed",
791
+ "path": installed_path or "",
792
+ "attempts": attempts,
793
+ **({} if installed_path else {"error": install_error or "Codex install did not produce a `codex` binary in PATH"}),
794
+ }
795
+
796
+
474
797
  def build_server_config(
475
798
  *,
476
799
  nexo_home: str | os.PathLike[str] | None = None,
@@ -1334,7 +1657,7 @@ def sync_codex(
1334
1657
  operator_name=operator_name,
1335
1658
  client="codex",
1336
1659
  )
1337
- codex_bin = shutil.which("codex")
1660
+ codex_bin = _sync_codex_binary(home_path)
1338
1661
  config_path = _codex_config_path(home_path)
1339
1662
  hooks_path = _codex_hooks_path(home_path)
1340
1663
  if not codex_bin:
@@ -1423,6 +1746,7 @@ def sync_all_clients(
1423
1746
  enabled_clients: list[str] | tuple[str, ...] | set[str] | None = None,
1424
1747
  preferences: dict | None = None,
1425
1748
  auto_install_missing_claude: bool = False,
1749
+ auto_install_missing_codex: bool = False,
1426
1750
  ) -> dict:
1427
1751
  nexo_home_path = Path(nexo_home).expanduser() if nexo_home else _default_nexo_home()
1428
1752
  guardian_runtime_surfaces_result: dict = {
@@ -1469,6 +1793,8 @@ def sync_all_clients(
1469
1793
  install_results: dict[str, dict] = {}
1470
1794
  if auto_install_missing_claude and "claude_code" in enabled_set:
1471
1795
  install_results["claude_code"] = ensure_claude_code_installed(user_home=user_home)
1796
+ if auto_install_missing_codex and "codex" in enabled_set:
1797
+ install_results["codex"] = ensure_codex_installed(user_home=user_home)
1472
1798
 
1473
1799
  def _safe(label: str, fn) -> dict:
1474
1800
  if label not in enabled_set:
package/src/crons/sync.py CHANGED
@@ -150,6 +150,41 @@ RETIRED_CORE_FILES = (
150
150
  )
151
151
 
152
152
 
153
+ def _resolve_core_python_bin() -> str:
154
+ """Prefer the NEXO-managed Python for core cron execution."""
155
+ candidates = [
156
+ os.environ.get("NEXO_RUNTIME_PYTHON", ""),
157
+ os.environ.get("NEXO_PYTHON", ""),
158
+ str(RUNTIME_ROOT / ".venv" / "bin" / "python3"),
159
+ str(RUNTIME_ROOT / ".venv" / "bin" / "python"),
160
+ str(_runtime_code_dir() / ".venv" / "bin" / "python3"),
161
+ str(_runtime_code_dir() / ".venv" / "bin" / "python"),
162
+ ]
163
+ if platform.system() == "Darwin":
164
+ candidates.extend(
165
+ [
166
+ "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3",
167
+ "/opt/homebrew/bin/python3.12",
168
+ "/usr/local/bin/python3.12",
169
+ "/opt/homebrew/bin/python3",
170
+ "/usr/local/bin/python3",
171
+ "/usr/bin/python3",
172
+ ]
173
+ )
174
+ else:
175
+ candidates.extend(["/usr/bin/python3", "/usr/local/bin/python3", "python3"])
176
+
177
+ for candidate in candidates:
178
+ if not candidate:
179
+ continue
180
+ expanded = Path(str(candidate)).expanduser()
181
+ if expanded.exists():
182
+ return str(expanded)
183
+ if os.sep not in str(candidate) and shutil.which(str(candidate)):
184
+ return str(candidate)
185
+ return "python3"
186
+
187
+
153
188
  def _runtime_scripts_dir() -> Path:
154
189
  new = RUNTIME_ROOT / "core" / "scripts"
155
190
  legacy = RUNTIME_ROOT / "scripts"
@@ -407,21 +442,10 @@ def build_plist(cron: dict) -> dict:
407
442
  if subdir_src.is_dir():
408
443
  _copy_into_runtime(subdir_src)
409
444
 
445
+ python_bin = _resolve_core_python_bin()
410
446
  if script_type == "shell":
411
447
  program_args = ["/bin/bash", wrapper_path, cron_id, "/bin/bash", script_path]
412
448
  else:
413
- # Find python3
414
- python_candidates = [
415
- "/opt/homebrew/bin/python3",
416
- "/usr/local/bin/python3",
417
- "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3",
418
- "/usr/bin/python3",
419
- ]
420
- python_bin = "python3"
421
- for p in python_candidates:
422
- if Path(p).exists():
423
- python_bin = p
424
- break
425
449
  program_args = ["/bin/bash", wrapper_path, cron_id, python_bin, script_path]
426
450
 
427
451
  plist = {
@@ -436,6 +460,7 @@ def build_plist(cron: dict) -> dict:
436
460
  "NEXO_CODE": str(_runtime_code_dir()),
437
461
  "NEXO_SOURCE_CODE": str(SOURCE_ROOT),
438
462
  "NEXO_MANAGED_CORE_CRON": "1",
463
+ "NEXO_RUNTIME_PYTHON": python_bin,
439
464
  "PYTHONUNBUFFERED": "1",
440
465
  },
441
466
  }
@@ -505,6 +530,7 @@ def _linux_crontab_entry(cron: dict, exec_cmd: str, stdout_log: Path, stderr_log
505
530
  "HOME": Path.home(),
506
531
  "NEXO_HOME": NEXO_HOME,
507
532
  "NEXO_CODE": _runtime_code_dir(),
533
+ "NEXO_RUNTIME_PYTHON": _resolve_core_python_bin(),
508
534
  "PYTHONUNBUFFERED": "1",
509
535
  }.items()
510
536
  )
@@ -578,12 +604,13 @@ def _sync_wsl_windows_host_local_index_task(dry_run: bool = False) -> dict:
578
604
  log("WARNING: WSL_DISTRO_NAME missing; local-index host task not installed.")
579
605
  return {"ok": False, "skipped": True, "reason": "wsl_distro_missing"}
580
606
 
581
- python_bin = "/usr/bin/python3" if Path("/usr/bin/python3").exists() else "python3"
607
+ python_bin = _resolve_core_python_bin()
582
608
  script_path = _runtime_code_dir() / "scripts" / "nexo-local-index.py"
583
609
  command = (
584
610
  f"cd {shlex.quote(str(Path.home()))} && "
585
611
  f"NEXO_HOME={shlex.quote(str(NEXO_HOME))} "
586
612
  f"NEXO_CODE={shlex.quote(str(_runtime_code_dir()))} "
613
+ f"NEXO_RUNTIME_PYTHON={shlex.quote(python_bin)} "
587
614
  f"{shlex.quote(python_bin)} {shlex.quote(str(script_path))}"
588
615
  )
589
616
  wsl_args = " ".join(
@@ -835,11 +862,7 @@ def sync_linux(dry_run: bool = False):
835
862
 
836
863
  log(f"Manifest: {len(manifest_crons)} core crons")
837
864
 
838
- python_bin = "/usr/bin/python3"
839
- for p in ["/usr/bin/python3", "/usr/local/bin/python3"]:
840
- if Path(p).exists():
841
- python_bin = p
842
- break
865
+ python_bin = _resolve_core_python_bin()
843
866
 
844
867
  enable_units: list[str] = []
845
868
  crontab_entries: list[str] = []
@@ -878,6 +901,7 @@ Type={service_type}
878
901
  ExecStart={exec_cmd}
879
902
  Environment=NEXO_HOME={NEXO_HOME}
880
903
  Environment=NEXO_CODE={_runtime_code_dir()}
904
+ Environment=NEXO_RUNTIME_PYTHON={python_bin}
881
905
  Environment=HOME={Path.home()}
882
906
  StandardOutput=append:{stdout_log}
883
907
  StandardError=append:{stderr_log}
package/src/db/_schema.py CHANGED
@@ -1117,6 +1117,28 @@ def _m41_automation_sessions_columns(conn):
1117
1117
  )
1118
1118
 
1119
1119
 
1120
+ def _m69_provider_runtime_metadata(conn):
1121
+ """Add provider/runtime metadata required for Anthropic/OpenAI parity."""
1122
+ if not _table_exists(conn, "automation_runs"):
1123
+ _m28_automation_runs(conn)
1124
+ if not _table_exists(conn, "cron_runs"):
1125
+ _m17_cron_runs(conn)
1126
+
1127
+ if _table_exists(conn, "automation_runs"):
1128
+ _migrate_add_column(conn, "automation_runs", "provider", "TEXT DEFAULT ''")
1129
+ _migrate_add_column(conn, "automation_runs", "runtime_version", "TEXT DEFAULT ''")
1130
+ _migrate_add_column(conn, "automation_runs", "runtime_session_id", "TEXT DEFAULT ''")
1131
+ _migrate_add_index(conn, "idx_automation_runs_provider", "automation_runs", "provider")
1132
+ if _table_exists(conn, "cron_runs"):
1133
+ _migrate_add_column(conn, "cron_runs", "provider", "TEXT DEFAULT ''")
1134
+ _migrate_add_column(conn, "cron_runs", "backend", "TEXT DEFAULT ''")
1135
+ _migrate_add_column(conn, "cron_runs", "runtime_snapshot", "TEXT DEFAULT '{}'")
1136
+ _migrate_add_index(conn, "idx_cron_runs_provider", "cron_runs", "provider")
1137
+ if _table_exists(conn, "sessions"):
1138
+ _migrate_add_column(conn, "sessions", "session_provider", "TEXT DEFAULT ''")
1139
+ _migrate_add_index(conn, "idx_sessions_provider", "sessions", "session_provider")
1140
+
1141
+
1120
1142
  def _m42_v6_0_1_hotfix(conn):
1121
1143
  """v6.0.1 hotfix — last_heartbeat_ts on sessions + hook_inbox_reminders.
1122
1144
 
@@ -1767,6 +1789,7 @@ def _m62_memory_observations_fts_trigger_fix(conn):
1767
1789
 
1768
1790
  def _m63_local_context_layer(conn):
1769
1791
  """Local Context Layer storage for on-device memory indexing."""
1792
+ _m63_repair_legacy_local_context_columns(conn)
1770
1793
  conn.executescript(
1771
1794
  """
1772
1795
  CREATE TABLE IF NOT EXISTS local_index_roots (
@@ -1995,6 +2018,35 @@ def _m63_local_context_layer(conn):
1995
2018
  )
1996
2019
 
1997
2020
 
2021
+ def _table_exists(conn, table: str) -> bool:
2022
+ row = conn.execute(
2023
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
2024
+ (table,),
2025
+ ).fetchone()
2026
+ return bool(row)
2027
+
2028
+
2029
+ def _m63_repair_legacy_local_context_columns(conn):
2030
+ """Add v2 columns before m63 creates indexes that reference them.
2031
+
2032
+ Existing sidecar DBs can already have m63-era tables without the v2
2033
+ columns. CREATE TABLE IF NOT EXISTS will not alter those tables, so index
2034
+ creation must be preceded by additive repairs.
2035
+ """
2036
+ if _table_exists(conn, "local_index_roots"):
2037
+ _migrate_add_column(conn, "local_index_roots", "source", "TEXT NOT NULL DEFAULT 'legacy'")
2038
+ _migrate_add_column(conn, "local_index_roots", "remote", "INTEGER NOT NULL DEFAULT 0")
2039
+ _migrate_add_column(conn, "local_index_roots", "seed_version", "INTEGER NOT NULL DEFAULT 1")
2040
+ if _table_exists(conn, "local_index_exclusions"):
2041
+ _migrate_add_column(conn, "local_index_exclusions", "source", "TEXT NOT NULL DEFAULT 'legacy'")
2042
+ _migrate_add_column(conn, "local_index_exclusions", "kind", "TEXT NOT NULL DEFAULT 'folder'")
2043
+ if _table_exists(conn, "local_index_file_type_rules"):
2044
+ _migrate_add_column(conn, "local_index_file_type_rules", "source", "TEXT NOT NULL DEFAULT 'legacy'")
2045
+ _migrate_add_column(conn, "local_index_file_type_rules", "priority", "INTEGER NOT NULL DEFAULT 0")
2046
+ _migrate_add_column(conn, "local_index_file_type_rules", "reason", "TEXT NOT NULL DEFAULT ''")
2047
+ _migrate_add_column(conn, "local_index_file_type_rules", "updated_at", "REAL NOT NULL DEFAULT 0")
2048
+
2049
+
1998
2050
  def _m64_local_context_live_dirs(conn):
1999
2051
  """Track known folders so local context can detect new/deleted/changed files quickly."""
2000
2052
  conn.executescript(
@@ -2217,6 +2269,7 @@ MIGRATIONS = [
2217
2269
  (66, "transcript_index", _m66_transcript_index),
2218
2270
  (67, "diary_quality_backfill_repair", _m67_diary_quality_backfill_repair),
2219
2271
  (68, "memory_fabric_index", _m68_memory_fabric_index),
2272
+ (69, "provider_runtime_metadata", _m69_provider_runtime_metadata),
2220
2273
  ]
2221
2274
 
2222
2275