compartment 3.0__tar.gz → 3.2__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 (72) hide show
  1. {compartment-3.0 → compartment-3.2}/PKG-INFO +13 -12
  2. {compartment-3.0 → compartment-3.2}/README.md +9 -8
  3. {compartment-3.0 → compartment-3.2}/pyproject.toml +17 -11
  4. {compartment-3.0 → compartment-3.2}/src/compartment/__init__.py +1 -1
  5. {compartment-3.0 → compartment-3.2}/src/compartment/cli.py +211 -0
  6. {compartment-3.0 → compartment-3.2}/src/compartment/menubar.py +87 -3
  7. {compartment-3.0 → compartment-3.2}/src/compartment/systray.py +35 -2
  8. {compartment-3.0 → compartment-3.2}/src/compartment.egg-info/PKG-INFO +13 -12
  9. {compartment-3.0 → compartment-3.2}/src/compartment.egg-info/SOURCES.txt +1 -0
  10. {compartment-3.0 → compartment-3.2}/src/compartment.egg-info/requires.txt +7 -7
  11. {compartment-3.0 → compartment-3.2}/tests/test_offline_selftest_bench.py +8 -1
  12. {compartment-3.0 → compartment-3.2}/tests/test_session_entropy.py +5 -0
  13. compartment-3.2/tests/test_status_bar_install.py +160 -0
  14. {compartment-3.0 → compartment-3.2}/setup.cfg +0 -0
  15. {compartment-3.0 → compartment-3.2}/src/compartment/acl.py +0 -0
  16. {compartment-3.0 → compartment-3.2}/src/compartment/audit.py +0 -0
  17. {compartment-3.0 → compartment-3.2}/src/compartment/bench.py +0 -0
  18. {compartment-3.0 → compartment-3.2}/src/compartment/claude_hooks.py +0 -0
  19. {compartment-3.0 → compartment-3.2}/src/compartment/claude_memory.py +0 -0
  20. {compartment-3.0 → compartment-3.2}/src/compartment/crypto.py +0 -0
  21. {compartment-3.0 → compartment-3.2}/src/compartment/dash.py +0 -0
  22. {compartment-3.0 → compartment-3.2}/src/compartment/data/hermes-plugin/__init__.py +0 -0
  23. {compartment-3.0 → compartment-3.2}/src/compartment/data/hermes-plugin/plugin.yaml +0 -0
  24. {compartment-3.0 → compartment-3.2}/src/compartment/data/menubar.png +0 -0
  25. {compartment-3.0 → compartment-3.2}/src/compartment/data/menubar@2x.png +0 -0
  26. {compartment-3.0 → compartment-3.2}/src/compartment/data/starter.mpack +0 -0
  27. {compartment-3.0 → compartment-3.2}/src/compartment/data/tray.ico +0 -0
  28. {compartment-3.0 → compartment-3.2}/src/compartment/embed.py +0 -0
  29. {compartment-3.0 → compartment-3.2}/src/compartment/home.py +0 -0
  30. {compartment-3.0 → compartment-3.2}/src/compartment/longmemeval.py +0 -0
  31. {compartment-3.0 → compartment-3.2}/src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
  32. {compartment-3.0 → compartment-3.2}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json +0 -0
  33. {compartment-3.0 → compartment-3.2}/src/compartment/offline_guard.py +0 -0
  34. {compartment-3.0 → compartment-3.2}/src/compartment/packs.py +0 -0
  35. {compartment-3.0 → compartment-3.2}/src/compartment/platforms.py +0 -0
  36. {compartment-3.0 → compartment-3.2}/src/compartment/salience.py +0 -0
  37. {compartment-3.0 → compartment-3.2}/src/compartment/selftest.py +0 -0
  38. {compartment-3.0 → compartment-3.2}/src/compartment/server.py +0 -0
  39. {compartment-3.0 → compartment-3.2}/src/compartment/session.py +0 -0
  40. {compartment-3.0 → compartment-3.2}/src/compartment/store.py +0 -0
  41. {compartment-3.0 → compartment-3.2}/src/compartment/vault.py +0 -0
  42. {compartment-3.0 → compartment-3.2}/src/compartment/vaultfile.py +0 -0
  43. {compartment-3.0 → compartment-3.2}/src/compartment/vindex.py +0 -0
  44. {compartment-3.0 → compartment-3.2}/src/compartment/wire.py +0 -0
  45. {compartment-3.0 → compartment-3.2}/src/compartment.egg-info/dependency_links.txt +0 -0
  46. {compartment-3.0 → compartment-3.2}/src/compartment.egg-info/entry_points.txt +0 -0
  47. {compartment-3.0 → compartment-3.2}/src/compartment.egg-info/top_level.txt +0 -0
  48. {compartment-3.0 → compartment-3.2}/tests/test_acl_audit_packs.py +0 -0
  49. {compartment-3.0 → compartment-3.2}/tests/test_acl_precedence.py +0 -0
  50. {compartment-3.0 → compartment-3.2}/tests/test_attempt_pacing.py +0 -0
  51. {compartment-3.0 → compartment-3.2}/tests/test_audit_chain.py +0 -0
  52. {compartment-3.0 → compartment-3.2}/tests/test_claude_hooks_and_integrate.py +0 -0
  53. {compartment-3.0 → compartment-3.2}/tests/test_claude_memory_and_recent.py +0 -0
  54. {compartment-3.0 → compartment-3.2}/tests/test_crypto.py +0 -0
  55. {compartment-3.0 → compartment-3.2}/tests/test_dash.py +0 -0
  56. {compartment-3.0 → compartment-3.2}/tests/test_instructions.py +0 -0
  57. {compartment-3.0 → compartment-3.2}/tests/test_journal_framing.py +0 -0
  58. {compartment-3.0 → compartment-3.2}/tests/test_macos_bundle.py +0 -0
  59. {compartment-3.0 → compartment-3.2}/tests/test_menubar.py +0 -0
  60. {compartment-3.0 → compartment-3.2}/tests/test_offline_guard_paths.py +0 -0
  61. {compartment-3.0 → compartment-3.2}/tests/test_ondisk_format.py +0 -0
  62. {compartment-3.0 → compartment-3.2}/tests/test_pack_trust.py +0 -0
  63. {compartment-3.0 → compartment-3.2}/tests/test_platforms.py +0 -0
  64. {compartment-3.0 → compartment-3.2}/tests/test_relations.py +0 -0
  65. {compartment-3.0 → compartment-3.2}/tests/test_salience.py +0 -0
  66. {compartment-3.0 → compartment-3.2}/tests/test_session_lock.py +0 -0
  67. {compartment-3.0 → compartment-3.2}/tests/test_starter_data_quality.py +0 -0
  68. {compartment-3.0 → compartment-3.2}/tests/test_starter_packs.py +0 -0
  69. {compartment-3.0 → compartment-3.2}/tests/test_systray.py +0 -0
  70. {compartment-3.0 → compartment-3.2}/tests/test_tamper.py +0 -0
  71. {compartment-3.0 → compartment-3.2}/tests/test_twofa.py +0 -0
  72. {compartment-3.0 → compartment-3.2}/tests/test_vault_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 3.0
