compartment 3.1__tar.gz → 3.3__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 (73) hide show
  1. {compartment-3.1 → compartment-3.3}/PKG-INFO +1 -1
  2. {compartment-3.1 → compartment-3.3}/pyproject.toml +1 -1
  3. {compartment-3.1 → compartment-3.3}/src/compartment/__init__.py +1 -1
  4. {compartment-3.1 → compartment-3.3}/src/compartment/acl.py +1 -0
  5. {compartment-3.1 → compartment-3.3}/src/compartment/cli.py +145 -11
  6. {compartment-3.1 → compartment-3.3}/src/compartment/menubar.py +39 -3
  7. {compartment-3.1 → compartment-3.3}/src/compartment/systray.py +15 -3
  8. {compartment-3.1 → compartment-3.3}/src/compartment/vault.py +18 -1
  9. {compartment-3.1 → compartment-3.3}/src/compartment.egg-info/PKG-INFO +1 -1
  10. {compartment-3.1 → compartment-3.3}/src/compartment.egg-info/SOURCES.txt +1 -0
  11. {compartment-3.1 → compartment-3.3}/tests/test_menubar.py +1 -1
  12. compartment-3.3/tests/test_starter_visibility.py +197 -0
  13. {compartment-3.1 → compartment-3.3}/README.md +0 -0
  14. {compartment-3.1 → compartment-3.3}/setup.cfg +0 -0
  15. {compartment-3.1 → compartment-3.3}/src/compartment/audit.py +0 -0
  16. {compartment-3.1 → compartment-3.3}/src/compartment/bench.py +0 -0
  17. {compartment-3.1 → compartment-3.3}/src/compartment/claude_hooks.py +0 -0
  18. {compartment-3.1 → compartment-3.3}/src/compartment/claude_memory.py +0 -0
  19. {compartment-3.1 → compartment-3.3}/src/compartment/crypto.py +0 -0
  20. {compartment-3.1 → compartment-3.3}/src/compartment/dash.py +0 -0
  21. {compartment-3.1 → compartment-3.3}/src/compartment/data/hermes-plugin/__init__.py +0 -0
  22. {compartment-3.1 → compartment-3.3}/src/compartment/data/hermes-plugin/plugin.yaml +0 -0
  23. {compartment-3.1 → compartment-3.3}/src/compartment/data/menubar.png +0 -0
  24. {compartment-3.1 → compartment-3.3}/src/compartment/data/menubar@2x.png +0 -0
  25. {compartment-3.1 → compartment-3.3}/src/compartment/data/starter.mpack +0 -0
  26. {compartment-3.1 → compartment-3.3}/src/compartment/data/tray.ico +0 -0
  27. {compartment-3.1 → compartment-3.3}/src/compartment/embed.py +0 -0
  28. {compartment-3.1 → compartment-3.3}/src/compartment/home.py +0 -0
  29. {compartment-3.1 → compartment-3.3}/src/compartment/longmemeval.py +0 -0
  30. {compartment-3.1 → compartment-3.3}/src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
  31. {compartment-3.1 → compartment-3.3}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json +0 -0
  32. {compartment-3.1 → compartment-3.3}/src/compartment/offline_guard.py +0 -0
  33. {compartment-3.1 → compartment-3.3}/src/compartment/packs.py +0 -0
  34. {compartment-3.1 → compartment-3.3}/src/compartment/platforms.py +0 -0
  35. {compartment-3.1 → compartment-3.3}/src/compartment/salience.py +0 -0
  36. {compartment-3.1 → compartment-3.3}/src/compartment/selftest.py +0 -0
  37. {compartment-3.1 → compartment-3.3}/src/compartment/server.py +0 -0
  38. {compartment-3.1 → compartment-3.3}/src/compartment/session.py +0 -0
  39. {compartment-3.1 → compartment-3.3}/src/compartment/store.py +0 -0
  40. {compartment-3.1 → compartment-3.3}/src/compartment/vaultfile.py +0 -0
  41. {compartment-3.1 → compartment-3.3}/src/compartment/vindex.py +0 -0
  42. {compartment-3.1 → compartment-3.3}/src/compartment/wire.py +0 -0
  43. {compartment-3.1 → compartment-3.3}/src/compartment.egg-info/dependency_links.txt +0 -0
  44. {compartment-3.1 → compartment-3.3}/src/compartment.egg-info/entry_points.txt +0 -0
  45. {compartment-3.1 → compartment-3.3}/src/compartment.egg-info/requires.txt +0 -0
  46. {compartment-3.1 → compartment-3.3}/src/compartment.egg-info/top_level.txt +0 -0
  47. {compartment-3.1 → compartment-3.3}/tests/test_acl_audit_packs.py +0 -0
  48. {compartment-3.1 → compartment-3.3}/tests/test_acl_precedence.py +0 -0
  49. {compartment-3.1 → compartment-3.3}/tests/test_attempt_pacing.py +0 -0
  50. {compartment-3.1 → compartment-3.3}/tests/test_audit_chain.py +0 -0
  51. {compartment-3.1 → compartment-3.3}/tests/test_claude_hooks_and_integrate.py +0 -0
  52. {compartment-3.1 → compartment-3.3}/tests/test_claude_memory_and_recent.py +0 -0
  53. {compartment-3.1 → compartment-3.3}/tests/test_crypto.py +0 -0
  54. {compartment-3.1 → compartment-3.3}/tests/test_dash.py +0 -0
  55. {compartment-3.1 → compartment-3.3}/tests/test_instructions.py +0 -0
  56. {compartment-3.1 → compartment-3.3}/tests/test_journal_framing.py +0 -0
  57. {compartment-3.1 → compartment-3.3}/tests/test_macos_bundle.py +0 -0
  58. {compartment-3.1 → compartment-3.3}/tests/test_offline_guard_paths.py +0 -0
  59. {compartment-3.1 → compartment-3.3}/tests/test_offline_selftest_bench.py +0 -0
  60. {compartment-3.1 → compartment-3.3}/tests/test_ondisk_format.py +0 -0
  61. {compartment-3.1 → compartment-3.3}/tests/test_pack_trust.py +0 -0
  62. {compartment-3.1 → compartment-3.3}/tests/test_platforms.py +0 -0
  63. {compartment-3.1 → compartment-3.3}/tests/test_relations.py +0 -0
  64. {compartment-3.1 → compartment-3.3}/tests/test_salience.py +0 -0
  65. {compartment-3.1 → compartment-3.3}/tests/test_session_entropy.py +0 -0
  66. {compartment-3.1 → compartment-3.3}/tests/test_session_lock.py +0 -0
  67. {compartment-3.1 → compartment-3.3}/tests/test_starter_data_quality.py +0 -0
  68. {compartment-3.1 → compartment-3.3}/tests/test_starter_packs.py +0 -0
  69. {compartment-3.1 → compartment-3.3}/tests/test_status_bar_install.py +0 -0
  70. {compartment-3.1 → compartment-3.3}/tests/test_systray.py +0 -0
  71. {compartment-3.1 → compartment-3.3}/tests/test_tamper.py +0 -0
  72. {compartment-3.1 → compartment-3.3}/tests/test_twofa.py +0 -0
  73. {compartment-3.1 → compartment-3.3}/tests/test_vault_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 3.1
