sibyl-memory-cli 0.3.11__tar.gz → 0.3.15__tar.gz

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.
Files changed (26) hide show
  1. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/PKG-INFO +1 -1
  2. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/pyproject.toml +1 -1
  3. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/src/sibyl_memory_cli/cli.py +156 -2
  4. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/src/sibyl_memory_cli/migrate.py +2 -2
  5. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/src/sibyl_memory_cli/setup.py +32 -6
  6. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/src/sibyl_memory_cli.egg-info/PKG-INFO +1 -1
  7. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/src/sibyl_memory_cli.egg-info/SOURCES.txt +3 -0
  8. sibyl_memory_cli-0.3.15/tests/test_memory_cmd_2026_06_16.py +55 -0
  9. sibyl_memory_cli-0.3.15/tests/test_migrate_force_2026_06_05.py +67 -0
  10. sibyl_memory_cli-0.3.15/tests/test_status_stores_2026_06_11.py +70 -0
  11. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/README.md +0 -0
  12. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/setup.cfg +0 -0
  13. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/src/sibyl_memory_cli/__init__.py +0 -0
  14. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/src/sibyl_memory_cli/_aesthetic.py +0 -0
  15. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/src/sibyl_memory_cli/_banner.py +0 -0
  16. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/src/sibyl_memory_cli.egg-info/dependency_links.txt +0 -0
  17. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/src/sibyl_memory_cli.egg-info/entry_points.txt +0 -0
  18. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/src/sibyl_memory_cli.egg-info/requires.txt +0 -0
  19. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/src/sibyl_memory_cli.egg-info/top_level.txt +0 -0
  20. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/tests/test_hermes_install_args.py +0 -0
  21. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/tests/test_init_perms_guard.py +0 -0
  22. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/tests/test_migrate.py +0 -0
  23. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/tests/test_migrate_adversarial.py +0 -0
  24. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/tests/test_orchestrator.py +0 -0
  25. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/tests/test_setup.py +0 -0
  26. {sibyl_memory_cli-0.3.11 → sibyl_memory_cli-0.3.15}/tests/test_wiring_fix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.3.11
3
+ Version: 0.3.15
4
4
  Summary: Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats, `sibyl whoami` gives a one-line account summary, `sibyl devices` lists active devices and supports per-device revoke.
5
5
  Author-email: "SIBYL, Sibyl Labs LLC" <sibyl@sibyllabs.org>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sibyl-memory-cli"
7
- version = "0.3.11"
7
+ version = "0.3.15"
8
8
  description = "Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats, `sibyl whoami` gives a one-line account summary, `sibyl devices` lists active devices and supports per-device revoke."
9
9
  authors = [{ name = "SIBYL, Sibyl Labs LLC", email = "sibyl@sibyllabs.org" }]
10
10
  license = { text = "MIT" }
@@ -516,6 +516,55 @@ def cmd_upgrade(args: argparse.Namespace) -> int:
516
516
 
517
517
  # ---- `sibyl status` ----------------------------------------------------
518
518
 
