compartment 2__tar.gz → 2.1__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 (62) hide show
  1. {compartment-2 → compartment-2.1}/PKG-INFO +24 -1
  2. {compartment-2 → compartment-2.1}/pyproject.toml +34 -1
  3. {compartment-2 → compartment-2.1}/src/compartment/__init__.py +1 -1
  4. {compartment-2 → compartment-2.1}/src/compartment/menubar.py +87 -37
  5. {compartment-2 → compartment-2.1}/src/compartment/systray.py +105 -44
  6. {compartment-2 → compartment-2.1}/src/compartment.egg-info/PKG-INFO +24 -1
  7. {compartment-2 → compartment-2.1}/tests/test_systray.py +30 -0
  8. {compartment-2 → compartment-2.1}/README.md +0 -0
  9. {compartment-2 → compartment-2.1}/setup.cfg +0 -0
  10. {compartment-2 → compartment-2.1}/src/compartment/acl.py +0 -0
  11. {compartment-2 → compartment-2.1}/src/compartment/audit.py +0 -0
  12. {compartment-2 → compartment-2.1}/src/compartment/bench.py +0 -0
  13. {compartment-2 → compartment-2.1}/src/compartment/claude_hooks.py +0 -0
  14. {compartment-2 → compartment-2.1}/src/compartment/claude_memory.py +0 -0
  15. {compartment-2 → compartment-2.1}/src/compartment/cli.py +0 -0
  16. {compartment-2 → compartment-2.1}/src/compartment/crypto.py +0 -0
  17. {compartment-2 → compartment-2.1}/src/compartment/dash.py +0 -0
  18. {compartment-2 → compartment-2.1}/src/compartment/data/hermes-plugin/__init__.py +0 -0
  19. {compartment-2 → compartment-2.1}/src/compartment/data/hermes-plugin/plugin.yaml +0 -0
  20. {compartment-2 → compartment-2.1}/src/compartment/data/menubar.png +0 -0
  21. {compartment-2 → compartment-2.1}/src/compartment/data/menubar@2x.png +0 -0
  22. {compartment-2 → compartment-2.1}/src/compartment/data/starter.mpack +0 -0
  23. {compartment-2 → compartment-2.1}/src/compartment/data/tray.ico +0 -0
  24. {compartment-2 → compartment-2.1}/src/compartment/embed.py +0 -0
  25. {compartment-2 → compartment-2.1}/src/compartment/home.py +0 -0
  26. {compartment-2 → compartment-2.1}/src/compartment/longmemeval.py +0 -0
  27. {compartment-2 → compartment-2.1}/src/compartment/models/bge-small-en-v1.5-int8/model_quantized.onnx +0 -0
  28. {compartment-2 → compartment-2.1}/src/compartment/models/bge-small-en-v1.5-int8/tokenizer.json +0 -0
  29. {compartment-2 → compartment-2.1}/src/compartment/offline_guard.py +0 -0
  30. {compartment-2 → compartment-2.1}/src/compartment/packs.py +0 -0
  31. {compartment-2 → compartment-2.1}/src/compartment/platforms.py +0 -0
  32. {compartment-2 → compartment-2.1}/src/compartment/salience.py +0 -0
  33. {compartment-2 → compartment-2.1}/src/compartment/selftest.py +0 -0
  34. {compartment-2 → compartment-2.1}/src/compartment/server.py +0 -0
  35. {compartment-2 → compartment-2.1}/src/compartment/session.py +0 -0
  36. {compartment-2 → compartment-2.1}/src/compartment/store.py +0 -0
  37. {compartment-2 → compartment-2.1}/src/compartment/vault.py +0 -0
  38. {compartment-2 → compartment-2.1}/src/compartment/vaultfile.py +0 -0
  39. {compartment-2 → compartment-2.1}/src/compartment/vindex.py +0 -0
  40. {compartment-2 → compartment-2.1}/src/compartment.egg-info/SOURCES.txt +0 -0
  41. {compartment-2 → compartment-2.1}/src/compartment.egg-info/dependency_links.txt +0 -0
  42. {compartment-2 → compartment-2.1}/src/compartment.egg-info/entry_points.txt +0 -0
  43. {compartment-2 → compartment-2.1}/src/compartment.egg-info/requires.txt +0 -0
  44. {compartment-2 → compartment-2.1}/src/compartment.egg-info/top_level.txt +0 -0
  45. {compartment-2 → compartment-2.1}/tests/test_acl_audit_packs.py +0 -0
  46. {compartment-2 → compartment-2.1}/tests/test_claude_hooks_and_integrate.py +0 -0
  47. {compartment-2 → compartment-2.1}/tests/test_claude_memory_and_recent.py +0 -0
  48. {compartment-2 → compartment-2.1}/tests/test_crypto.py +0 -0
  49. {compartment-2 → compartment-2.1}/tests/test_dash.py +0 -0
  50. {compartment-2 → compartment-2.1}/tests/test_instructions.py +0 -0
  51. {compartment-2 → compartment-2.1}/tests/test_macos_bundle.py +0 -0
  52. {compartment-2 → compartment-2.1}/tests/test_menubar.py +0 -0
  53. {compartment-2 → compartment-2.1}/tests/test_offline_selftest_bench.py +0 -0
  54. {compartment-2 → compartment-2.1}/tests/test_ondisk_format.py +0 -0
  55. {compartment-2 → compartment-2.1}/tests/test_platforms.py +0 -0
  56. {compartment-2 → compartment-2.1}/tests/test_relations.py +0 -0
  57. {compartment-2 → compartment-2.1}/tests/test_salience.py +0 -0
  58. {compartment-2 → compartment-2.1}/tests/test_session_lock.py +0 -0
  59. {compartment-2 → compartment-2.1}/tests/test_starter_packs.py +0 -0
  60. {compartment-2 → compartment-2.1}/tests/test_tamper.py +0 -0
  61. {compartment-2 → compartment-2.1}/tests/test_twofa.py +0 -0
  62. {compartment-2 → compartment-2.1}/tests/test_vault_ops.py +0 -0