3
+ Version: 3.3
4
4
  Summary: Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)
5
5
  Project-URL: Homepage, https://github.com/MaxFreedomPollard/Compartment
6
6
  Project-URL: Source, https://github.com/MaxFreedomPollard/Compartment
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compartment"
7
- version = "3.1"
7
+ version = "3.3"
8
8
  description = "Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  """Compartment - high-security, fully offline, encrypted vector memory for AI agents."""
2
2
 
3
- __version__ = "3.1"
3
+ __version__ = "3.3"
4
4
 
5
5
  from . import offline_guard as _og
6
6
 
@@ -25,6 +25,7 @@ DEFAULT_CONFIG = {
25
25
  "settings": {
26
26
  "auto_lock_minutes": 30,
27
27
  "include_packs_in_search": True,
28
+ "search_starter_facts": True,
28
29
  "unlock_tool_enabled": False,
29
30
  "duplicate_threshold": 0.97,
30
31
  "index_precision": "f32",
@@ -86,11 +86,23 @@ def _pack_bytes(name: str) -> bytes | None:
86
86
  return p.read_bytes() if p.is_file() else None
87
87
 
88
88
 
89
- def _seed_pack_bytes() -> bytes:
90
- b = _pack_bytes("starter")
91
- if b is None:
92
- raise CryptoError("Bundled starter.mpack is missing from this install")
93
- return b
89
+ def _seed_blobs() -> list[tuple[str, bytes]]:
90
+ """The starting memories, or a hard failure.
91
+
92
+ They are part of every install, command line or app, so an install that
93
+ cannot produce them is broken and has to say so. Returning nothing here
94
+ would create a vault that is empty but looks finished, which is the one
95
+ outcome a new user cannot diagnose."""
96
+ out = []
97
+ for name in _starter_pack_names():
98
+ blob = _pack_bytes(name)
99
+ if blob is None:
100
+ _die(f"this install is incomplete: {name}.mpack is missing from "
101
+ f"{_data_dir()}. The starting memories ship with every "
102
+ "install - reinstall Compartment rather than start with an "
103
+ "empty vault.")
104
+ out.append((name, blob))
105
+ return out
94
106
 
95
107
 
96
108
  def _starter_pack_names() -> list[str]:
@@ -112,6 +124,9 @@ def cmd_init(args) -> None:
112
124
  Path(path).parent.mkdir(parents=True, exist_ok=True)
113
125
  if os.path.exists(path):
114
126
  _die(f"{path} already exists - Compartment never overwrites a vault")
127
+ # Before a passphrase is chosen and before anything reaches disk: a broken
128
+ # install fails here, with nothing to clean up.
129
+ seeds = _seed_blobs()
115
130
  print(f"Compartment {__version__} - creating vault: {path}")
116
131
  print(f"Embedding model: {DEFAULT_MODEL} (bundled, offline)")
117
132
  if args.passphrase:
@@ -131,10 +146,7 @@ def cmd_init(args) -> None:
131
146
  print("(Optional second factor: `compartment 2fa enable` - see README.)")
132
147
  print("\nFinishing vault setup (offline)…")
133
148
  total = 0
134
- for name in _starter_pack_names():
135
- blob = _pack_bytes(name)
136
- if blob is None:
137
- continue
149
+ for _, blob in seeds:
138
150
  out = packs.seed_records(v, blob, caller=args.creator)
139
151
  total += out["records"]
140
152
  nrec = out["records"]
@@ -249,6 +261,106 @@ def _start_status_bar_app(vault: str) -> None:
249
261
  "`compartment menubar` yourself")
250
262
 
251
263
 
264
+ def _install_kind() -> tuple[str, list[str]]:
265
+ """How this copy was installed, and the command that upgrades it."""
266
+ exe = Path(sys.argv[0]).resolve()
267
+ if "uv/tools" in str(exe) or shutil.which("uv") and "uv/tools" in str(
268
+ Path(shutil.which("compartment") or "").resolve()):
269
+ return "uv", ["uv", "tool", "upgrade", "compartment"]
270
+ return "pip", [sys.executable, "-m", "pip", "install", "--upgrade",
271
+ "compartment"]
272
+
273
+
274
+ def cmd_update(args) -> None:
275
+ """Upgrade Compartment in place, then put the app back up."""
276
+ kind, cmd = _install_kind()
277
+ if args.source:
278
+ target = "git+https://github.com/MaxFreedomPollard/Compartment@main"
279
+ cmd = (["uv", "tool", "install", "--force", target] if kind == "uv"
280
+ else [sys.executable, "-m", "pip", "install", "--upgrade",
281
+ "--force-reinstall", target])
282
+ print(f"Compartment {__version__} - updating ({kind} install)")
283
+ print(" " + " ".join(cmd))
284
+ try:
285
+ res = subprocess.run(cmd, timeout=1800)
286
+ except (OSError, subprocess.SubprocessError) as exc:
287
+ _die(f"update failed to run: {exc}")
288
+ if res.returncode != 0:
289
+ _die(f"update failed (exit {res.returncode})")
290
+ ver = "?"
291
+ try:
292
+ out = subprocess.run([shutil.which("compartment") or "compartment",
293
+ "--version"], capture_output=True, text=True,
294
+ timeout=120)
295
+ ver = (out.stdout or "").strip() or "?"
296
+ except (OSError, subprocess.SubprocessError):
297
+ pass
298
+ print(f" updated: now {ver}")
299
+ print(" your vault and settings are untouched")
300
+ if not args.no_app and sys.platform in ("darwin", "win32"):
301
+ _restart_status_bar_app(args.vault)
302
+
303
+
304
+ def _restart_status_bar_app(vault: str) -> None:
305
+ """Stop the running status bar app and start the new build."""
306
+ try:
307
+ app = _tray_app()
308
+ app.quit_running()
309
+ except Exception: # noqa: BLE001
310
+ pass
311
+ _start_status_bar_app(vault)
312
+
313
+
314
+ def cmd_uninstall(args) -> None:
315
+ """Remove Compartment from this machine. The vault is KEPT unless asked."""
316
+ print(f"Compartment {__version__} - uninstalling")
317
+ if sys.platform in ("darwin", "win32"):
318
+ try:
319
+ app = _tray_app()
320
+ try:
321
+ app.quit_running()
322
+ print(" status bar app stopped")
323
+ except Exception: # noqa: BLE001
324
+ pass
325
+ print(f" start at login: {app.set_login(False)}")
326
+ except Exception as exc: # noqa: BLE001
327
+ print(f" status bar app: nothing to remove ({exc})")
328
+ try:
329
+ if claude_hooks.is_installed():
330
+ claude_hooks.uninstall()
331
+ print(" Claude Code capture hook removed")
332
+ except Exception as exc: # noqa: BLE001
333
+ print(f" capture hook: {exc}")
334
+ from .home import LEGACY_NAME, NAME
335
+ for name in (NAME, LEGACY_NAME):
336
+ try:
337
+ r = subprocess.run(["claude", "mcp", "remove", name],
338
+ capture_output=True, text=True, timeout=120)
339
+ if r.returncode == 0:
340
+ print(f" MCP registration removed: {name}")
341
+ except (OSError, subprocess.SubprocessError):
342
+ pass
343
+ if args.purge:
344
+ # only on an explicit --purge: this is the only copy of everything
345
+ # the agent has ever learned, and it is not recoverable.
346
+ for f in (args.vault, args.vault + ".config.json",
347
+ args.vault + ".flock"):
348
+ try:
349
+ os.remove(f)
350
+ print(f" deleted {f}")
351
+ except OSError:
352
+ pass
353
+ print(" vault deleted (--purge)")
354
+ else:
355
+ print(f" vault KEPT at {args.vault}")
356
+ print(" delete it yourself, or re-run with --purge, once you are sure")
357
+ kind, _ = _install_kind()
358
+ final = ("uv tool uninstall compartment" if kind == "uv"
359
+ else f"{sys.executable} -m pip uninstall compartment")
360
+ print("\nOne step left, which cannot remove itself while it is running:")
361
+ print(f" {final}")
362
+
363
+
252
364
  def _ask_yn(q: str) -> bool:
253
365
  if not sys.stdin.isatty():
254
366
  return False
@@ -350,8 +462,16 @@ def cmd_recent(args) -> None:
350
462
  print(f"{counts['total']} records | {counts['organic']} organic "
351
463
  f"(stored during use) | {counts['seeded']} seeded")
352
464
  if not out["results"]:
353
- print("\nno memories stored yet"
354
- + ("" if args.all else " (starter facts hidden; --all shows them)"))
465
+ # A fresh vault is not an empty one. Saying "no memories" to
466
+ # someone who just watched several thousand load is how a working
467
+ # install gets mistaken for a broken one.
468
+ if counts["seeded"] and not args.all:
469
+ print(f"\nnothing stored during use yet. The "
470
+ f"{counts['seeded']:,} starting memories that came with "
471
+ f"the vault are loaded and searchable - list them with "
472
+ f"`compartment recent --all`.")
473
+ else:
474
+ print("\nno memories stored yet")
355
475
  else:
356
476
  print(f"\n{len(out['results'])} most recent, oldest first:\n")
357
477
  for r in out["results"]:
@@ -1376,6 +1496,20 @@ def main(argv: list[str] | None = None) -> None:
1376
1496
  help="only packs signed by the project are trusted by default. Name an author's 64 hex character public key to trust it deliberately; repeatable")
1377
1497
  p.set_defaults(fn=cmd_pack_export)
1378
1498
 
1499
+ p = sub.add_parser("update", help="upgrade Compartment in place")
1500
+ p.add_argument("--source", action="store_true",
1501
+ help="update from the GitHub main branch instead of PyPI")
1502
+ p.add_argument("--no-app", action="store_true",
1503
+ help="do not restart the status bar app afterwards")
1504
+ p.set_defaults(fn=cmd_update)
1505
+
1506
+ p = sub.add_parser("uninstall",
1507
+ help="remove Compartment from this machine "
1508
+ "(the vault is kept unless --purge)")
1509
+ p.add_argument("--purge", action="store_true",
1510
+ help="ALSO delete the vault and its config, permanently")
1511
+ p.set_defaults(fn=cmd_uninstall)
1512
+
1379
1513
  p = sub.add_parser("integrate",
1380
1514
  help="one-command wiring into claude, hermes, or openclaw")
1381
1515
  p.add_argument("target", choices=["claude", "hermes", "openclaw"])
@@ -146,7 +146,7 @@ def read_settings(vault: str) -> dict:
146
146
  return {
147
147
  "capture_hook": hook,
148
148
  "search_starter_facts": bool(
149
- cfg.settings.get("include_packs_in_search", True)),
149
+ cfg.settings.get("search_starter_facts", True)),
150
150
  "auto_lock_minutes": int(cfg.settings.get("auto_lock_minutes", 30)),
151
151
  }
152
152
 
@@ -162,7 +162,7 @@ def set_setting(vault: str, key: str, value) -> dict:
162
162
  return read_settings(vault)
163
163
  cfg = VaultConfig.load(vault)
164
164
  if key == "search_starter_facts":
165
- cfg.settings["include_packs_in_search"] = bool(value)
165
+ cfg.settings["search_starter_facts"] = bool(value)
166
166
  elif key == "auto_lock_minutes":
167
167
  cfg.settings["auto_lock_minutes"] = int(value)
168
168
  else:
@@ -289,6 +289,19 @@ def summarise(state: dict) -> str:
289
289
  f"{state['organic']:,} stored by you")
290
290
 
291
291
 
292
+ def starter_note(state: dict) -> str:
293
+ """What the recent list says when nothing has been stored during use.
294
+
295
+ A new vault holds thousands of starting memories and no organic ones, so
296
+ the recent feed is legitimately empty on day one. "Nothing yet" there
297
+ reads as an empty vault, which is the opposite of what happened."""
298
+ seeded = max(int(state.get("records", 0)) - int(state.get("organic", 0)), 0)
299
+ if not seeded:
300
+ return "nothing stored yet"
301
+ return (f"nothing stored during use yet - {seeded:,} starting memories "
302
+ "came with the vault and are searchable now")
303
+
304
+
292
305
  def auto_lock_label(minutes: int) -> str:
293
306
  return "Never" if not minutes else f"{minutes} min"
294
307
 
@@ -413,6 +426,28 @@ def set_login(enabled: bool) -> str:
413
426
  return login_status()
414
427
 
415
428
 
429
+ def quit_running() -> bool:
430
+ """Stop a running status bar app, so an update or uninstall does not leave
431
+ the old build sitting in the menu bar with its binary already gone."""
432
+ import signal
433
+ killed = False
434
+ try:
435
+ out = subprocess.run(["pgrep", "-f", "compartment.*menubar"],
436
+ capture_output=True, text=True, timeout=15).stdout
437
+ except (OSError, subprocess.SubprocessError):
438
+ return False
439
+ me = os.getpid()
440
+ for pid in [int(x) for x in out.split() if x.isdigit()]:
441
+ if pid == me:
442
+ continue
443
+ try:
444
+ os.kill(pid, signal.SIGTERM)
445
+ killed = True
446
+ except OSError:
447
+ pass
448
+ return killed
449
+
450
+
416
451
  # ----------------------------------------------------------------- self test
417
452
 
418
453
  def self_check(vault: str | None = None) -> int:
@@ -708,7 +743,8 @@ def run(vault: str | None = None, show: bool = False,
708
743
  views.append(label("unlock the vault to see them", 11,
709
744
  secondary=True))
710
745
  elif not st["recent"]:
711
- views.append(label("nothing stored yet", 11, secondary=True))
746
+ views.append(label(starter_note(st), 11, secondary=True,
747
+ wrap=True))
712
748
  else:
713
749
  for r in st["recent"]:
714
750
  text = " ".join((r.get("text") or "").split())
@@ -32,7 +32,7 @@ from .home import env, home
32
32
  from .menubar import (AUTO_LOCK_CHOICES, RECENT_COUNT, auto_lock_label,
33
33
  change_passphrase, claim_first_run, default_vault,
34
34
  fetch_state, lock_vault, self_check, set_setting,
35
- summarise, unlock_vault)
35
+ starter_note, summarise, unlock_vault)
36
36
 
37
37
  PANEL_WIDTH = 360
38
38
  PANEL_MAX_HEIGHT = 640
@@ -109,7 +109,7 @@ def panel_rows(state: dict) -> list[tuple[str, str]]:
109
109
  rows.append(("heading", f"LAST {RECENT_COUNT} MEMORIES"))
110
110
  recent = state.get("recent") or []
111
111
  if not recent:
112
- rows.append(("empty", "Nothing yet."))
112
+ rows.append(("empty", starter_note(state)))
113
113
  for r in recent:
114
114
  rows.append(("memory", (r.get("text") or "").strip()))
115
115
  return rows
@@ -486,5 +486,17 @@ def run(vault: str | None = None, show: bool = False,
486
486
  return 0
487
487
 
488
488
 
489
- __all__ = ["run", "self_check", "login_status", "set_login", "panel_rows",
489
+ def quit_running() -> bool:
490
+ """Stop a running tray app before an update or uninstall replaces it."""
491
+ import subprocess
492
+ try:
493
+ subprocess.run(["taskkill", "/F", "/IM", "compartment.exe"],
494
+ capture_output=True, timeout=30)
495
+ return True
496
+ except (OSError, subprocess.SubprocessError):
497
+ return False
498
+
499
+
500
+ __all__ = ["run", "self_check", "login_status", "set_login", "quit_running",
501
+ "panel_rows",
490
502
  "icon_path"]
@@ -61,6 +61,15 @@ QUARANTINE_WARNING = (
61
61
  )
62
62
 
63
63
 
64
+ def is_seeded(tags_json: str) -> bool:
65
+ """Did this record arrive with the vault, rather than during use?
66
+
67
+ Seeding preserves each starting memory's stable id as an "id:" tag, and
68
+ nothing else writes one, so the tag is the mark. It is the same test
69
+ everywhere a caller asks to see one set without the other."""
70
+ return any(t.startswith("id:") for t in json.loads(tags_json))
71
+
72
+
64
73
  class VaultLockedError(CryptoError):
65
74
  pass
66
75
 
@@ -503,11 +512,19 @@ class Vault:
503
512
  + IMPORTANCE_WEIGHT * self._importance_of(rid)
504
513
  for rid, s in scores.items()}
505
514
 
515
+ # The starting memories are ordinary records in "main", so a namespace
516
+ # filter cannot reach them. Anyone who wants recall limited to what the
517
+ # agent learned here turns them off, and that has to be honoured on the
518
+ # one path that returns memories to a model.
519
+ starter = self.config.settings.get("search_starter_facts", True)
520
+
506
521
  results = []
507
522
  for rid in sorted(boosted, key=boosted.get, reverse=True):
508
523
  row = self.db.get_row(rid)
509
524
  if row is None or row["ns"] not in allowed:
510
525
  continue
526
+ if not starter and is_seeded(row["tags"]):
527
+ continue
511
528
  if tags and not set(tags) <= set(json.loads(row["tags"])):
512
529
  continue
513
530
  if since and row["created"] < since:
@@ -564,7 +581,7 @@ class Vault:
564
581
  if row["ns"] not in allowed:
565
582
  continue
566
583
  total += 1
567
- seeded = any(t.startswith("id:") for t in json.loads(row["tags"]))
584
+ seeded = is_seeded(row["tags"])
568
585
  if not seeded:
569
586
  organic += 1
570
587
  if seeded and not include_seeded:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 3.1
3
+ Version: 3.3
4
4
  Summary: Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)
5
5
  Project-URL: Homepage, https://github.com/MaxFreedomPollard/Compartment
6
6
  Project-URL: Source, https://github.com/MaxFreedomPollard/Compartment
@@ -63,6 +63,7 @@ tests/test_session_entropy.py
63
63
  tests/test_session_lock.py
64
64
  tests/test_starter_data_quality.py
65
65
  tests/test_starter_packs.py
66
+ tests/test_starter_visibility.py
66
67
  tests/test_status_bar_install.py
67
68
  tests/test_systray.py
68
69
  tests/test_tamper.py
@@ -43,7 +43,7 @@ def test_set_setting_persists_to_the_config_file(vault_path, monkeypatch):
43
43
  menubar.set_setting(vault_path, "search_starter_facts", False)
44
44
  menubar.set_setting(vault_path, "auto_lock_minutes", 0)
45
45
  cfg = VaultConfig.load(vault_path) # straight from disk
46
- assert cfg.settings["include_packs_in_search"] is False
46
+ assert cfg.settings["search_starter_facts"] is False
47
47
  assert cfg.settings["auto_lock_minutes"] == 0
48
48
  again = menubar.read_settings(vault_path)
49
49
  assert again["search_starter_facts"] is False
@@ -0,0 +1,197 @@
1
+ """The starting memories arrive with every install, and every surface says so.
2
+
3
+ Two separate failures hide behind "where are the starter memories?". One is an
4
+ install that never seeded and said nothing about it. The other is an install
5
+ that seeded perfectly and then reported itself as empty, because the recent
6
+ feed hides seeded records by design. Both look identical to the person who
7
+ just installed it, so both are tested here.
8
+ """
9
+ import json
10
+ import pathlib
11
+
12
+ import pytest
13
+
14
+ from compartment import cli, menubar, packs
15
+ from compartment.acl import VaultConfig
16
+ from compartment.vault import Vault, is_seeded
17
+
18
+ DATA = pathlib.Path(__file__).resolve().parents[1] / "src" / "compartment" / "data"
19
+ PASS = "CorrectHorse"
20
+
21
+
22
+ # --------------------------------------------------------------- the install
23
+
24
+ def test_a_missing_starter_pack_is_a_hard_failure(monkeypatch, tmp_path):
25
+ """Never quietly hand back an empty vault."""
26
+ monkeypatch.setattr(cli, "_data_dir", lambda: tmp_path / "not-here")
27
+ with pytest.raises(SystemExit):
28
+ cli._seed_blobs()
29
+
30
+
31
+ def test_the_failure_names_the_remedy(monkeypatch, tmp_path, capsys):
32
+ monkeypatch.setattr(cli, "_data_dir", lambda: tmp_path / "not-here")
33
+ with pytest.raises(SystemExit):
34
+ cli._seed_blobs()
35
+ err = capsys.readouterr().err
36
+ assert "starter.mpack" in err and "reinstall" in err.lower()
37
+
38
+
39
+ def test_a_broken_install_leaves_no_vault_behind(monkeypatch, tmp_path):
40
+ """The check runs before the passphrase prompt and before any write, so a
41
+ failed init cannot leave a half-made vault that `init` then refuses to
42
+ overwrite."""
43
+ monkeypatch.setattr(cli, "_data_dir", lambda: tmp_path / "not-here")
44
+ path = tmp_path / "v.vault"
45
+ args = _init_args(str(path))
46
+ with pytest.raises(SystemExit):
47
+ cli.cmd_init(args)
48
+ assert not path.exists()
49
+
50
+
51
+ def test_the_shipped_install_finds_its_seed():
52
+ blobs = cli._seed_blobs()
53
+ assert [n for n, _ in blobs] == ["starter"]
54
+ assert len(blobs[0][1]) > 1_000_000
55
+
56
+
57
+ def _init_args(path):
58
+ class A:
59
+ vault = None
60
+ passphrase = PASS
61
+ creator = "test"
62
+ keychain = False
63
+ no_app = True
64
+ keyfile = None
65
+ a = A()
66
+ a.vault = path
67
+ return a
68
+
69
+
70
+ # ------------------------------------------------------------- the reporting
71
+
72
+ @pytest.fixture(scope="module")
73
+ def _own_seeded(tmp_path_factory):
74
+ """This module's own vault. The shared session fixture is written to by
75
+ other tests, and these assertions are about exactly which records come
76
+ back."""
77
+ p = str(tmp_path_factory.mktemp("starter-vis") / "v.vault")
78
+ v = Vault.create(p, PASS, creator="test")
79
+ packs.seed_records(v, (DATA / "starter.mpack").read_bytes(), caller="test")
80
+ return v
81
+
82
+
83
+ @pytest.fixture()
84
+ def sv(_own_seeded):
85
+ """Settings are per-vault and this one is reused, so put the toggle back."""
86
+ _own_seeded.config.settings["search_starter_facts"] = True
87
+ yield _own_seeded
88
+ _own_seeded.config.settings["search_starter_facts"] = True
89
+
90
+
91
+ def test_the_recent_feed_hides_seeded_memories(sv):
92
+ """Unchanged behaviour, stated so the fix below cannot be read as a
93
+ licence to bury real memories under thousands of starting ones."""
94
+ out = sv.recent(caller="test", limit=5)
95
+ assert not any(r["seeded"] for r in out["results"])
96
+ assert out["counts"]["seeded"] == 6665
97
+
98
+
99
+ def test_the_panel_says_the_starting_memories_are_there():
100
+ note = menubar.starter_note({"records": 6665, "organic": 0})
101
+ assert "6,665" in note and "searchable" in note
102
+ assert "nothing stored yet" != note
103
+
104
+
105
+ def test_the_panel_still_says_empty_when_it_really_is_empty():
106
+ assert menubar.starter_note({"records": 0, "organic": 0}) == \
107
+ "nothing stored yet"
108
+
109
+
110
+ def test_the_panel_counts_only_the_seeded_ones():
111
+ note = menubar.starter_note({"records": 6670, "organic": 5})
112
+ assert "6,665" in note
113
+
114
+
115
+ def test_the_tray_and_the_menu_bar_say_the_same_thing():
116
+ from compartment import systray
117
+ state = {"vault": "/v", "exists": True, "locked": False,
118
+ "records": 6665, "organic": 0, "recent": [], "error": None,
119
+ "settings": {"capture_hook": False, "search_starter_facts": True,
120
+ "auto_lock_minutes": 30}}
121
+ rows = systray.panel_rows(state)
122
+ empty = [text for kind, text in rows if kind == "empty"]
123
+ assert empty and "6,665" in empty[0]
124
+
125
+
126
+ # ---------------------------------------------------------------- the toggle
127
+
128
+ def test_search_returns_starting_memories_by_default(sv):
129
+ hits = sv.search("capital of France", caller="test", top_k=3)
130
+ assert any("Paris" in h["text"] for h in hits["results"])
131
+
132
+
133
+ def test_turning_starter_facts_off_removes_them_from_search(sv):
134
+ on = sv.search("capital of France", caller="test", top_k=5)["results"]
135
+ assert any("Paris" in h["text"] for h in on) # the toggle is what changed
136
+ sv.config.settings["search_starter_facts"] = False
137
+ off = sv.search("capital of France", caller="test", top_k=5)["results"]
138
+ assert not any("Paris" in h["text"] for h in off)
139
+ assert not any(is_seeded(json.dumps(h["tags"])) for h in off)
140
+
141
+
142
+ def test_turning_starter_facts_off_keeps_what_the_agent_stored(sv):
143
+ sv.store("Max prefers the master-detail layout", caller="test")
144
+ sv.config.settings["search_starter_facts"] = False
145
+ hits = sv.search("master-detail layout preference", caller="test", top_k=5)
146
+ texts = [h["text"] for h in hits["results"]]
147
+ assert "Max prefers the master-detail layout" in texts
148
+ assert not any(is_seeded(json.dumps(h["tags"])) for h in hits["results"])
149
+
150
+
151
+ def test_the_toggle_writes_the_setting_it_claims_to(tmp_path):
152
+ """It used to write include_packs_in_search, which stopped meaning
153
+ anything when the starting memories moved into "main"."""
154
+ path = str(tmp_path / "v.vault")
155
+ Vault.create(path, PASS, creator="t").lock()
156
+ menubar.set_setting(path, "search_starter_facts", False)
157
+ cfg = VaultConfig.load(path)
158
+ assert cfg.settings["search_starter_facts"] is False
159
+ assert cfg.settings.get("include_packs_in_search", True) is True
160
+
161
+
162
+ def test_the_toggle_reads_back_what_it_wrote(tmp_path):
163
+ path = str(tmp_path / "v.vault")
164
+ Vault.create(path, PASS, creator="t").lock()
165
+ menubar.set_setting(path, "search_starter_facts", False)
166
+ assert menubar.read_settings(path)["search_starter_facts"] is False
167
+ menubar.set_setting(path, "search_starter_facts", True)
168
+ assert menubar.read_settings(path)["search_starter_facts"] is True
169
+
170
+
171
+ def test_installed_packs_keep_their_own_switch(tmp_path):
172
+ """Two different things, two different settings: the starter toggle must
173
+ not silently change what installed packs do."""
174
+ path = str(tmp_path / "v.vault")
175
+ Vault.create(path, PASS, creator="t").lock()
176
+ cfg = VaultConfig.load(path)
177
+ cfg.settings["include_packs_in_search"] = False
178
+ cfg.save(path)
179
+ menubar.set_setting(path, "search_starter_facts", True)
180
+ assert VaultConfig.load(path).settings["include_packs_in_search"] is False
181
+
182
+
183
+ # ------------------------------------------------------------------ the mark
184
+
185
+ def test_seeded_records_are_marked_by_their_id_tag(vault):
186
+ packs.seed_records(vault, (DATA / "starter.mpack").read_bytes(), caller="t")
187
+ row = vault.db.conn.execute(
188
+ "SELECT tags FROM records LIMIT 1").fetchone()
189
+ assert is_seeded(row["tags"])
190
+
191
+
192
+ def test_what_the_agent_stores_is_not_marked_seeded(vault):
193
+ vault.store("something learned during use", caller="t")
194
+ row = vault.db.conn.execute(
195
+ "SELECT tags FROM records ORDER BY ikey DESC LIMIT 1").fetchone()
196
+ assert not is_seeded(row["tags"])
197
+ assert json.loads(row["tags"]) == []
File without changes
File without changes
File without changes
File without changes