519
+ def _discover_stores(primary_db: Path) -> list[dict[str, Any]]:
520
+ """Enumerate every memory.db an agent on this machine might resolve.
521
+
522
+ Beta reports (VRTX 2026-06-11) showed split-brain storage: the SDK / CLI /
523
+ MCP default (``~/.sibyl-memory/memory.db``), the Hermes adapter
524
+ (``$HERMES_HOME/sibyl/memory.db``), per-profile DBs
525
+ (``$HERMES_HOME/sibyl/profiles/<p>/memory.db``), and an MCP
526
+ ``SIBYL_MEMORY_DB`` override can each hold a disjoint set of memories, so a
527
+ user switching entry points sees memory "vanish". This surfaces all of
528
+ them in one place. Read-only: it never creates or moves anything (path
529
+ unification is a separate, migration-gated change).
530
+
531
+ Returns one dict per DISTINCT existing store, resolved + deduped:
532
+ ``{"label", "path", "size"}``.
533
+ """
534
+ candidates: list[tuple[str, Path]] = [("default (SDK/CLI/MCP)", primary_db)]
535
+
536
+ hermes_home_env = os.environ.get("HERMES_HOME")
537
+ hermes_home = Path(hermes_home_env).expanduser() if hermes_home_env else (Path.home() / ".hermes")
538
+ candidates.append(("hermes adapter", hermes_home / "sibyl" / "memory.db"))
539
+ profiles_dir = hermes_home / "sibyl" / "profiles"
540
+ if profiles_dir.is_dir():
541
+ for prof in sorted(profiles_dir.iterdir()):
542
+ db = prof / "memory.db"
543
+ if db.exists():
544
+ candidates.append((f"hermes profile · {prof.name}", db))
545
+
546
+ mcp_override = os.environ.get("SIBYL_MEMORY_DB")
547
+ if mcp_override:
548
+ candidates.append(("MCP SIBYL_MEMORY_DB", Path(mcp_override).expanduser()))
549
+
550
+ seen: set[str] = set()
551
+ stores: list[dict[str, Any]] = []
552
+ for label, path in candidates:
553
+ try:
554
+ resolved = str(path.resolve())
555
+ except OSError:
556
+ resolved = str(path)
557
+ if resolved in seen or not path.exists():
558
+ continue
559
+ seen.add(resolved)
560
+ try:
561
+ size = path.stat().st_size
562
+ except OSError:
563
+ size = 0
564
+ stores.append({"label": label, "path": str(path), "size": size})
565
+ return stores
566
+
567
+
519
568
  def cmd_status(args: argparse.Namespace) -> int:
520
569
  """Show local + server-side state without modifying anything.
521
570
 
@@ -552,10 +601,39 @@ def cmd_status(args: argparse.Namespace) -> int:
552
601
  else:
553
602
  print(a.kv("DB path", f"{db_path} (not created)"))
554
603
 
604
+ # All resolvable stores on this machine (split-brain visibility floor,
605
+ # VRTX beta report 2026-06-11). Read-only: lists what exists, moves
606
+ # nothing. A divergence warning fires when more than one store holds data,
607
+ # because that is exactly when an agent "loses" memory by switching the
608
+ # entry point it reads from.
609
+ stores = _discover_stores(db_path)
610
+ if len(stores) > 1:
611
+ print()
612
+ print(a.eyebrow("memory stores"))
613
+ for s in stores:
614
+ mb = s["size"] / (1024 * 1024)
615
+ print(a.kv(s["label"], f"{s['path']} ({s['size']:,} bytes · {mb:.2f} MB)"))
616
+ with_data = [s for s in stores if s["size"] > 0]
617
+ if len(with_data) > 1:
618
+ print()
619
+ print(a.warn_line("Multiple memory stores hold data on this machine."))
620
+ print(a.dim(" Memory is NOT shared across these paths. An agent reads only the store"))
621
+ print(a.dim(" for its entry point (SDK/CLI vs Hermes vs profile vs MCP), so memory can"))
622
+ print(a.dim(" look 'missing' when you switch. Point every entry point at one path via"))
623
+ print(a.dim(" --db / SIBYL_MEMORY_DB, or back up and consolidate before relying on recall."))
624
+
555
625
  tier_cache = Path(args.tier_cache).expanduser()
556
626
  if tier_cache.exists():
557
627
  cache = json.loads(tier_cache.read_text(encoding="utf-8"))
558
- print(a.kv("Tier cache", f"{cache.get('tier','?')} (checked {cache.get('checked_at','?')[:19]})"))
628
+ # checked_at is written by _capcheck.py as epoch seconds (float), but
629
+ # older caches / future formats may carry an ISO string. Render both;
630
+ # never index a float (TypeError on every `sibyl status` run with a
631
+ # populated tier cache; Discord report 2026-06-10).
632
+ checked = cache.get("checked_at")
633
+ if isinstance(checked, (int, float)) and not isinstance(checked, bool):
634
+ checked = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(checked))
635
+ checked = str(checked)[:19] if checked else "?"
636
+ print(a.kv("Tier cache", f"{cache.get('tier','?')} (checked {checked})"))
559
637
  else:
560
638
  print(a.kv("Tier cache", "—"))
561
639
 
@@ -1056,7 +1134,7 @@ def cmd_migrate(args: argparse.Namespace) -> int:
1056
1134
  io = _migrate_io()
1057
1135
  report = M.run_guided_setup(
1058
1136
  home=home, cwd=cwd, db_path=db_path, backup_parent=backup_parent,
1059
- io=io, debloat=not args.no_debloat,
1137
+ io=io, debloat=not args.no_debloat, force=getattr(args, "force", False),
1060
1138
  )
1061
1139
 
1062
1140
  ph = report.get("phases", {})
@@ -1136,6 +1214,22 @@ def build_parser() -> argparse.ArgumentParser:
1136
1214
  p_h = sub.add_parser("health", help="Run the provider self-check")
1137
1215
  p_h.set_defaults(func=cmd_health)
1138
1216
 
1217
+ p_mem = sub.add_parser("memory", help="Read-only inspection of your memory store (list / search / recall)")
1218
+ mem_sub = p_mem.add_subparsers(dest="mem_cmd")
1219
+ p_ml = mem_sub.add_parser("list", help="List entities (optionally filtered by category)")
1220
+ p_ml.add_argument("category", nargs="?", default=None, help="Optional category to filter by")
1221
+ p_ml.add_argument("--limit", type=int, default=50, help="Max rows (default 50)")
1222
+ p_ml.set_defaults(func=cmd_memory)
1223
+ p_ms = mem_sub.add_parser("search", help="Full-text search across entities + state + reference + journal")
1224
+ p_ms.add_argument("query", help="Search query (matches stored text, not meaning)")
1225
+ p_ms.add_argument("--limit", type=int, default=20, help="Max hits (default 20)")
1226
+ p_ms.set_defaults(func=cmd_memory)
1227
+ p_mr = mem_sub.add_parser("recall", help="Recall one entity by category + name")
1228
+ p_mr.add_argument("category", help="Entity category")
1229
+ p_mr.add_argument("name", help="Entity name")
1230
+ p_mr.set_defaults(func=cmd_memory)
1231
+ p_mem.set_defaults(func=cmd_memory)
1232
+
1139
1233
  p_update = sub.add_parser(
1140
1234
  "update",
1141
1235
  help="Check for newer sibyl-memory-* releases on PyPI (use --apply to upgrade)",
@@ -1196,11 +1290,71 @@ def build_parser() -> argparse.ArgumentParser:
1196
1290
  "--yes", "-y", action="store_true",
1197
1291
  help="Skip the initial confirm (the trim step still always asks separately)",
1198
1292
  )
1293
+ p_migrate.add_argument(
1294
+ "--force", action="store_true",
1295
+ help="Overwrite an existing non-sibyl memory provider when wiring a harness "
1296
+ "(without this, migrate stops at that harness and tells you to re-run with --force)",
1297
+ )
1199
1298
  p_migrate.set_defaults(func=cmd_migrate)
1200
1299
 
1201
1300
  return p
1202
1301
 
1203
1302
 
1303
+ def cmd_memory(args: argparse.Namespace) -> int:
1304
+ """Read-only inspection of the local memory store (PKG-4, VRTX/deadguy beta).
1305
+
1306
+ sibyl memory list [category] list entities
1307
+ sibyl memory search <query> full-text search across tiers
1308
+ sibyl memory recall <cat> <nm> recall one entity by category + name
1309
+
1310
+ Opens the resolved DB read-only via the SDK; never writes. Respects --db so
1311
+ you can inspect any split-brain store that `sibyl status` surfaces.
1312
+ """
1313
+ from sibyl_memory_client import MemoryClient
1314
+
1315
+ db_path = Path(args.db).expanduser()
1316
+ print()
1317
+ if not db_path.exists():
1318
+ print(a.warn_line(f"No memory store at {db_path}."))
1319
+ print(a.dim(" Run `sibyl status` to see every store on this machine."))
1320
+ return 1
1321
+ client = MemoryClient.local(path=db_path)
1322
+ op = getattr(args, "mem_cmd", None)
1323
+ if op == "list":
1324
+ rows = client.list_entities(category=args.category, limit=args.limit)
1325
+ if not rows:
1326
+ print(a.dim("(no entities)"))
1327
+ return 0
1328
+ print(a.eyebrow(f"entities ({len(rows)})"))
1329
+ for r in rows:
1330
+ print(a.kv(f"{r['category']}/{r['name']}", r.get("status") or "-"))
1331
+ return 0
1332
+ if op == "search":
1333
+ hits = client.search(args.query, limit=args.limit)
1334
+ if not hits:
1335
+ print(a.dim(f"(no matches for {args.query!r})"))
1336
+ return 0
1337
+ print(a.eyebrow(f"matches ({len(hits)})"))
1338
+ for h in hits:
1339
+ snip = (h.get("snippet") or "").replace("\n", " ")[:100]
1340
+ print(a.kv(f"[{h.get('tier') or '-'}] {h.get('key') or '-'}", snip))
1341
+ return 0
1342
+ if op == "recall":
1343
+ try:
1344
+ ent = client.get_entity(args.category, args.name)
1345
+ except Exception as e:
1346
+ print(a.warn_line(str(e)))
1347
+ return 1
1348
+ print(a.eyebrow(f"{ent['category']}/{ent['name']}"))
1349
+ print(a.kv("status", ent.get("status") or "-"))
1350
+ print(a.kv("updated", ent.get("updated_at") or "-"))
1351
+ body = ent.get("body")
1352
+ print(body if isinstance(body, str) else json.dumps(body, indent=2, ensure_ascii=False))
1353
+ return 0
1354
+ print(a.warn_line("Usage: sibyl memory {list|search|recall} ..."))
1355
+ return 1
1356
+
1357
+
1204
1358
  def main(argv: list[str] | None = None) -> int:
1205
1359
  parser = build_parser()
1206
1360
  args = parser.parse_args(argv)
@@ -330,7 +330,7 @@ class GuidedIO:
330
330
  def run_guided_setup(*, home=None, cwd=None, db_path=None, backup_parent=None,
331
331
  io: Optional[GuidedIO] = None, wirers: Optional[dict] = None,
332
332
  extract_fn: Optional[Callable[[Path, Path], None]] = None,
333
- debloat: bool = True, now=None) -> dict:
333
+ debloat: bool = True, force: bool = False, now=None) -> dict:
334
334
  """The assembled guided flow: backup -> auto-wire each harness (instructions on
335
335
  failure) -> extraction handoff -> verify -> confirmed debloat. Returns a structured