3
+ Version: 3.2
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
@@ -33,13 +33,13 @@ Requires-Dist: onnxruntime>=1.17
33
33
  Requires-Dist: tokenizers>=0.15
34
34
  Requires-Dist: numpy>=1.26
35
35
  Requires-Dist: mcp>=1.0
36
+ Requires-Dist: pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin"
37
+ Requires-Dist: pystray>=0.19; sys_platform == "win32"
38
+ Requires-Dist: pillow>=10; sys_platform == "win32"
36
39
  Provides-Extra: hnsw
37
40
  Requires-Dist: usearch>=2.9; extra == "hnsw"
38
41
  Provides-Extra: menubar
39
- Requires-Dist: pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin" and extra == "menubar"
40
42
  Provides-Extra: tray
41
- Requires-Dist: pystray>=0.19; sys_platform == "win32" and extra == "tray"
42
- Requires-Dist: pillow>=10; sys_platform == "win32" and extra == "tray"
43
43
  Provides-Extra: dev
44
44
  Requires-Dist: pytest>=8; extra == "dev"
45
45
  Requires-Dist: usearch>=2.9; extra == "dev"
@@ -203,14 +203,15 @@ never break your editor - and it stays quiet when the vault is locked.
203
203
  alt="The Compartment panel in the Windows notification area.">
204
204
  </p>
205
205
 
206
- **A menu bar and system tray app.** Download **Compartment.pkg** from the
207
- [latest release](https://github.com/MaxFreedomPollard/Compartment/releases/latest)
208
- and open it - the installer asks whether you also want the menu bar utility,
209
- and everything (Python included) is self-contained, so there is nothing to
210
- install first. From a checkout, `pip install 'compartment[menubar]'`
211
- then `compartment menubar` does the same thing; on Windows,
212
- `pip install 'compartment[tray]'` then `compartment tray`. Either way it puts
213
- Compartment in the status bar: click the icon and a
206
+ **A menu bar and system tray app, from the same one install.** `pip install
207
+ compartment && compartment init` sets it up on macOS and on Windows: the app
208
+ starts straight away and comes back at every login. There is no separate
209
+ package, no extra to remember and no second command. On the Mac you can
210
+ instead open **Compartment.pkg** from the
211
+ [latest release](https://github.com/MaxFreedomPollard/Compartment/releases/latest),
212
+ which carries Python and every dependency inside it, so there is nothing to
213
+ install first. `compartment init --no-app` skips the app for headless boxes
214
+ and CI. Either way it puts Compartment in the status bar: click the icon and a
214
215
  panel shows whether the vault is open, how much it has learned, the three
215
216
  settings worth changing day to day (capture hook, whether starter facts join
216
217
  searches, auto-lock), and the last five things it remembered. Unlock, lock and
@@ -157,14 +157,15 @@ never break your editor - and it stays quiet when the vault is locked.
157
157
  alt="The Compartment panel in the Windows notification area.">
158
158
  </p>
159
159
 
160
- **A menu bar and system tray app.** Download **Compartment.pkg** from the
161
- [latest release](https://github.com/MaxFreedomPollard/Compartment/releases/latest)
162
- and open it - the installer asks whether you also want the menu bar utility,
163
- and everything (Python included) is self-contained, so there is nothing to
164
- install first. From a checkout, `pip install 'compartment[menubar]'`
165
- then `compartment menubar` does the same thing; on Windows,
166
- `pip install 'compartment[tray]'` then `compartment tray`. Either way it puts
167
- Compartment in the status bar: click the icon and a
160
+ **A menu bar and system tray app, from the same one install.** `pip install
161
+ compartment && compartment init` sets it up on macOS and on Windows: the app
162
+ starts straight away and comes back at every login. There is no separate
163
+ package, no extra to remember and no second command. On the Mac you can
164
+ instead open **Compartment.pkg** from the
165
+ [latest release](https://github.com/MaxFreedomPollard/Compartment/releases/latest),
166
+ which carries Python and every dependency inside it, so there is nothing to
167
+ install first. `compartment init --no-app` skips the app for headless boxes
168
+ and CI. Either way it puts Compartment in the status bar: click the icon and a
168
169
  panel shows whether the vault is open, how much it has learned, the three
169
170
  settings worth changing day to day (capture hook, whether starter facts join
170
171
  searches, auto-lock), and the last five things it remembered. Unlock, lock and
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compartment"
7
- version = "3.0"
7
+ version = "3.2"
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"
@@ -36,6 +36,16 @@ dependencies = [
36
36
  "tokenizers>=0.15",
37
37
  "numpy>=1.26",
38
38
  "mcp>=1.0",
39
+ # The status bar app ships with every install, on the platforms that have
40
+ # one. It is not an add-on: unlocking, locking and changing the passphrase
41
+ # are things people do through the icon, not through a terminal, so an
42
+ # install that only lays down a CLI is an incomplete install. The markers
43
+ # keep it honest - nothing macOS-only is fetched on Windows, nothing
44
+ # Windows-only on macOS, and Linux (which has no status bar app here)
45
+ # installs neither and compiles nothing.
46
+ "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'",
47
+ "pystray>=0.19; sys_platform == 'win32'",
48
+ "pillow>=10; sys_platform == 'win32'",
39
49
  ]
40
50
 
41
51
  # PyPI is the repo's declared homepage, so it is a real front door - and
@@ -64,16 +74,12 @@ engram = "compartment.cli:main"
64
74
  # `pip install` compiles nothing and installs cleanly everywhere; large
65
75
  # corpora add it with `pip install compartment[hnsw]`.
66
76
  hnsw = ["usearch>=2.9"]
67
- # The macOS menu bar app. PyObjC is macOS-only and irrelevant to the CLI and
68
- # MCP server, so it stays out of the default install: `pip install
69
- # compartment[menubar]`, then `compartment menubar`.
70
- menubar = ["pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'"]
71
- # The Windows tray app. tkinter draws the panel and comes with Python itself;
72
- # pystray owns the notification-area icon and is pure Python, so a Windows
73
- # install still compiles nothing: `pip install compartment[tray]`, then
74
- # `compartment tray`.
75
- tray = ["pystray>=0.19; sys_platform == 'win32'",
76
- "pillow>=10; sys_platform == 'win32'"]
77
+ # The status bar app is part of the base install now, so these two are kept
78
+ # only so that an existing `pip install compartment[menubar]` or
79
+ # `compartment[tray]` in someone's notes or script keeps resolving instead of
80
+ # failing on an unknown extra. They add nothing.
81
+ menubar = []
82
+ tray = []
77
83
  dev = ["pytest>=8", "usearch>=2.9"]
78
84
 
79
85
  [tool.setuptools.packages.find]
@@ -1,6 +1,6 @@
1
1
  """Compartment - high-security, fully offline, encrypted vector memory for AI agents."""
2
2
 
3
- __version__ = "3.0"
3
+ __version__ = "3.2"
4
4
 
5
5
  from . import offline_guard as _og
6
6
 
@@ -11,7 +11,10 @@ import getpass
11
11
  import hashlib
12
12
  import json
13
13
  import os
14
+ import shutil
15
+ import subprocess
14
16
  import sys
17
+ import time
15
18
  import zipfile
16
19
  from pathlib import Path
17
20
 
@@ -151,10 +154,201 @@ def cmd_init(args) -> None:
151
154
  "`compartment lock`")
152
155
  st = v.status()
153
156
  v.save()
157
+ if args.no_app:
158
+ print(" command-line only install (--no-app): no status bar app")
159
+ elif _cli_only_requested():
160
+ print(" command-line only install: no status bar app, no login item")
161
+ print(" add it later with `compartment menubar --login on`")
162
+ else:
163
+ _start_status_bar_app(path)
154
164
  print(f"\nVault ready: {st['records']} records, projected RAM "
155
165
  f"~{st['projected_ram_mb']}MB. Run `compartment selftest` to verify.")
156
166
 
157
167
 
168
+ def _cli_only_requested(seconds: float = 5.0) -> bool:
169
+ """Offer a way out of the GUI, without making anyone wait for it.
170
+
171
+ A window, not a question: the install carries on by itself when the time
172
+ is up, so someone who is not looking at the terminal loses nothing. Only
173
+ a real keyboard gets asked - a piped or redirected stdin (installer
174
+ script, CI, `yes | ...`) skips this instantly rather than blocking for
175
+ five seconds on input that is never coming.
176
+ """
177
+ try:
178
+ if not sys.stdin.isatty():
179
+ return False
180
+ except Exception: # noqa: BLE001
181
+ return False
182
+ print('\nThis is a normal install. Press the letter "s" within 5 seconds '
183
+ 'if you want to do a command-line only install.')
184
+ try:
185
+ if sys.platform == "win32":
186
+ import msvcrt
187
+ deadline = time.monotonic() + seconds
188
+ while time.monotonic() < deadline:
189
+ if msvcrt.kbhit():
190
+ return msvcrt.getwch().lower() == "s"
191
+ time.sleep(0.05)
192
+ return False
193
+ import select
194
+ import termios
195
+ import tty
196
+ fd = sys.stdin.fileno()
197
+ saved = termios.tcgetattr(fd)
198
+ try:
199
+ tty.setcbreak(fd) # single keypress, no Enter needed
200
+ ready, _, _ = select.select([sys.stdin], [], [], seconds)
201
+ if not ready:
202
+ return False
203
+ return sys.stdin.read(1).lower() == "s"
204
+ finally:
205
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
206
+ except Exception: # noqa: BLE001
207
+ return False # never let this block an install
208
+
209
+
210
+ def _start_status_bar_app(vault: str) -> None:
211
+ """Put the status bar app up now, and again at every login.
212
+
213
+ This runs as part of `init` because the icon is the product for most
214
+ people: unlocking, locking and changing the passphrase all live there.
215
+ An install that leaves you with only a terminal command is an install
216
+ that has not finished. Never fatal - a headless box, an SSH session or a
217
+ platform with no status bar still gets a perfectly good CLI and MCP
218
+ server, so a failure here is reported and stepped over.
219
+ """
220
+ if sys.platform not in ("darwin", "win32"):
221
+ return # no status bar app here
222
+ try:
223
+ app = _tray_app()
224
+ except Exception as exc: # noqa: BLE001
225
+ print(f" status bar app unavailable ({exc}); the CLI is unaffected")
226
+ return
227
+ try:
228
+ state = app.set_login(True)
229
+ print(f" start at login: {state}")
230
+ except Exception as exc: # noqa: BLE001
231
+ print(f" could not enable start at login ({exc})")
232
+ try:
233
+ exe = shutil.which("compartment")
234
+ argv = ([exe, "--vault", vault, "menubar"] if exe else
235
+ [sys.executable, "-m", "compartment.cli", "--vault", vault,
236
+ "menubar"])
237
+ kwargs = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
238
+ if sys.platform == "win32":
239
+ # no console window behind an app that has no window
240
+ kwargs["creationflags"] = 0x00000008 # DETACHED_PROCESS
241
+ else:
242
+ kwargs["start_new_session"] = True # outlive this shell
243
+ subprocess.Popen(argv, **kwargs)
244
+ print(" status bar app started - look for the icon in your "
245
+ + ("menu bar" if sys.platform == "darwin"
246
+ else "notification area"))
247
+ except Exception as exc: # noqa: BLE001
248
+ print(f" could not start the status bar app ({exc}); run "
249
+ "`compartment menubar` yourself")
250
+
251
+
252
+ def _install_kind() -> tuple[str, list[str]]:
253
+ """How this copy was installed, and the command that upgrades it."""
254
+ exe = Path(sys.argv[0]).resolve()
255
+ if "uv/tools" in str(exe) or shutil.which("uv") and "uv/tools" in str(
256
+ Path(shutil.which("compartment") or "").resolve()):
257
+ return "uv", ["uv", "tool", "upgrade", "compartment"]
258
+ return "pip", [sys.executable, "-m", "pip", "install", "--upgrade",
259
+ "compartment"]
260
+
261
+
262
+ def cmd_update(args) -> None:
263
+ """Upgrade Compartment in place, then put the app back up."""
264
+ kind, cmd = _install_kind()
265
+ if args.source:
266
+ target = "git+https://github.com/MaxFreedomPollard/Compartment@main"
267
+ cmd = (["uv", "tool", "install", "--force", target] if kind == "uv"
268
+ else [sys.executable, "-m", "pip", "install", "--upgrade",
269
+ "--force-reinstall", target])
270
+ print(f"Compartment {__version__} - updating ({kind} install)")
271
+ print(" " + " ".join(cmd))
272
+ try:
273
+ res = subprocess.run(cmd, timeout=1800)
274
+ except (OSError, subprocess.SubprocessError) as exc:
275
+ _die(f"update failed to run: {exc}")
276
+ if res.returncode != 0:
277
+ _die(f"update failed (exit {res.returncode})")
278
+ ver = "?"
279
+ try:
280
+ out = subprocess.run([shutil.which("compartment") or "compartment",
281
+ "--version"], capture_output=True, text=True,
282
+ timeout=120)
283
+ ver = (out.stdout or "").strip() or "?"
284
+ except (OSError, subprocess.SubprocessError):
285
+ pass
286
+ print(f" updated: now {ver}")
287
+ print(" your vault and settings are untouched")
288
+ if not args.no_app and sys.platform in ("darwin", "win32"):
289
+ _restart_status_bar_app(args.vault)
290
+
291
+
292
+ def _restart_status_bar_app(vault: str) -> None:
293
+ """Stop the running status bar app and start the new build."""
294
+ try:
295
+ app = _tray_app()
296
+ app.quit_running()
297
+ except Exception: # noqa: BLE001
298
+ pass
299
+ _start_status_bar_app(vault)
300
+
301
+
302
+ def cmd_uninstall(args) -> None:
303
+ """Remove Compartment from this machine. The vault is KEPT unless asked."""
304
+ print(f"Compartment {__version__} - uninstalling")
305
+ if sys.platform in ("darwin", "win32"):
306
+ try:
307
+ app = _tray_app()
308
+ try:
309
+ app.quit_running()
310
+ print(" status bar app stopped")
311
+ except Exception: # noqa: BLE001
312
+ pass
313
+ print(f" start at login: {app.set_login(False)}")
314
+ except Exception as exc: # noqa: BLE001
315
+ print(f" status bar app: nothing to remove ({exc})")
316
+ try:
317
+ if claude_hooks.is_installed():
318
+ claude_hooks.uninstall()
319
+ print(" Claude Code capture hook removed")
320
+ except Exception as exc: # noqa: BLE001
321
+ print(f" capture hook: {exc}")
322
+ from .home import LEGACY_NAME, NAME
323
+ for name in (NAME, LEGACY_NAME):
324
+ try:
325
+ r = subprocess.run(["claude", "mcp", "remove", name],
326
+ capture_output=True, text=True, timeout=120)
327
+ if r.returncode == 0:
328
+ print(f" MCP registration removed: {name}")
329
+ except (OSError, subprocess.SubprocessError):
330
+ pass
331
+ if args.purge:
332
+ # only on an explicit --purge: this is the only copy of everything
333
+ # the agent has ever learned, and it is not recoverable.
334
+ for f in (args.vault, args.vault + ".config.json",
335
+ args.vault + ".flock"):
336
+ try:
337
+ os.remove(f)
338
+ print(f" deleted {f}")
339
+ except OSError:
340
+ pass
341
+ print(" vault deleted (--purge)")
342
+ else:
343
+ print(f" vault KEPT at {args.vault}")
344
+ print(" delete it yourself, or re-run with --purge, once you are sure")
345
+ kind, _ = _install_kind()
346
+ final = ("uv tool uninstall compartment" if kind == "uv"
347
+ else f"{sys.executable} -m pip uninstall compartment")
348
+ print("\nOne step left, which cannot remove itself while it is running:")
349
+ print(f" {final}")
350
+
351
+
158
352
  def _ask_yn(q: str) -> bool:
159
353
  if not sys.stdin.isatty():
160
354
  return False
@@ -1047,6 +1241,9 @@ def main(argv: list[str] | None = None) -> None:
1047
1241
  help="store a reboot-surviving Keychain credential (macOS)")
1048
1242
  p.add_argument("--no-session", action="store_true",
1049
1243
  help="do not stay unlocked after init")
1244
+ p.add_argument("--no-app", action="store_true",
1245
+ help="do not start the status bar app or enable it at "
1246
+ "login (headless installs, CI)")
1050
1247
  p.set_defaults(fn=cmd_init)
1051
1248
 
1052
1249
  p = sub.add_parser(
@@ -1279,6 +1476,20 @@ def main(argv: list[str] | None = None) -> None:
1279
1476
  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")
1280
1477
  p.set_defaults(fn=cmd_pack_export)
1281
1478
 
1479
+ p = sub.add_parser("update", help="upgrade Compartment in place")
1480
+ p.add_argument("--source", action="store_true",
1481
+ help="update from the GitHub main branch instead of PyPI")
1482
+ p.add_argument("--no-app", action="store_true",
1483
+ help="do not restart the status bar app afterwards")
1484
+ p.set_defaults(fn=cmd_update)
1485
+
1486
+ p = sub.add_parser("uninstall",
1487
+ help="remove Compartment from this machine "
1488
+ "(the vault is kept unless --purge)")
1489
+ p.add_argument("--purge", action="store_true",
1490
+ help="ALSO delete the vault and its config, permanently")
1491
+ p.set_defaults(fn=cmd_uninstall)
1492
+
1282
1493
  p = sub.add_parser("integrate",
1283
1494
  help="one-command wiring into claude, hermes, or openclaw")
1284
1495
  p.add_argument("target", choices=["claude", "hermes", "openclaw"])
@@ -307,7 +307,13 @@ def _app_service():
307
307
  from Foundation import NSBundle
308
308
  except ImportError:
309
309
  return None
310
- if not (NSBundle.mainBundle().bundleIdentifier() or "").strip():
310
+ # It has to be OUR bundle, not merely SOME bundle. A framework Python
311
+ # reports bundleIdentifier "org.python.python" with a bundle path inside
312
+ # Python.framework, so a bare "is there an identifier" test passes on an
313
+ # ordinary pip install. SMAppService would then act on Python.app: status
314
+ # comes back "not found", and registering would put PYTHON in the user's
315
+ # login items instead of Compartment.
316
+ if (NSBundle.mainBundle().bundleIdentifier() or "") != BUNDLE_ID:
311
317
  return None
312
318
  try:
313
319
  objc.loadBundle(
@@ -318,10 +324,66 @@ def _app_service():
318
324
  return None
319
325
 
320
326
 
327
+ # A pip install has no app bundle, and SMAppService can only register one. So
328
+ # the bundle path is used when there is a bundle, and a LaunchAgent otherwise.
329
+ # Without this, `pip install compartment` could never start at login on macOS,
330
+ # which would make the status bar app a second-class citizen of the very
331
+ # install most people use.
332
+ # Must match tools/build_macos_app.py: the identity of the real app bundle.
333
+ BUNDLE_ID = "io.github.maxfreedompollard.compartment"
334
+ LAUNCH_AGENT_LABEL = f"{BUNDLE_ID}.menubar"
335
+
336
+
337
+ def _agent_plist() -> Path:
338
+ return Path.home() / "Library" / "LaunchAgents" / f"{LAUNCH_AGENT_LABEL}.plist"
339
+
340
+
341
+ def _launcher_argv() -> list[str]:
342
+ """How to start the status bar app again later, from wherever we live."""
343
+ exe = shutil.which("compartment")
344
+ if exe:
345
+ return [exe, "menubar"]
346
+ return [sys.executable, "-m", "compartment.cli", "menubar"]
347
+
348
+
349
+ def _set_login_agent(enabled: bool) -> str:
350
+ plist = _agent_plist()
351
+ if not enabled:
352
+ try:
353
+ subprocess.run(["launchctl", "unload", str(plist)],
354
+ capture_output=True, timeout=10)
355
+ except (OSError, subprocess.SubprocessError):
356
+ pass
357
+ plist.unlink(missing_ok=True)
358
+ return "off"
359
+ argv = _launcher_argv()
360
+ args = "".join(f" <string>{a}</string>\n" for a in argv)
361
+ plist.parent.mkdir(parents=True, exist_ok=True)
362
+ plist.write_text(
363
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
364
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
365
+ '"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
366
+ '<plist version="1.0">\n'
367
+ ' <dict>\n'
368
+ f' <key>Label</key><string>{LAUNCH_AGENT_LABEL}</string>\n'
369
+ ' <key>ProgramArguments</key>\n'
370
+ f' <array>\n{args} </array>\n'
371
+ ' <key>RunAtLoad</key><true/>\n'
372
+ ' <key>KeepAlive</key><false/>\n'
373
+ ' </dict>\n'
374
+ '</plist>\n', encoding="utf-8")
375
+ try:
376
+ subprocess.run(["launchctl", "load", str(plist)],
377
+ capture_output=True, timeout=10)
378
+ except (OSError, subprocess.SubprocessError) as exc:
379
+ return f"failed: {exc}"
380
+ return "on"
381
+
382
+
321
383
  def login_status() -> str:
322
384
  svc = _app_service()
323
385
  if svc is None:
324
- return "unavailable (not running from Compartment.app)"
386
+ return "on" if _agent_plist().is_file() else "off"
325
387
  return LOGIN_STATUS.get(svc.status(), str(svc.status()))
326
388
 
327
389
 
@@ -330,7 +392,7 @@ def set_login(enabled: bool) -> str:
330
392
  name with its icon instead of an anonymous "legacy agent" entry."""
331
393
  svc = _app_service()
332
394
  if svc is None:
333
- return "unavailable (not running from Compartment.app)"
395
+ return _set_login_agent(enabled) # pip install: no bundle to register
334
396
  try:
335
397
  res = (svc.registerAndReturnError_(None) if enabled
336
398
  else svc.unregisterAndReturnError_(None))
@@ -351,6 +413,28 @@ def set_login(enabled: bool) -> str:
351
413
  return login_status()
352
414
 
353
415
 
416
+ def quit_running() -> bool:
417
+ """Stop a running status bar app, so an update or uninstall does not leave
418
+ the old build sitting in the menu bar with its binary already gone."""
419
+ import signal
420
+ killed = False
421
+ try:
422
+ out = subprocess.run(["pgrep", "-f", "compartment.*menubar"],
423
+ capture_output=True, text=True, timeout=15).stdout
424
+ except (OSError, subprocess.SubprocessError):
425
+ return False
426
+ me = os.getpid()
427
+ for pid in [int(x) for x in out.split() if x.isdigit()]:
428
+ if pid == me:
429
+ continue
430
+ try:
431
+ os.kill(pid, signal.SIGTERM)
432
+ killed = True
433
+ except OSError:
434
+ pass
435
+ return killed
436
+
437
+
354
438
  # ----------------------------------------------------------------- self test
355
439
 
356
440
  def self_check(vault: str | None = None) -> int:
@@ -23,9 +23,12 @@ Design notes:
23
23
  from __future__ import annotations
24
24
 
25
25
  import os
26
+ import shutil
26
27
  import sys
27
28
  from pathlib import Path
28
29
 
30
+ from .home import env, home
31
+
29
32
  from .menubar import (AUTO_LOCK_CHOICES, RECENT_COUNT, auto_lock_label,
30
33
  change_passphrase, claim_first_run, default_vault,
31
34
  fetch_state, lock_vault, self_check, set_setting,
@@ -122,6 +125,24 @@ def _winreg():
122
125
  return winreg
123
126
 
124
127
 
128
+ def _autostart_command(vault: str | None = None) -> str:
129
+ """What Windows runs at sign-in.
130
+
131
+ Two things this has to get right. It launches through the console script
132
+ or pythonw.exe rather than python.exe, because python.exe opens a console
133
+ window behind a tray app that has no window, at every single sign-in. And
134
+ it carries the vault path, because a user running a non-default vault
135
+ would otherwise silently get the default one back after a reboot.
136
+ """
137
+ vault = vault or env("VAULT") or str(home() / "memory.vault")
138
+ exe = shutil.which("compartment")
139
+ if exe:
140
+ return f'"{exe}" --vault "{vault}" tray'
141
+ pyw = Path(sys.executable).with_name("pythonw.exe")
142
+ runner = str(pyw) if pyw.is_file() else sys.executable
143
+ return f'"{runner}" -m compartment.cli --vault "{vault}" tray'
144
+
145
+
125
146
  def login_status() -> str:
126
147
  try:
127
148
  winreg = _winreg()
@@ -141,7 +162,7 @@ def set_login(enabled: bool) -> str:
141
162
  with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as k:
142
163
  if enabled:
143
164
  winreg.SetValueEx(k, RUN_VALUE, 0, winreg.REG_SZ,
144
- f'"{sys.executable}" -m compartment.cli tray')
165
+ _autostart_command())
145
166
  return "on"
146
167
  try:
147
168
  winreg.DeleteValue(k, RUN_VALUE)
@@ -465,5 +486,17 @@ def run(vault: str | None = None, show: bool = False,
465
486
  return 0
466
487
 
467
488
 
468
- __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",
469
502
  "icon_path"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 3.0
3
+ Version: 3.2
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
@@ -33,13 +33,13 @@ Requires-Dist: onnxruntime>=1.17
33
33
  Requires-Dist: tokenizers>=0.15
34
34
  Requires-Dist: numpy>=1.26
35
35
  Requires-Dist: mcp>=1.0
36
+ Requires-Dist: pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin"
37
+ Requires-Dist: pystray>=0.19; sys_platform == "win32"
38
+ Requires-Dist: pillow>=10; sys_platform == "win32"
36
39
  Provides-Extra: hnsw
37
40
  Requires-Dist: usearch>=2.9; extra == "hnsw"
38
41
  Provides-Extra: menubar
39
- Requires-Dist: pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin" and extra == "menubar"
40
42
  Provides-Extra: tray
41
- Requires-Dist: pystray>=0.19; sys_platform == "win32" and extra == "tray"
42
- Requires-Dist: pillow>=10; sys_platform == "win32" and extra == "tray"
43
43
  Provides-Extra: dev
44
44
  Requires-Dist: pytest>=8; extra == "dev"
45
45
  Requires-Dist: usearch>=2.9; extra == "dev"
@@ -203,14 +203,15 @@ never break your editor - and it stays quiet when the vault is locked.
203
203
  alt="The Compartment panel in the Windows notification area.">
204
204
  </p>
205
205
 
206
- **A menu bar and system tray app.** Download **Compartment.pkg** from the
207
- [latest release](https://github.com/MaxFreedomPollard/Compartment/releases/latest)
208
- and open it - the installer asks whether you also want the menu bar utility,
209
- and everything (Python included) is self-contained, so there is nothing to
210
- install first. From a checkout, `pip install 'compartment[menubar]'`
211
- then `compartment menubar` does the same thing; on Windows,
212
- `pip install 'compartment[tray]'` then `compartment tray`. Either way it puts
213
- Compartment in the status bar: click the icon and a
206
+ **A menu bar and system tray app, from the same one install.** `pip install
207
+ compartment && compartment init` sets it up on macOS and on Windows: the app
208
+ starts straight away and comes back at every login. There is no separate
209
+ package, no extra to remember and no second command. On the Mac you can
210
+ instead open **Compartment.pkg** from the
211
+ [latest release](https://github.com/MaxFreedomPollard/Compartment/releases/latest),
212
+ which carries Python and every dependency inside it, so there is nothing to
213
+ install first. `compartment init --no-app` skips the app for headless boxes
214
+ and CI. Either way it puts Compartment in the status bar: click the icon and a
214
215
  panel shows whether the vault is open, how much it has learned, the three
215
216
  settings worth changing day to day (capture hook, whether starter facts join
216
217
  searches, auto-lock), and the last five things it remembered. Unlock, lock and
@@ -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_status_bar_install.py
66
67
  tests/test_systray.py
67
68
  tests/test_tamper.py
68
69
  tests/test_twofa.py
@@ -5,6 +5,13 @@ tokenizers>=0.15
5
5
  numpy>=1.26
6
6
  mcp>=1.0
7
7
 
8
+ [:sys_platform == "darwin"]
9
+ pyobjc-framework-Cocoa>=10.0
10
+
11
+ [:sys_platform == "win32"]
12
+ pystray>=0.19
13
+ pillow>=10
14
+
8
15
  [dev]
9
16
  pytest>=8
10
17
  usearch>=2.9
@@ -14,11 +21,4 @@ usearch>=2.9
14
21
 
15
22
  [menubar]
16
23
 
17
- [menubar:sys_platform == "darwin"]
18
- pyobjc-framework-Cocoa>=10.0
19
-
20
24
  [tray]
21
-
22
- [tray:sys_platform == "win32"]
23
- pystray>=0.19
24
- pillow>=10
@@ -78,7 +78,14 @@ def test_index_add_remove():
78
78
  def test_bench_budgets(seeded_vault):
79
79
  out = bench.run(seeded_vault, synthetic_n=5000, queries=20)
80
80
  assert out["budgets"]["vector_search_p95_under_100ms"], out
81
- assert out["budgets"]["rss_under_1gb"], out
81
+ # None means the platform cannot measure RSS at all (no `resource`
82
+ # module, ie. Windows). It must never read as a pass, and it must not be
83
+ # asserted as one either.
84
+ rss = out["budgets"]["rss_under_1gb"]
85
+ if out["peak_rss_mb"] is None:
86
+ assert rss is None, out
87
+ else:
88
+ assert rss, out
82
89
 
83
90
 
84
91
  # --------------------------------------------------------------- bench fixes
@@ -180,6 +180,9 @@ def test_clear_keeps_the_secret_while_another_vault_still_needs_it():
180
180
 
181
181
  # --- file permissions ------------------------------------------------------
182
182
 
183
+ @pytest.mark.skipif(os.name == "nt",
184
+ reason="POSIX mode bits: Windows reports 0o666 for any "
185
+ "writable file whatever mode os.open was given")
183
186
  def test_the_file_is_created_0600_under_any_umask():
184
187
  old = os.umask(0o000) # the friendliest possible umask
185
188
  try:
@@ -189,6 +192,8 @@ def test_the_file_is_created_0600_under_any_umask():
189
192
  assert oct(p.stat().st_mode & 0o777) == "0o600"
190
193
 
191
194
 
195
+ @pytest.mark.skipif(os.name == "nt",
196
+ reason="POSIX mode bits: see above")
192
197
  def test_an_existing_wider_file_is_narrowed_by_the_write():
193
198
  """os.open with a mode does nothing to a file that already exists, so the
194
199
  write has to narrow it as well as create it narrow."""
@@ -0,0 +1,160 @@
1
+ """The status bar app is part of a normal install, on macOS and on Windows.
2
+
3
+ Unlocking, locking and changing the passphrase are things people do through
4
+ the icon. An install that lays down only a CLI has not finished, and until
5
+ this it did not: the GUI dependencies were optional extras, `init` never
6
+ started the app, and on macOS start-at-login was impossible without the .app
7
+ bundle.
8
+ """
9
+ import sys
10
+ import tomllib
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from compartment import menubar
16
+
17
+ ROOT = Path(__file__).resolve().parents[1]
18
+
19
+
20
+ # --- one install, not two --------------------------------------------------
21
+
22
+ def _pyproject():
23
+ return tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
24
+
25
+
26
+ def test_the_gui_deps_ship_with_a_plain_install():
27
+ """`pip install compartment` has to be enough. No extra, no second step."""
28
+ deps = " ".join(_pyproject()["project"]["dependencies"])
29
+ assert "pyobjc-framework-Cocoa" in deps, "macOS status bar missing"
30
+ assert "pystray" in deps and "pillow" in deps, "Windows tray missing"
31
+
32
+
33
+ def test_the_gui_deps_are_platform_gated():
34
+ """Nothing macOS-only on Windows, nothing Windows-only on macOS."""
35
+ for dep in _pyproject()["project"]["dependencies"]:
36
+ if "pyobjc" in dep:
37
+ assert "sys_platform == 'darwin'" in dep
38
+ if "pystray" in dep or "pillow" in dep:
39
+ assert "sys_platform == 'win32'" in dep
40
+
41
+
42
+ def test_the_old_extras_still_resolve():
43
+ """Someone's script or notes may still say compartment[menubar]. That must
44
+ keep working rather than failing on an unknown extra."""
45
+ extras = _pyproject()["project"]["optional-dependencies"]
46
+ assert extras["menubar"] == [] and extras["tray"] == []
47
+
48
+
49
+ def test_one_command_covers_both_platforms():
50
+ from compartment import cli
51
+ assert 'aliases=["tray"]' in (ROOT / "src/compartment/cli.py").read_text(
52
+ encoding="utf-8"), "menubar/tray must stay one command"
53
+ assert hasattr(cli, "_start_status_bar_app")
54
+
55
+
56
+ # --- macOS start at login, without an app bundle ---------------------------
57
+
58
+ @pytest.fixture()
59
+ def fake_home(tmp_path, monkeypatch):
60
+ monkeypatch.setenv("HOME", str(tmp_path))
61
+ return tmp_path
62
+
63
+
64
+ @pytest.mark.skipif(sys.platform != "darwin", reason="macOS login item")
65
+ def test_a_pip_install_is_not_mistaken_for_the_app_bundle():
66
+ """The regression that would put PYTHON in the user's login items.
67
+
68
+ A framework Python reports bundleIdentifier "org.python.python" with a
69
+ bundle path inside Python.framework, so a bare "is there a bundle?" test
70
+ passes on an ordinary pip install and SMAppService then acts on Python.app.
71
+ """
72
+ assert menubar._app_service() is None
73
+
74
+
75
+ @pytest.mark.skipif(sys.platform != "darwin", reason="macOS login item")
76
+ def test_start_at_login_round_trips_without_a_bundle(fake_home):
77
+ plist = menubar._agent_plist()
78
+ assert menubar.login_status() == "off"
79
+ assert menubar.set_login(True) == "on"
80
+ assert plist.is_file()
81
+ assert menubar.login_status() == "on"
82
+ assert menubar.set_login(False) == "off"
83
+ assert not plist.exists()
84
+ assert menubar.login_status() == "off"
85
+
86
+
87
+ @pytest.mark.skipif(sys.platform != "darwin", reason="macOS login item")
88
+ def test_the_login_agent_names_a_real_launcher(fake_home):
89
+ menubar.set_login(True)
90
+ body = menubar._agent_plist().read_text(encoding="utf-8")
91
+ assert menubar.LAUNCH_AGENT_LABEL in body
92
+ assert "<key>RunAtLoad</key><true/>" in body
93
+ assert "menubar" in body
94
+ menubar.set_login(False)
95
+
96
+
97
+ # --- Windows autostart -----------------------------------------------------
98
+
99
+ def test_windows_autostart_avoids_a_console_window_and_keeps_the_vault():
100
+ """python.exe opens a console window behind an app that has no window, at
101
+ every sign-in, and an autostart that drops --vault silently sends a user
102
+ with a non-default vault back to the default one."""
103
+ from compartment import systray
104
+ cmd = systray._autostart_command("/tmp/some.vault")
105
+ assert "/tmp/some.vault" in cmd, "the vault path must survive a reboot"
106
+ assert "python.exe" not in cmd.lower() or "pythonw.exe" in cmd.lower()
107
+
108
+
109
+ # --- opting out of the GUI at install time ---------------------------------
110
+
111
+ def test_a_scripted_install_never_waits_for_a_keypress(monkeypatch, capsys):
112
+ """The window must cost a non-interactive install nothing. A piped stdin
113
+ (installer script, CI, `yes | ...`) has no keyboard behind it, so waiting
114
+ five seconds for input that cannot arrive would be five seconds wasted on
115
+ every automated install."""
116
+ import time as _time
117
+ from compartment import cli
118
+
119
+ class NotATerminal:
120
+ def isatty(self):
121
+ return False
122
+
123
+ monkeypatch.setattr(cli.sys, "stdin", NotATerminal())
124
+ start = _time.monotonic()
125
+ assert cli._cli_only_requested(5.0) is False
126
+ assert _time.monotonic() - start < 0.5, "a scripted install must not stall"
127
+ assert "5 seconds" not in capsys.readouterr().out, "and must not be asked"
128
+
129
+
130
+ def test_the_offer_survives_a_terminal_that_refuses_raw_mode(monkeypatch):
131
+ """Some terminals refuse cbreak. The install continues normally rather
132
+ than dying at the very last step, after the vault already exists."""
133
+ from compartment import cli
134
+
135
+ class Terminal:
136
+ def isatty(self):
137
+ return True
138
+
139
+ def fileno(self):
140
+ raise OSError("no fileno here")
141
+
142
+ monkeypatch.setattr(cli.sys, "stdin", Terminal())
143
+ assert cli._cli_only_requested(0.2) is False
144
+
145
+
146
+ def test_the_prompt_says_what_max_asked_it_to_say(monkeypatch, capsys):
147
+ from compartment import cli
148
+
149
+ class Terminal:
150
+ def isatty(self):
151
+ return True
152
+
153
+ def fileno(self):
154
+ raise OSError("stop here, we only want the text")
155
+
156
+ monkeypatch.setattr(cli.sys, "stdin", Terminal())
157
+ cli._cli_only_requested(0.1)
158
+ out = capsys.readouterr().out
159
+ assert 'This is a normal install. Press the letter "s" within 5 seconds' in out
160
+ assert "command-line only install" in out
File without changes
File without changes
File without changes