@@ -1,7 +1,30 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 2
3
+ Version: 2.1
4
4
  Summary: Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)
5
+ Project-URL: Homepage, https://github.com/MaxFreedomPollard/Compartment
6
+ Project-URL: Source, https://github.com/MaxFreedomPollard/Compartment
7
+ Project-URL: Issues, https://github.com/MaxFreedomPollard/Compartment/issues
8
+ Project-URL: Changelog, https://github.com/MaxFreedomPollard/Compartment/releases
9
+ Project-URL: Documentation, https://github.com/MaxFreedomPollard/Compartment#readme
10
+ Project-URL: Security, https://github.com/MaxFreedomPollard/Compartment/blob/main/SECURITY.md
11
+ Keywords: mcp,model-context-protocol,memory,agent-memory,vector-database,semantic-search,encryption,offline,local-first,claude,claude-code,privacy,rag
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Environment :: Console
15
+ Classifier: Environment :: MacOS X
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Operating System :: MacOS :: MacOS X
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Security :: Cryptography
25
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
26
+ Classifier: Topic :: Database :: Database Engines/Servers
27
+ Classifier: Typing :: Typed
5
28
  Requires-Python: >=3.11
6
29
  Description-Content-Type: text/markdown
7
30
  Requires-Dist: pynacl>=1.5