336
336
  report. `extract_fn(backup_dir, db_path)` performs/simulates extraction; default
@@ -367,7 +367,7 @@ def run_guided_setup(*, home=None, cwd=None, db_path=None, backup_parent=None,
367
367
  if w.current_state().get("wired_with_sibyl"):
368
368
  wire_report[name] = "already"
369
369
  continue
370
- outcome = w.wire()
370
+ outcome = w.wire(force=force)
371
371
  wire_report[name] = outcome.status
372
372
  if outcome.status not in ("wired", "already"):
373
373
  io.say(f"{name}: auto-wire incomplete ({outcome.message}). Do this manually:")
@@ -222,6 +222,15 @@ class HermesWirer:
222
222
  loaded = yaml.safe_load(raw)
223
223
  if isinstance(loaded, dict):
224
224
  cfg = loaded
225
+ elif loaded is not None:
226
+ # Fail fast: a non-mapping top level means the config is not
227
+ # something we can merge into. Silently reinitializing would
228
+ # destroy the user's existing Hermes settings.
229
+ raise ValueError(
230
+ f"{self.config_path} top level is not a YAML mapping "
231
+ f"(got {type(loaded).__name__}). Fix the file or move it "
232
+ "aside and re-run; it was not modified."
233
+ )
225
234
  if not isinstance(cfg.get("memory"), dict):
226
235
  cfg["memory"] = {}
227
236
  cfg["memory"]["provider"] = "sibyl"
@@ -338,6 +347,7 @@ class ClaudeCodeWirer:
338
347
  settings_exists = self.settings_path.exists()
339
348
  mcp_servers: dict = {}
340
349
  sibyl_block: Optional[dict] = None
350
+ settings_parse_error: Optional[str] = None
341
351
  if settings_exists:
342
352
  try:
343
353
  cfg = json.loads(self.settings_path.read_text(encoding="utf-8"))
@@ -346,8 +356,8 @@ class ClaudeCodeWirer:
346
356
  if isinstance(raw_servers, dict):
347
357
  mcp_servers = raw_servers
348
358
  sibyl_block = mcp_servers.get("sibyl-memory")
349
- except Exception:
350
- pass
359
+ except Exception as e:
360
+ settings_parse_error = f"{type(e).__name__}: {e}"
351
361
  mcp_binary = self._mcp_binary_found()
352
362
  cli_registered = self._registered_via_cli() # None when no `claude` CLI
353
363
  # Source of truth: when the claude CLI exists, trust `claude mcp get` (where
@@ -359,6 +369,7 @@ class ClaudeCodeWirer:
359
369
  return {
360
370
  "settings_path": str(self.settings_path),
361
371
  "settings_exists": settings_exists,
372
+ "settings_parse_error": settings_parse_error,
362
373
  "mcp_servers_count": len(mcp_servers),
363
374
  "sibyl_mcp": sibyl_block,
364
375
  "mcp_binary_found": mcp_binary,
@@ -493,12 +504,27 @@ class ClaudeCodeWirer:
493
504
  def _write_settings_with_sibyl(self) -> None:
494
505
  cfg: dict = {}
495
506
  if self.settings_path.exists():
496
- try:
497
- loaded = json.loads(self.settings_path.read_text(encoding="utf-8"))
507
+ raw = self.settings_path.read_text(encoding="utf-8")
508
+ if raw.strip():
509
+ try:
510
+ loaded = json.loads(raw)
511
+ except ValueError as e:
512
+ # Fail fast: never atomically replace a corrupt settings.json
513
+ # with a sibyl-only file (that destroys the user's other
514
+ # mcpServers, permissions, hooks, env).
515
+ raise ValueError(
516
+ f"{self.settings_path} is not valid JSON ({e}). "
517
+ "Fix the file or move it aside and re-run setup; "
518
+ "your settings were not modified."
519
+ ) from e
498
520
  if isinstance(loaded, dict):
499
521
  cfg = loaded
500
- except Exception:
501
- cfg = {}
522
+ else:
523
+ raise ValueError(
524
+ f"{self.settings_path} top level is not a JSON object "
525
+ f"(got {type(loaded).__name__}). Fix the file or move it "
526
+ "aside and re-run setup; your settings were not modified."
527
+ )
502
528
  if not isinstance(cfg.get("mcpServers"), dict):
503
529
  cfg["mcpServers"] = {}
504
530
  cfg["mcpServers"]["sibyl-memory"] = self.SIBYL_MCP_BLOCK
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sibyl-memory-cli
3
- Version: 0.3.11
3
+ Version: 0.3.15
4
4
  Summary: Command-line interface for the Sibyl Memory Plugin. `sibyl init` activates, `sibyl upgrade` runs the staker / subscription flow, `sibyl status` shows current tier and DB stats, `sibyl whoami` gives a one-line account summary, `sibyl devices` lists active devices and supports per-device revoke.
5
5
  Author-email: "SIBYL, Sibyl Labs LLC" <sibyl@sibyllabs.org>
6
6
  License: MIT
@@ -14,8 +14,11 @@ src/sibyl_memory_cli.egg-info/requires.txt
14
14
  src/sibyl_memory_cli.egg-info/top_level.txt
15
15
  tests/test_hermes_install_args.py
16
16
  tests/test_init_perms_guard.py
17
+ tests/test_memory_cmd_2026_06_16.py
17
18
  tests/test_migrate.py
18
19
  tests/test_migrate_adversarial.py
20
+ tests/test_migrate_force_2026_06_05.py
19
21
  tests/test_orchestrator.py
20
22
  tests/test_setup.py
23
+ tests/test_status_stores_2026_06_11.py
21
24
  tests/test_wiring_fix.py
@@ -0,0 +1,55 @@
1
+ """PKG-4 (VRTX/deadguy beta): read-only `sibyl memory list/search/recall` CLI.
2
+ Lets a tester inspect what's actually stored without writing through an agent."""
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from sibyl_memory_client import MemoryClient
8
+ from sibyl_memory_cli import cli
9
+
10
+
11
+ def _store(tmp_path: Path) -> Path:
12
+ d = tmp_path / "memory.db"
13
+ c = MemoryClient.local(path=d)
14
+ c.set_entity("partner", "Blocktronics", {"stage": "active", "note": "token forensics suite"})
15
+ c.set_entity("partner", "Reppo", {"stage": "negotiation"})
16
+ return d
17
+
18
+
19
+ def test_memory_list(tmp_path, capsys):
20
+ d = _store(tmp_path)
21
+ rc = cli.main(["--db", str(d), "memory", "list"])
22
+ out = capsys.readouterr().out
23
+ assert rc == 0
24
+ assert "Blocktronics" in out and "Reppo" in out
25
+
26
+
27
+ def test_memory_list_category_filter(tmp_path, capsys):
28
+ d = _store(tmp_path)
29
+ rc = cli.main(["--db", str(d), "memory", "list", "partner", "--limit", "1"])
30
+ assert rc == 0
31
+
32
+
33
+ def test_memory_search(tmp_path, capsys):
34
+ d = _store(tmp_path)
35
+ rc = cli.main(["--db", str(d), "memory", "search", "forensics"])
36
+ out = capsys.readouterr().out
37
+ assert rc == 0 and "Blocktronics" in out
38
+
39
+
40
+ def test_memory_recall(tmp_path, capsys):
41
+ d = _store(tmp_path)
42
+ rc = cli.main(["--db", str(d), "memory", "recall", "partner", "Blocktronics"])
43
+ out = capsys.readouterr().out
44
+ assert rc == 0 and "forensics" in out
45
+
46
+
47
+ def test_memory_recall_missing_returns_1(tmp_path):
48
+ d = _store(tmp_path)
49
+ rc = cli.main(["--db", str(d), "memory", "recall", "partner", "Nope"])
50
+ assert rc == 1
51
+
52
+
53
+ def test_memory_no_store_returns_1(tmp_path):
54
+ rc = cli.main(["--db", str(tmp_path / "absent.db"), "memory", "list"])
55
+ assert rc == 1
@@ -0,0 +1,67 @@
1
+ """Regression (bugflow 2026-06-05): `sibyl migrate --force` must reach the wirers.
2
+
3
+ Onboarding dead-end: when a detected harness already had a non-sibyl memory
4
+ provider, the wirer refused with "Use --force to overwrite." but
5
+ `run_guided_setup` called `wire()` with no `force`, and `sibyl migrate` had no
6
+ `--force` flag to pass. The flag now threads cli -> run_guided_setup(force=) ->
7
+ wire(force=force).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from sibyl_memory_cli import migrate as M
12
+ from sibyl_memory_cli.setup import WireOutcome
13
+ from sibyl_memory_client import MemoryClient
14
+
15
+
16
+ class _RecordingWirer:
17
+ """A fake harness wirer that records the `force` kwarg it was called with."""
18
+
19
+ name = "rec"
20
+
21
+ def __init__(self):
22
+ self.seen_force = None
23
+
24
+ def is_present(self):
25
+ return True
26
+
27
+ def current_state(self):
28
+ return {"wired_with_sibyl": False}
29
+
30
+ def wire(self, *, force: bool = False, dry_run: bool = False, prompt_fn=None):
31
+ self.seen_force = force
32
+ return WireOutcome(self.name, "wired", "ok")
33
+
34
+
35
+ def _home_with_memory(tmp_path):
36
+ h = tmp_path / "home"
37
+ (h / "proj").mkdir(parents=True)
38
+ (h / "proj" / "CLAUDE.md").write_text("# memory\n- a fact worth keeping\n")
39
+ db = h / ".sibyl-memory" / "memory.db"
40
+ db.parent.mkdir(parents=True)
41
+ return h, db
42
+
43
+
44
+ def _fake_extract(_backup_dir, db_path):
45
+ MemoryClient.local(str(db_path), tenant_id="qa").set_entity("f", "a", {"v": 1})
46
+
47
+
48
+ def test_force_true_threads_to_wirer(tmp_path):
49
+ h, db = _home_with_memory(tmp_path)
50
+ rec = _RecordingWirer()
51
+ M.run_guided_setup(
52
+ home=h, cwd=h / "proj", db_path=db, backup_parent=tmp_path / "bk",
53
+ io=M.GuidedIO(scripted=["n"]), wirers={"rec": rec},
54
+ extract_fn=_fake_extract, force=True,
55
+ )
56
+ assert rec.seen_force is True
57
+
58
+
59
+ def test_force_defaults_false(tmp_path):
60
+ h, db = _home_with_memory(tmp_path)
61
+ rec = _RecordingWirer()
62
+ M.run_guided_setup(
63
+ home=h, cwd=h / "proj", db_path=db, backup_parent=tmp_path / "bk",
64
+ io=M.GuidedIO(scripted=["n"]), wirers={"rec": rec},
65
+ extract_fn=_fake_extract,
66
+ )
67
+ assert rec.seen_force is False
@@ -0,0 +1,70 @@
1
+ """PKG-2/3 regression: `sibyl status` discovers + lists every memory store and
2
+ warns on split-brain divergence (VRTX beta report 2026-06-11).
3
+
4
+ Read-only: discovery never creates or moves a DB.
5
+ """
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from sibyl_memory_cli import cli
13
+
14
+
15
+ @pytest.fixture
16
+ def sandbox(monkeypatch):
17
+ d = Path(tempfile.mkdtemp())
18
+ primary = d / ".sibyl-memory" / "memory.db"
19
+ primary.parent.mkdir(parents=True)
20
+ hermes = d / ".hermes"
21
+ (hermes / "sibyl").mkdir(parents=True)
22
+ monkeypatch.setenv("HERMES_HOME", str(hermes))
23
+ monkeypatch.delenv("SIBYL_MEMORY_DB", raising=False)
24
+ return d, primary, hermes
25
+
26
+
27
+ def test_discovers_default_hermes_and_profiles(sandbox):
28
+ d, primary, hermes = sandbox
29
+ primary.write_bytes(b"x" * 100)
30
+ (hermes / "sibyl" / "memory.db").write_bytes(b"y" * 200)
31
+ prof = hermes / "sibyl" / "profiles" / "alpha"
32
+ prof.mkdir(parents=True)
33
+ (prof / "memory.db").write_bytes(b"z" * 50)
34
+
35
+ stores = cli._discover_stores(primary)
36
+ labels = {s["label"] for s in stores}
37
+ assert "default (SDK/CLI/MCP)" in labels
38
+ assert "hermes adapter" in labels
39
+ assert any(l.startswith("hermes profile") for l in labels)
40
+ assert {s["size"] for s in stores} == {100, 200, 50}
41
+
42
+
43
+ def test_skips_nonexistent_and_dedups(sandbox):
44
+ d, primary, hermes = sandbox
45
+ primary.write_bytes(b"x" * 10)
46
+ # hermes adapter db does not exist → excluded
47
+ stores = cli._discover_stores(primary)
48
+ assert [s["label"] for s in stores] == ["default (SDK/CLI/MCP)"]
49
+
50
+
51
+ def test_mcp_override_included(sandbox, monkeypatch):
52
+ d, primary, hermes = sandbox
53
+ primary.write_bytes(b"x" * 10)
54
+ shadow = d / "shadow.db"
55
+ shadow.write_bytes(b"s" * 5)
56
+ monkeypatch.setenv("SIBYL_MEMORY_DB", str(shadow))
57
+ stores = cli._discover_stores(primary)
58
+ assert any(s["label"] == "MCP SIBYL_MEMORY_DB" for s in stores)
59
+
60
+
61
+ def test_status_warns_on_divergence(sandbox, capsys):
62
+ d, primary, hermes = sandbox
63
+ primary.write_bytes(b"x" * 100)
64
+ (hermes / "sibyl" / "memory.db").write_bytes(b"y" * 200)
65
+
66
+ # Build the minimal args cmd_status reads. No credentials → early-return
67
+ # path, so drive discovery directly to assert the warning copy instead.
68
+ stores = cli._discover_stores(primary)
69
+ with_data = [s for s in stores if s["size"] > 0]
70
+ assert len(with_data) > 1 # divergence condition that triggers the warning