@@ -4,10 +4,31 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "compartment"
7
- version = "2"
7
+ version = "2.1"
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"
11
+ keywords = ["mcp", "model-context-protocol", "memory", "agent-memory",
12
+ "vector-database", "semantic-search", "encryption", "offline",
13
+ "local-first", "claude", "claude-code", "privacy", "rag"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Environment :: Console",
18
+ "Environment :: MacOS X",
19
+ "Operating System :: OS Independent",
20
+ "Operating System :: MacOS :: MacOS X",
21
+ "Operating System :: Microsoft :: Windows",
22
+ "Operating System :: POSIX :: Linux",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Security :: Cryptography",
28
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
29
+ "Topic :: Database :: Database Engines/Servers",
30
+ "Typing :: Typed",
31
+ ]
11
32
  dependencies = [
12
33
  "pynacl>=1.5",
13
34
  "argon2-cffi>=23.1",
@@ -17,6 +38,18 @@ dependencies = [
17
38
  "mcp>=1.0",
18
39
  ]
19
40
 
41
+ # PyPI is the repo's declared homepage, so it is a real front door - and
42
+ # without these it is a dead end: no Source link, no Issues link, nothing
43
+ # carrying a reader back to GitHub. Directories that scrape PyPI were
44
+ # producing a listing with no repository link at all.
45
+ [project.urls]
46
+ Homepage = "https://github.com/MaxFreedomPollard/Compartment"
47
+ Source = "https://github.com/MaxFreedomPollard/Compartment"
48
+ Issues = "https://github.com/MaxFreedomPollard/Compartment/issues"
49
+ Changelog = "https://github.com/MaxFreedomPollard/Compartment/releases"
50
+ Documentation = "https://github.com/MaxFreedomPollard/Compartment#readme"
51
+ Security = "https://github.com/MaxFreedomPollard/Compartment/blob/main/SECURITY.md"
52
+
20
53
  [project.scripts]
21
54
  compartment = "compartment.cli:main"
22
55
  cmpt = "compartment.cli:main"
@@ -1,6 +1,6 @@
1
1
  """Compartment - high-security, fully offline, encrypted vector memory for AI agents."""
2
2
 
3
- __version__ = "2"
3
+ __version__ = "2.1"
4
4
 
5
5
  from . import offline_guard as _og
6
6
 
@@ -396,6 +396,7 @@ def run(vault: str | None = None, show: bool = False,
396
396
  NSRunningApplication,
397
397
  NSFont, NSImage, NSMakeRect, NSMenu, NSMenuItem,
398
398
  NSPanel, NSPopover, NSScreen, NSSegmentedControl,
399
+ NSClipView, NSScrollView,
399
400
  NSSecureTextField, NSStackView, NSStatusBar,
400
401
  NSSwitch, NSTextField,
401
402
  NSButton, NSView, NSViewController,
@@ -468,6 +469,17 @@ def run(vault: str | None = None, show: bool = False,
468
469
  CONTENT_WIDTH).setActive_(True)
469
470
  return b
470
471
 
472
+ class TopClip(NSClipView):
473
+ """A clip view whose origin is its top left corner.
474
+
475
+ AppKit's default is the bottom left, which inside a scroll view means
476
+ the panel opens showing its last line, with the title somewhere above
477
+ the fold. Flipping it makes "scrolled to the top" the resting state.
478
+ """
479
+
480
+ def isFlipped(self):
481
+ return True
482
+
471
483
  class Controller(NSObject):
472
484
  def init(self):
473
485
  self = objc.super(Controller, self).init()
@@ -485,8 +497,78 @@ def run(vault: str | None = None, show: bool = False,
485
497
  return self
486
498
 
487
499
  # ---- building the popover contents -----------------------------
500
+ @objc.python_method
501
+ def _finish(self, views):
502
+ stack = NSStackView.stackViewWithViews_(views)
503
+ stack.setOrientation_(_VERTICAL)
504
+ stack.setAlignment_(_LEADING)
505
+ stack.setSpacing_(6)
506
+ stack.setEdgeInsets_((CONTENT_INSET, CONTENT_INSET,
507
+ CONTENT_INSET, CONTENT_INSET))
508
+ stack.layoutSubtreeIfNeeded()
509
+ fit = stack.fittingSize()
510
+ stack.setFrameSize_((POPOVER_WIDTH, fit.height))
511
+ if fit.height <= POPOVER_MAX_HEIGHT:
512
+ return stack
513
+
514
+ # Taller than a popover is allowed to be. Scroll it. What this
515
+ # did before was cut the overflow off and say nothing, so the
516
+ # buttons below the fold were not merely out of reach - there
517
+ # was no sign they existed. That is how the passphrase form
518
+ # came to show one box out of two.
519
+ stack.setTranslatesAutoresizingMaskIntoConstraints_(True)
520
+ box = NSMakeRect(0, 0, POPOVER_WIDTH, POPOVER_MAX_HEIGHT)
521
+ scroll = NSScrollView.alloc().initWithFrame_(box)
522
+ scroll.setContentView_(TopClip.alloc().initWithFrame_(box))
523
+ scroll.setHasVerticalScroller_(True)
524
+ scroll.setDrawsBackground_(False)
525
+ scroll.setDocumentView_(stack)
526
+ return scroll
527
+
528
+ @objc.python_method
529
+ def buildChangeBody(self, st):
530
+ """Changing the passphrase gets the whole panel to itself.
531
+
532
+ The ordinary panel already fills POPOVER_MAX_HEIGHT, and this
533
+ popover clips instead of scrolling. Appending the fields to the
534
+ bottom of it put the second box, Save, and the error note below
535
+ the visible edge: you saw one box, pressed Return, and the save
536
+ failed its repeat check against an empty field you could neither
537
+ fill nor read the complaint about. A focused view cannot overflow.
538
+ """
539
+ def secure(placeholder):
540
+ f = NSSecureTextField.alloc().initWithFrame_(
541
+ NSMakeRect(0, 0, CONTENT_WIDTH, 24))
542
+ f.setPlaceholderString_(placeholder)
543
+ f.setTarget_(self)
544
+ f.setAction_("saveChangePw:") # Return saves
545
+ return f
546
+
547
+ self.pw_new = secure("New passphrase")
548
+ self.pw_repeat = secure("Repeat it")
549
+ views = [row(label("Change password", 15, bold=True), _spacer()),
550
+ label(summarise(st), 11, secondary=True),
551
+ divider(),
552
+ row(self.pw_new),
553
+ row(self.pw_repeat)]
554
+ if self.change_note:
555
+ views.append(label(self.change_note, 11, secondary=True,
556
+ wrap=True))
557
+ views.append(label("Both boxes must match. There is no recovery "
558
+ "phrase - if you forget this, the memories are "
559
+ "unrecoverable.", 10, secondary=True, wrap=True))
560
+ save_b = NSButton.buttonWithTitle_target_action_("Save", self,
561
+ "saveChangePw:")
562
+ save_b.setKeyEquivalent_("\r")
563
+ cancel_b = NSButton.buttonWithTitle_target_action_(
564
+ "Cancel", self, "cancelChangePw:")
565
+ views.append(row(save_b, cancel_b, _spacer()))
566
+ return self._finish(views)
567
+
488
568
  def buildBody(self):
489
569
  st = self.state
570
+ if self.changing_pw and st["exists"] and not st["locked"]:
571
+ return self.buildChangeBody(st)
490
572
  views = []
491
573
  title = row(label("Compartment", 15, bold=True),
492
574
  label("locked" if st["locked"] else "unlocked", 11,
@@ -516,33 +598,11 @@ def run(vault: str | None = None, show: bool = False,
516
598
  views.append(label("Stays unlocked until restart or Lock",
517
599
  10, secondary=True))
518
600
 
519
- # Changing the passphrase needs the vault open, because rekey
520
- # re-wraps a master key it can only hold while unlocked.
601
+ # Changing the passphrase is a separate view: see buildChangeBody.
521
602
  self.pw_new = self.pw_repeat = None
522
- if st["exists"] and not st["locked"] and self.changing_pw:
523
- def secure(placeholder):
524
- f = NSSecureTextField.alloc().initWithFrame_(
525
- NSMakeRect(0, 0, CONTENT_WIDTH - 92, 24))
526
- f.setPlaceholderString_(placeholder)
527
- f.setTarget_(self)
528
- f.setAction_("saveChangePw:") # Return saves
529
- return f
530
-
531
- self.pw_new = secure("New passphrase")
532
- self.pw_repeat = secure("Repeat it")
533
- save_b = NSButton.buttonWithTitle_target_action_(
534
- "Save", self, "saveChangePw:")
535
- cancel_b = NSButton.buttonWithTitle_target_action_(
536
- "Cancel", self, "cancelChangePw:")
537
- views.append(self.pw_new)
538
- views.append(row(self.pw_repeat, save_b))
539
- views.append(row(cancel_b, _spacer()))
540
- if self.change_note:
541
- views.append(label(self.change_note, 11, secondary=True,
542
- wrap=True))
543
- views.append(label("There is no recovery phrase. If you forget "
544
- "this, the memories are unrecoverable.",
545
- 10, secondary=True, wrap=True))
603
+ if self.change_note:
604
+ views.append(label(self.change_note, 11, secondary=True,
605
+ wrap=True))
546
606
  views.append(divider())
547
607
 
548
608
  views.append(label("SETTINGS", 10, bold=True, secondary=True))
@@ -613,17 +673,7 @@ def run(vault: str | None = None, show: bool = False,
613
673
  else:
614
674
  views.append(row(refresh, _spacer(), quit_b))
615
675
 
616
- stack = NSStackView.stackViewWithViews_(views)
617
- stack.setOrientation_(_VERTICAL)
618
- stack.setAlignment_(_LEADING)
619
- stack.setSpacing_(6)
620
- stack.setEdgeInsets_((CONTENT_INSET, CONTENT_INSET,
621
- CONTENT_INSET, CONTENT_INSET))
622
- stack.layoutSubtreeIfNeeded()
623
- fit = stack.fittingSize()
624
- stack.setFrameSize_((POPOVER_WIDTH,
625
- min(fit.height, POPOVER_MAX_HEIGHT)))
626
- return stack
676
+ return self._finish(views)
627
677
 
628
678
  # Not an action: PyObjC infers a selector from the method name, and
629
679
  # a name with no trailing underscores means "takes no arguments".
@@ -152,6 +152,18 @@ def set_login(enabled: bool) -> str:
152
152
  return f"error: {exc}"
153
153
 
154
154
 
155
+ def panel_geometry(content: int, maximum: int) -> tuple[int, bool]:
156
+ """How tall to draw the panel, and whether it needs a scrollbar.
157
+
158
+ Split out from the Tk code so the rule is checked on every OS in CI: any
159
+ content taller than the panel scrolls. It is never cut off - the panel
160
+ losing its bottom silently is the whole reason this function exists.
161
+ """
162
+ if content > maximum:
163
+ return maximum, True
164
+ return max(content, 1), False
165
+
166
+
155
167
  # --- the app ---------------------------------------------------------------
156
168
 
157
169
  def run(vault: str | None = None, show: bool = False,
@@ -192,14 +204,53 @@ def run(vault: str | None = None, show: bool = False,
192
204
  root.withdraw() # no stray empty window
193
205
  panel: dict = {"win": None, "note": None}
194
206
 
195
- def build(win) -> None:
196
- for child in win.winfo_children():
197
- child.destroy()
198
- state = fetch_state(vault_path)
199
- frame = ttk.Frame(win, padding=14)
200
- frame.pack(fill="both", expand=True)
207
+ def _content(frame, state) -> None:
208
+ """Everything the panel shows. Packs into `frame`, never sizes it -
209
+ deciding how tall the window gets is build()'s job, below."""
201
210
  s = state["settings"]
202
211
 
212
+ # Changing the passphrase gets the whole panel to itself, rather than
213
+ # being appended below a panel that is already full. Two boxes, Save
214
+ # and the reason the last attempt failed all belong on screen at once.
215
+ if state["exists"] and not state["locked"] and panel.get("changing"):
216
+ ttk.Label(frame, text="Change password", wraplength=WRAP,
217
+ font=("Segoe UI", 10, "bold")).pack(anchor="w")
218
+ ttk.Label(frame, text=summarise(state), wraplength=WRAP,
219
+ foreground="#666").pack(anchor="w", pady=(2, 0))
220
+ ttk.Separator(frame).pack(fill="x", pady=10)
221
+ new = ttk.Entry(frame, show="•", width=34)
222
+ new.pack(anchor="w")
223
+ rep = ttk.Entry(frame, show="•", width=34)
224
+ rep.pack(anchor="w", pady=(6, 0))
225
+ new.focus_set()
226
+
227
+ def do_change(*_):
228
+ ok, note = change_passphrase(vault_path, new.get(), rep.get())
229
+ new.delete(0, "end") # never leave one on screen
230
+ rep.delete(0, "end")
231
+ panel["changing"] = not ok
232
+ panel["change_note"] = note
233
+ refresh()
234
+
235
+ new.bind("<Return>", do_change)
236
+ rep.bind("<Return>", do_change)
237
+ if panel.get("change_note"):
238
+ ttk.Label(frame, text=panel["change_note"], wraplength=WRAP,
239
+ foreground="#b00020").pack(anchor="w", pady=(8, 0))
240
+ ttk.Label(frame, text="Both boxes must match. There is no recovery "
241
+ "phrase - if you forget this, the memories "
242
+ "are unrecoverable.",
243
+ foreground="#666", wraplength=WRAP,
244
+ justify="left").pack(anchor="w", pady=(8, 0))
245
+ bar = ttk.Frame(frame)
246
+ bar.pack(fill="x", pady=(10, 0))
247
+ ttk.Button(bar, text="Save", command=do_change).pack(side="left")
248
+ ttk.Button(bar, text="Cancel",
249
+ command=lambda: (panel.update(changing=False,
250
+ change_note=None),
251
+ refresh())).pack(side="left", padx=6)
252
+ return
253
+
203
254
  ttk.Label(frame, text=summarise(state), wraplength=WRAP,
204
255
  font=("Segoe UI", 10, "bold")).pack(anchor="w")
205
256
  if state.get("error"):
@@ -275,43 +326,7 @@ def run(vault: str | None = None, show: bool = False,
275
326
  wraplength=WRAP,
276
327
  justify="left").pack(anchor="w", pady=(4, 0))
277
328
 
278
- if state["exists"] and not state["locked"] and panel.get("changing"):
279
- ttk.Separator(frame).pack(fill="x", pady=10)
280
- ttk.Label(frame, text="CHANGE PASSWORD",
281
- font=("Segoe UI", 8, "bold")).pack(anchor="w")
282
- new = ttk.Entry(frame, show="•", width=34)
283
- new.pack(anchor="w", pady=(6, 0))
284
- rep = ttk.Entry(frame, show="•", width=34)
285
- rep.pack(anchor="w", pady=(4, 0))
286
- new.focus_set()
287
-
288
- def do_change(*_):
289
- ok, note = change_passphrase(vault_path, new.get(), rep.get())
290
- # Never leave a passphrase sitting in a field on screen.
291
- new.delete(0, "end")
292
- rep.delete(0, "end")
293
- panel["changing"] = not ok
294
- panel["change_note"] = note
295
- refresh()
296
-
297
- new.bind("<Return>", do_change)
298
- rep.bind("<Return>", do_change)
299
- bar = ttk.Frame(frame)
300
- bar.pack(fill="x", pady=(6, 0))
301
- ttk.Button(bar, text="Save", command=do_change).pack(side="left")
302
- ttk.Button(bar, text="Cancel",
303
- command=lambda: (panel.update(changing=False,
304
- change_note=None),
305
- refresh())).pack(side="left", padx=6)
306
- if panel.get("change_note"):
307
- ttk.Label(frame, text=panel["change_note"],
308
- wraplength=WRAP).pack(anchor="w",
309
- pady=(4, 0))
310
- ttk.Label(frame, text="There is no recovery phrase. If you forget "
311
- "this, the memories are unrecoverable.",
312
- foreground="#666", wraplength=WRAP,
313
- justify="left").pack(anchor="w", pady=(4, 0))
314
- elif panel.get("change_note"):
329
+ if panel.get("change_note"): # result of the last attempt
315
330
  ttk.Label(frame, text=panel["change_note"],
316
331
  wraplength=WRAP).pack(anchor="w", pady=(8, 0))
317
332
 
@@ -332,10 +347,56 @@ def run(vault: str | None = None, show: bool = False,
332
347
  refresh())).pack(side="left", padx=6)
333
348
  ttk.Button(buttons, text="Quit", command=quit_app).pack(side="right")
334
349
 
350
+ def build(win) -> None:
351
+ """Fill the window, and give it a scrollbar if the content overflows.
352
+
353
+ The content goes inside a canvas rather than straight into the window.
354
+ A fixed-height window cut its own bottom off and said nothing about
355
+ it, which is how the passphrase form came to show one box out of two:
356
+ the second box and the Save button were not merely out of reach, there
357
+ was no sign on screen that they existed at all.
358
+ """
359
+ for child in win.winfo_children():
360
+ child.destroy()
361
+ state = fetch_state(vault_path)
362
+
363
+ canvas = tk.Canvas(win, highlightthickness=0, borderwidth=0, width=PW)
364
+ try: # match the themed background
365
+ canvas.configure(background=ttk.Style().lookup("TFrame",
366
+ "background"))
367
+ except tk.TclError: # a theme without one: leave it
368
+ pass
369
+ vbar = ttk.Scrollbar(win, orient="vertical", command=canvas.yview)
370
+ canvas.configure(yscrollcommand=vbar.set)
371
+ canvas.pack(side="left", fill="both", expand=True)
372
+ frame = ttk.Frame(canvas, padding=14)
373
+ canvas.create_window((0, 0), window=frame, anchor="nw", width=PW)
374
+
375
+ _content(frame, state)
376
+
377
+ frame.update_idletasks()
378
+ need = frame.winfo_reqheight()
379
+ height, scrolling = panel_geometry(need, PMH)
380
+ canvas.configure(height=height, scrollregion=(0, 0, PW, need))
381
+ if scrolling:
382
+ vbar.pack(side="right", fill="y")
383
+ # Bound on the window, not the canvas: the content covers the
384
+ # canvas, so the wheel event never reaches it. Windows reports
385
+ # the delta in multiples of 120.
386
+ win.bind("<MouseWheel>",
387
+ lambda e: canvas.yview_scroll(-(e.delta // 120), "units"))
388
+ vbar.update_idletasks()
389
+ panel["scroll_w"] = vbar.winfo_reqwidth()
390
+ else:
391
+ win.unbind("<MouseWheel>")
392
+ panel["scroll_w"] = 0
393
+
335
394
  def place(win) -> None:
336
395
  """Bottom right, above the taskbar, where the tray icon is."""
337
396
  win.update_idletasks()
338
- w = PW
397
+ # The scrollbar sits beside the content, so widen the window by it
398
+ # rather than letting it eat a strip off the right of every line.
399
+ w = PW + panel.get("scroll_w", 0)
339
400
  h = min(win.winfo_reqheight(), PMH)
340
401
  x = win.winfo_screenwidth() - w - 12
341
402
  y = win.winfo_screenheight() - h - TBM
@@ -1,7 +1,30 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: compartment
3
- Version: 2
3
+ Version: 2.1
4
4
  Summary: Compartment - high-security, fully offline, encrypted vector memory for AI agents (MCP)
5
+ Project-URL: Homepage, https://github.com/MaxFreedomPollard/Compartment
6
+ Project-URL: Source, https://github.com/MaxFreedomPollard/Compartment
7
+ Project-URL: Issues, https://github.com/MaxFreedomPollard/Compartment/issues
8
+ Project-URL: Changelog, https://github.com/MaxFreedomPollard/Compartment/releases
9
+ Project-URL: Documentation, https://github.com/MaxFreedomPollard/Compartment#readme
10
+ Project-URL: Security, https://github.com/MaxFreedomPollard/Compartment/blob/main/SECURITY.md
11
+ Keywords: mcp,model-context-protocol,memory,agent-memory,vector-database,semantic-search,encryption,offline,local-first,claude,claude-code,privacy,rag
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Environment :: Console
15
+ Classifier: Environment :: MacOS X
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Operating System :: MacOS :: MacOS X
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Security :: Cryptography
25
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
26
+ Classifier: Topic :: Database :: Database Engines/Servers
27
+ Classifier: Typing :: Typed
5
28
  Requires-Python: >=3.11
6
29
  Description-Content-Type: text/markdown
7
30
  Requires-Dist: pynacl>=1.5
@@ -152,3 +152,33 @@ def test_login_helpers_report_instead_of_raising_off_windows():
152
152
  # No winreg here, so both must degrade to a message, not an exception.
153
153
  assert isinstance(systray.login_status(), str)
154
154
  assert systray.set_login(True).startswith("error:")
155
+
156
+
157
+ # The panel used to draw itself at min(content, PANEL_MAX_HEIGHT) and simply
158
+ # lose whatever fell below the line - no scrollbar, no ellipsis, nothing. The
159
+ # change-password form was the casualty: it rendered one of its two boxes,
160
+ # and the Save button and the "the two entries do not match" complaint were
161
+ # both below the fold, so the form looked like it worked and changed nothing.
162
+ def test_content_that_fits_is_drawn_whole_and_needs_no_scrollbar():
163
+ h, scrolling = systray.panel_geometry(300, systray.PANEL_MAX_HEIGHT)
164
+ assert (h, scrolling) == (300, False)
165
+
166
+
167
+ def test_content_taller_than_the_panel_scrolls_rather_than_being_cut_off():
168
+ tall = systray.PANEL_MAX_HEIGHT * 3
169
+ h, scrolling = systray.panel_geometry(tall, systray.PANEL_MAX_HEIGHT)
170
+ assert scrolling, "overflow must scroll, never be silently clipped"
171
+ assert h == systray.PANEL_MAX_HEIGHT
172
+
173
+
174
+ def test_the_panel_never_exceeds_its_maximum():
175
+ for content in (1, 100, 639, 640, 641, 5000):
176
+ h, _ = systray.panel_geometry(content, systray.PANEL_MAX_HEIGHT)
177
+ assert 0 < h <= systray.PANEL_MAX_HEIGHT, content
178
+
179
+
180
+ def test_exactly_full_height_does_not_scroll():
181
+ # The boundary: equal to the maximum still fits, so no scrollbar appears.
182
+ assert systray.panel_geometry(systray.PANEL_MAX_HEIGHT,
183
+ systray.PANEL_MAX_HEIGHT) == (
184
+ systray.PANEL_MAX_HEIGHT, False)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes