memgit 0.9.1__tar.gz → 0.11.0__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 (67) hide show
  1. {memgit-0.9.1 → memgit-0.11.0}/PKG-INFO +92 -2
  2. {memgit-0.9.1 → memgit-0.11.0}/README.md +90 -0
  3. {memgit-0.9.1 → memgit-0.11.0}/memgit/__init__.py +1 -1
  4. {memgit-0.9.1 → memgit-0.11.0}/memgit/cli.py +234 -1
  5. {memgit-0.9.1 → memgit-0.11.0}/memgit/cloud/commands.py +42 -3
  6. memgit-0.11.0/memgit/env.py +88 -0
  7. memgit-0.11.0/memgit/license.py +270 -0
  8. {memgit-0.9.1 → memgit-0.11.0}/memgit/mcp_server.py +113 -3
  9. {memgit-0.9.1 → memgit-0.11.0}/memgit/repo.py +250 -17
  10. memgit-0.11.0/memgit/savings.py +288 -0
  11. {memgit-0.9.1 → memgit-0.11.0}/memgit/store.py +50 -0
  12. {memgit-0.9.1 → memgit-0.11.0}/memgit.egg-info/PKG-INFO +92 -2
  13. {memgit-0.9.1 → memgit-0.11.0}/memgit.egg-info/SOURCES.txt +13 -1
  14. {memgit-0.9.1 → memgit-0.11.0}/memgit.egg-info/requires.txt +1 -1
  15. {memgit-0.9.1 → memgit-0.11.0}/pyproject.toml +23 -2
  16. memgit-0.11.0/tests/test_cloud_guard.py +66 -0
  17. memgit-0.11.0/tests/test_dependency_floor.py +78 -0
  18. memgit-0.11.0/tests/test_env_inventory.py +86 -0
  19. memgit-0.11.0/tests/test_git_args.py +71 -0
  20. memgit-0.11.0/tests/test_license.py +208 -0
  21. memgit-0.11.0/tests/test_lock_exclusion.py +87 -0
  22. memgit-0.11.0/tests/test_savings.py +149 -0
  23. memgit-0.11.0/tests/test_server_lifecycle.py +113 -0
  24. {memgit-0.9.1 → memgit-0.11.0}/tests/test_store_repo.py +184 -0
  25. memgit-0.11.0/tests/test_version_stamps.py +83 -0
  26. {memgit-0.9.1 → memgit-0.11.0}/LICENSE +0 -0
  27. {memgit-0.9.1 → memgit-0.11.0}/memgit/backup.py +0 -0
  28. {memgit-0.9.1 → memgit-0.11.0}/memgit/cloud/__init__.py +0 -0
  29. {memgit-0.9.1 → memgit-0.11.0}/memgit/cloud/client.py +0 -0
  30. {memgit-0.9.1 → memgit-0.11.0}/memgit/cloud/crypto.py +0 -0
  31. {memgit-0.9.1 → memgit-0.11.0}/memgit/cloud/state.py +0 -0
  32. {memgit-0.9.1 → memgit-0.11.0}/memgit/cloud/sync.py +0 -0
  33. {memgit-0.9.1 → memgit-0.11.0}/memgit/delivery.py +0 -0
  34. {memgit-0.9.1 → memgit-0.11.0}/memgit/evaluate.py +0 -0
  35. {memgit-0.9.1 → memgit-0.11.0}/memgit/gitdigest.py +0 -0
  36. {memgit-0.9.1 → memgit-0.11.0}/memgit/graph.py +0 -0
  37. {memgit-0.9.1 → memgit-0.11.0}/memgit/hooks.py +0 -0
  38. {memgit-0.9.1 → memgit-0.11.0}/memgit/http_server.py +0 -0
  39. {memgit-0.9.1 → memgit-0.11.0}/memgit/importer.py +0 -0
  40. {memgit-0.9.1 → memgit-0.11.0}/memgit/links.py +0 -0
  41. {memgit-0.9.1 → memgit-0.11.0}/memgit/metrics.py +0 -0
  42. {memgit-0.9.1 → memgit-0.11.0}/memgit/models.py +0 -0
  43. {memgit-0.9.1 → memgit-0.11.0}/memgit/project.py +0 -0
  44. {memgit-0.9.1 → memgit-0.11.0}/memgit/sanitize.py +0 -0
  45. {memgit-0.9.1 → memgit-0.11.0}/memgit/scorer.py +0 -0
  46. {memgit-0.9.1 → memgit-0.11.0}/memgit/tokens.py +0 -0
  47. {memgit-0.9.1 → memgit-0.11.0}/memgit/toon.py +0 -0
  48. {memgit-0.9.1 → memgit-0.11.0}/memgit/usage.py +0 -0
  49. {memgit-0.9.1 → memgit-0.11.0}/memgit.egg-info/dependency_links.txt +0 -0
  50. {memgit-0.9.1 → memgit-0.11.0}/memgit.egg-info/entry_points.txt +0 -0
  51. {memgit-0.9.1 → memgit-0.11.0}/memgit.egg-info/top_level.txt +0 -0
  52. {memgit-0.9.1 → memgit-0.11.0}/setup.cfg +0 -0
  53. {memgit-0.9.1 → memgit-0.11.0}/tests/test_accrue.py +0 -0
  54. {memgit-0.9.1 → memgit-0.11.0}/tests/test_advanced.py +0 -0
  55. {memgit-0.9.1 → memgit-0.11.0}/tests/test_aliases.py +0 -0
  56. {memgit-0.9.1 → memgit-0.11.0}/tests/test_backup.py +0 -0
  57. {memgit-0.9.1 → memgit-0.11.0}/tests/test_core.py +0 -0
  58. {memgit-0.9.1 → memgit-0.11.0}/tests/test_delivery.py +0 -0
  59. {memgit-0.9.1 → memgit-0.11.0}/tests/test_setup.py +0 -0
  60. {memgit-0.9.1 → memgit-0.11.0}/tests/test_toon.py +0 -0
  61. {memgit-0.9.1 → memgit-0.11.0}/tests/test_v020.py +0 -0
  62. {memgit-0.9.1 → memgit-0.11.0}/tests/test_v030.py +0 -0
  63. {memgit-0.9.1 → memgit-0.11.0}/tests/test_v040.py +0 -0
  64. {memgit-0.9.1 → memgit-0.11.0}/tests/test_v060.py +0 -0
  65. {memgit-0.9.1 → memgit-0.11.0}/tests/test_v070.py +0 -0
  66. {memgit-0.9.1 → memgit-0.11.0}/tests/test_v080.py +0 -0
  67. {memgit-0.9.1 → memgit-0.11.0}/tests/test_v081.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgit
3
- Version: 0.9.1
3
+ Version: 0.11.0
4
4
  Summary: Git for AI memory — version-controlled context persistence across Claude, GPT, Gemini, Cursor, Windsurf, and more
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://memgit.dev
@@ -21,7 +21,7 @@ Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: click>=8.0
23
23
  Requires-Dist: rich>=13.0
24
- Requires-Dist: mcp>=1.0.0
24
+ Requires-Dist: mcp<2,>=1.28.1
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: pytest>=8.0; extra == "dev"
27
27
  Requires-Dist: pytest-anyio>=0.0.0; extra == "dev"
@@ -298,6 +298,33 @@ Since 0.8.0 it also **starts itself**: a project's first guide is created automa
298
298
 
299
299
  ---
300
300
 
301
+ ## memgit Pro — one memory across every machine
302
+
303
+ The local engine, the store, the MCP server and every command below are MIT and never
304
+ gated. **Pro** is the hosted layer for people who work on more than one machine or with a
305
+ team: end-to-end-encrypted sync where the server stores ciphertext only, unlocked by one
306
+ licence key.
307
+
308
+ ```bash
309
+ memgit pro activate <key> # validate the key with Polar and store it (mode 0600)
310
+ memgit pro status # Free / Pro, last verification, expiry
311
+ memgit pro deactivate # remove the key from this machine
312
+ ```
313
+
314
+ - Buy a key at [memgit.dev/#pricing](https://memgit.dev/#pricing) — $12/month or $99/year,
315
+ billed by Polar (merchant of record, taxes handled). The key arrives by email and in your
316
+ Polar purchases page.
317
+ - The only bytes that leave your machine are the key and memgit's public organisation id,
318
+ sent to Polar's validation endpoint. **No memory content is ever sent.**
319
+ - Headless hosts: set `MEMGIT_LICENSE_KEY=<key>` in the MCP server's environment; it is
320
+ validated with the same cache and never written to disk.
321
+ - Fail-open: a key that verified in the last 14 days keeps working offline; a rejected key
322
+ drops to Free with a clear message. A lapsed plan never locks your data — reads, pulls and
323
+ exports keep working.
324
+ - If this store is logged in to memgit cloud, `pro activate` also upgrades that account.
325
+
326
+ ---
327
+
301
328
  ## Backups that actually happen
302
329
 
303
330
  memgit's premise is that the AI is the operator — but backup used to require a human to remember `memgit git init --remote <url>` and keep pushing. On this project's own store that meant 1,734 memories on one disk with no copy anywhere, five weeks in. A maintenance task that needs a human command is a task that will not happen.
@@ -414,6 +441,10 @@ memgit setup gemini-cli
414
441
  memgit setup hooks # Claude Code hooks: resume at start, per-prompt recall,
415
442
  # capture guard + auto-sync at stop (--no-recall / --no-guard)
416
443
 
444
+ # Pro (licence key)
445
+ memgit pro activate <key> # Polar-issued key; unlocks hosted E2E sync
446
+ memgit pro status / deactivate
447
+
417
448
  # Server
418
449
  memgit serve # MCP stdio (Claude Code, Cursor, Windsurf, Cline)
419
450
  memgit serve --http # HTTP REST (ChatGPT Custom Actions, Gemini)
@@ -444,6 +475,65 @@ memgit thread list / switch / create
444
475
 
445
476
  ---
446
477
 
478
+ ## What memgit reads from your environment
479
+
480
+ memgit runs inside your agent, so the honest answer to "what does this process
481
+ read from my environment?" should be checkable rather than a promise. The table
482
+ below is generated from [`memgit/env.py`](memgit/env.py), and
483
+ `tests/test_env_inventory.py` walks the package on every test run and fails if
484
+ the code reads a name this list does not carry — or carries a name nothing
485
+ reads. The list cannot drift away from the code.
486
+
487
+ memgit reads these and nothing else. It writes none of them. None of them turn
488
+ network access on: the cloud endpoints are overrides for a sync you have already
489
+ opted into with `memgit cloud login`, and with no licence and no cloud login,
490
+ memgit talks to nothing.
491
+
492
+ | | Variable | What memgit does with it |
493
+ |---|---|---|
494
+ | Store and state | `MEMGIT_STORE` | Absolute path to the memory store. When set it is the ONLY candidate — an explicit store never silently falls back to another one. |
495
+ | | `MEMGIT_HOME` | Base directory for memgit's per-person state, currently the licence file (default ~/.memgit, mode 0600). |
496
+ | | `MEMGIT_PROJECT` | Forces the project label instead of detecting it from the working directory. |
497
+ | Attribution | `MEMGIT_AUTHOR` | Explicit author stamped on checkpoints, for multi-agent jobs where several writers share one machine account. Wins over MEMGIT_CLIENT. |
498
+ | | `MEMGIT_CLIENT` | The host that launched this process (claude-code, cursor, ...), stamped into checkpoints so a memory says where it came from. |
499
+ | | `USER` | Fallback author name when neither MEMGIT_AUTHOR nor MEMGIT_CLIENT is set. |
500
+ | | `USERNAME` | Windows fallback for USER. |
501
+ | Licensing (memgit Pro) | `MEMGIT_LICENSE_KEY` | memgit Pro licence key. Never printed in full — `memgit pro status` shows the last four characters. |
502
+ | | `MEMGIT_POLAR_API` | Overrides the licence-validation API base URL (testing). |
503
+ | | `MEMGIT_POLAR_ORG_ID` | Overrides the Polar organisation the licence is checked against. |
504
+ | Cloud sync | `MEMGIT_CLOUD_API` | Overrides the memgit cloud API base URL. |
505
+ | | `MEMGIT_CLOUD_APP` | Overrides the memgit cloud web app base URL used in printed links. |
506
+ | | `MEMGIT_CLOUD_NO_CACHE` | Set to 1/true/yes to keep decrypted keys out of credentials.json; the passphrase is then re-prompted per command. |
507
+ | Tuning | `MEMGIT_LOCK_TIMEOUT` | Seconds a writer waits for the store lock before giving up (default 10). |
508
+ | | `MEMGIT_IDLE_EVICT_SECONDS` | Idle seconds before the MCP server drops its in-memory caches while staying connected (default 900). |
509
+ | | `MEMGIT_HOUSEKEEPING_INTERVAL` | Seconds between the MCP server's housekeeping passes. |
510
+ | Set by other software | `CLAUDE_PROJECT_DIR` | Set by Claude Code for hook processes; used as one input to project detection when the working directory is not the project root. |
511
+ | | `PYTEST_CURRENT_TEST` | Set by pytest. Its presence suppresses automatic backups, so a test run never writes into a real backup location. |
512
+
513
+ ### How the project label is decided
514
+
515
+ Memories are project-scoped, so a wrong label files a memory where you will not
516
+ find it again. The label comes from the first of these that yields one, and
517
+ there is exactly one detection path shared by the MCP server, the CLI and the
518
+ hooks — so a label derived at save time and one derived at recall time cannot
519
+ disagree:
520
+
521
+ 1. `MEMGIT_PROJECT` — a forced label, taken verbatim
522
+ 2. the `cwd` a host reports in its hook payload (the real workspace, even when
523
+ the process working directory is elsewhere)
524
+ 3. `CLAUDE_PROJECT_DIR`
525
+ 4. the process working directory
526
+
527
+ When none of them yields a label — running from `$HOME`, for instance — a write
528
+ is **quarantined** under `_unknown` rather than filed globally, and
529
+ `memgit doctor --relabel` moves it once you say where it belongs. Folding a
530
+ free-text label onto an existing project (`log-report` → `Downloads-log-report`)
531
+ happens only when the input is a trailing segment of exactly one known label and
532
+ that label already holds memories; anything ambiguous is left alone, and every
533
+ fold is reported rather than done silently.
534
+
535
+ ---
536
+
447
537
  ## TOON format — compact, readable, diffable
448
538
 
449
539
  Standard markdown memory file:
@@ -264,6 +264,33 @@ Since 0.8.0 it also **starts itself**: a project's first guide is created automa
264
264
 
265
265
  ---
266
266
 
267
+ ## memgit Pro — one memory across every machine
268
+
269
+ The local engine, the store, the MCP server and every command below are MIT and never
270
+ gated. **Pro** is the hosted layer for people who work on more than one machine or with a
271
+ team: end-to-end-encrypted sync where the server stores ciphertext only, unlocked by one
272
+ licence key.
273
+
274
+ ```bash
275
+ memgit pro activate <key> # validate the key with Polar and store it (mode 0600)
276
+ memgit pro status # Free / Pro, last verification, expiry
277
+ memgit pro deactivate # remove the key from this machine
278
+ ```
279
+
280
+ - Buy a key at [memgit.dev/#pricing](https://memgit.dev/#pricing) — $12/month or $99/year,
281
+ billed by Polar (merchant of record, taxes handled). The key arrives by email and in your
282
+ Polar purchases page.
283
+ - The only bytes that leave your machine are the key and memgit's public organisation id,
284
+ sent to Polar's validation endpoint. **No memory content is ever sent.**
285
+ - Headless hosts: set `MEMGIT_LICENSE_KEY=<key>` in the MCP server's environment; it is
286
+ validated with the same cache and never written to disk.
287
+ - Fail-open: a key that verified in the last 14 days keeps working offline; a rejected key
288
+ drops to Free with a clear message. A lapsed plan never locks your data — reads, pulls and
289
+ exports keep working.
290
+ - If this store is logged in to memgit cloud, `pro activate` also upgrades that account.
291
+
292
+ ---
293
+
267
294
  ## Backups that actually happen
268
295
 
269
296
  memgit's premise is that the AI is the operator — but backup used to require a human to remember `memgit git init --remote <url>` and keep pushing. On this project's own store that meant 1,734 memories on one disk with no copy anywhere, five weeks in. A maintenance task that needs a human command is a task that will not happen.
@@ -380,6 +407,10 @@ memgit setup gemini-cli
380
407
  memgit setup hooks # Claude Code hooks: resume at start, per-prompt recall,
381
408
  # capture guard + auto-sync at stop (--no-recall / --no-guard)
382
409
 
410
+ # Pro (licence key)
411
+ memgit pro activate <key> # Polar-issued key; unlocks hosted E2E sync
412
+ memgit pro status / deactivate
413
+
383
414
  # Server
384
415
  memgit serve # MCP stdio (Claude Code, Cursor, Windsurf, Cline)
385
416
  memgit serve --http # HTTP REST (ChatGPT Custom Actions, Gemini)
@@ -410,6 +441,65 @@ memgit thread list / switch / create
410
441
 
411
442
  ---
412
443
 
444
+ ## What memgit reads from your environment
445
+
446
+ memgit runs inside your agent, so the honest answer to "what does this process
447
+ read from my environment?" should be checkable rather than a promise. The table
448
+ below is generated from [`memgit/env.py`](memgit/env.py), and
449
+ `tests/test_env_inventory.py` walks the package on every test run and fails if
450
+ the code reads a name this list does not carry — or carries a name nothing
451
+ reads. The list cannot drift away from the code.
452
+
453
+ memgit reads these and nothing else. It writes none of them. None of them turn
454
+ network access on: the cloud endpoints are overrides for a sync you have already
455
+ opted into with `memgit cloud login`, and with no licence and no cloud login,
456
+ memgit talks to nothing.
457
+
458
+ | | Variable | What memgit does with it |
459
+ |---|---|---|
460
+ | Store and state | `MEMGIT_STORE` | Absolute path to the memory store. When set it is the ONLY candidate — an explicit store never silently falls back to another one. |
461
+ | | `MEMGIT_HOME` | Base directory for memgit's per-person state, currently the licence file (default ~/.memgit, mode 0600). |
462
+ | | `MEMGIT_PROJECT` | Forces the project label instead of detecting it from the working directory. |
463
+ | Attribution | `MEMGIT_AUTHOR` | Explicit author stamped on checkpoints, for multi-agent jobs where several writers share one machine account. Wins over MEMGIT_CLIENT. |
464
+ | | `MEMGIT_CLIENT` | The host that launched this process (claude-code, cursor, ...), stamped into checkpoints so a memory says where it came from. |
465
+ | | `USER` | Fallback author name when neither MEMGIT_AUTHOR nor MEMGIT_CLIENT is set. |
466
+ | | `USERNAME` | Windows fallback for USER. |
467
+ | Licensing (memgit Pro) | `MEMGIT_LICENSE_KEY` | memgit Pro licence key. Never printed in full — `memgit pro status` shows the last four characters. |
468
+ | | `MEMGIT_POLAR_API` | Overrides the licence-validation API base URL (testing). |
469
+ | | `MEMGIT_POLAR_ORG_ID` | Overrides the Polar organisation the licence is checked against. |
470
+ | Cloud sync | `MEMGIT_CLOUD_API` | Overrides the memgit cloud API base URL. |
471
+ | | `MEMGIT_CLOUD_APP` | Overrides the memgit cloud web app base URL used in printed links. |
472
+ | | `MEMGIT_CLOUD_NO_CACHE` | Set to 1/true/yes to keep decrypted keys out of credentials.json; the passphrase is then re-prompted per command. |
473
+ | Tuning | `MEMGIT_LOCK_TIMEOUT` | Seconds a writer waits for the store lock before giving up (default 10). |
474
+ | | `MEMGIT_IDLE_EVICT_SECONDS` | Idle seconds before the MCP server drops its in-memory caches while staying connected (default 900). |
475
+ | | `MEMGIT_HOUSEKEEPING_INTERVAL` | Seconds between the MCP server's housekeeping passes. |
476
+ | Set by other software | `CLAUDE_PROJECT_DIR` | Set by Claude Code for hook processes; used as one input to project detection when the working directory is not the project root. |
477
+ | | `PYTEST_CURRENT_TEST` | Set by pytest. Its presence suppresses automatic backups, so a test run never writes into a real backup location. |
478
+
479
+ ### How the project label is decided
480
+
481
+ Memories are project-scoped, so a wrong label files a memory where you will not
482
+ find it again. The label comes from the first of these that yields one, and
483
+ there is exactly one detection path shared by the MCP server, the CLI and the
484
+ hooks — so a label derived at save time and one derived at recall time cannot
485
+ disagree:
486
+
487
+ 1. `MEMGIT_PROJECT` — a forced label, taken verbatim
488
+ 2. the `cwd` a host reports in its hook payload (the real workspace, even when
489
+ the process working directory is elsewhere)
490
+ 3. `CLAUDE_PROJECT_DIR`
491
+ 4. the process working directory
492
+
493
+ When none of them yields a label — running from `$HOME`, for instance — a write
494
+ is **quarantined** under `_unknown` rather than filed globally, and
495
+ `memgit doctor --relabel` moves it once you say where it belongs. Folding a
496
+ free-text label onto an existing project (`log-report` → `Downloads-log-report`)
497
+ happens only when the input is a trailing segment of exactly one known label and
498
+ that label already holds memories; anything ambiguous is left alone, and every
499
+ fold is reported rather than done silently.
500
+
501
+ ---
502
+
413
503
  ## TOON format — compact, readable, diffable
414
504
 
415
505
  Standard markdown memory file:
@@ -1,3 +1,3 @@
1
1
  """memgit — git for AI memory."""
2
2
 
3
- __version__ = "0.9.1"
3
+ __version__ = "0.11.0"
@@ -118,11 +118,18 @@ def init(directory):
118
118
 
119
119
  # Step-by-step flow: find existing memories automatically and offer the
120
120
  # import, instead of making the user discover the right path themselves.
121
+ # Narrow on purpose: an unreadable ~/.claude tree (OSError) or a memory file
122
+ # that will not parse (ValueError, which UnicodeDecodeError subclasses) is a
123
+ # reason to skip the offer, not to abort `init`. Anything else is a bug in
124
+ # the importer and must reach the user rather than being swallowed here.
121
125
  try:
122
126
  from .importer import from_claude_code
123
127
  found = from_claude_code()
124
- except Exception:
128
+ except (OSError, ValueError) as exc:
125
129
  found = []
130
+ console.print(f'[dim]Could not scan ~/.claude/projects for existing '
131
+ f'memories ({exc.__class__.__name__}: {exc}) — skipping '
132
+ f'the import offer.[/dim]')
126
133
  if found:
127
134
  projects = {m.project for m in found if m.project}
128
135
  console.print(f'\nFound [bold]{len(found)}[/bold] existing Claude Code memories '
@@ -1978,6 +1985,105 @@ def metrics(fmt_json, reset):
1978
1985
  console.print()
1979
1986
 
1980
1987
 
1988
+ @cli.command()
1989
+ @click.option('--json', 'fmt_json', is_flag=True, help='Machine-readable output')
1990
+ @click.option('--match', default=None, type=float,
1991
+ help='Share of a memory\'s terms a file must carry to count as '
1992
+ 'containing the fact (default 0.5)')
1993
+ @click.option('--reads', default=None, type=int,
1994
+ help='How many files the counterfactual reader opens (default 3)')
1995
+ @click.option('--all-projects', is_flag=True,
1996
+ help='Every project, not just the one you are standing in')
1997
+ @click.option('--root', type=click.Path(exists=True, file_okay=False),
1998
+ help='Directory the counterfactual reader would search (default: cwd)')
1999
+ def savings(fmt_json, match, reads, all_projects, root):
2000
+ """What memory cost, against what finding the same fact would have cost.
2001
+
2002
+ The counterfactual is stated, not implied: without memgit an agent needing
2003
+ a fact would grep this project and read the best-matching files. That path
2004
+ is mechanical, so it can be measured. Memories no file contains are counted
2005
+ separately and NEVER priced — without memgit those facts are not found at
2006
+ any number of tokens, and turning that into a savings figure would be the
2007
+ fabrication `memgit metrics` was right to refuse.
2008
+
2009
+ The estimate is conservative on purpose: it counts the files only, not the
2010
+ grep output, not the turns spent choosing what to open, and not the reads
2011
+ that miss.
2012
+ """
2013
+ from . import savings as sv
2014
+ from .project import detect_project
2015
+ repo = _require_repo()
2016
+ search_root = Path(root) if root else Path.cwd()
2017
+ label = None if all_projects else detect_project(search_root)
2018
+ r = sv.measure(repo, search_root, project=label,
2019
+ match=match if match is not None else sv.DEFAULT_MATCH,
2020
+ reads=reads if reads is not None else sv.DEFAULT_READS)
2021
+
2022
+ if fmt_json:
2023
+ import json as _j
2024
+ print(_j.dumps(r, indent=2))
2025
+ return
2026
+
2027
+ from rich.rule import Rule
2028
+ from rich import box
2029
+
2030
+ console.print()
2031
+ console.print(Rule('[bold cyan]memgit savings[/bold cyan] '
2032
+ '[dim]measured against a stated counterfactual[/dim]'))
2033
+ console.print()
2034
+ console.print(f'[dim]Project:[/dim] {r["project"] or "all"} '
2035
+ f'[dim]Files a reader could open:[/dim] {r["files_indexed"]:,} '
2036
+ f'[dim]Reads assumed:[/dim] {r["parameters"]["reads"]} '
2037
+ f'[dim]Match:[/dim] {r["parameters"]["match"]:.0%}')
2038
+ console.print()
2039
+
2040
+ if r['memories_considered'] == 0:
2041
+ console.print('[dim]No recalled memories for this project yet — nothing '
2042
+ 'to compare. The usage ledger fills as memories get '
2043
+ 'surfaced.[/dim]')
2044
+ console.print()
2045
+ return
2046
+
2047
+ t = Table(box=box.SIMPLE, header_style='bold')
2048
+ t.add_column('', min_width=34)
2049
+ t.add_column('Tokens', justify='right')
2050
+ t.add_row('With memgit (what was injected)', f'{r["memgit_tokens"]:,}')
2051
+ t.add_row('Without memgit (files that would be read)',
2052
+ f'{r["without_memgit_tokens"]:,}')
2053
+ t.add_row('[bold]Saved[/bold]', f'[bold]{r["saved_tokens"]:,}[/bold]')
2054
+ console.print(t)
2055
+ if r['ratio']:
2056
+ console.print(f' Reading costs [bold]{r["ratio"]:.0f}x[/bold] what recall '
2057
+ f'costs, over {r["recalls_counted"]:,} recorded recalls of '
2058
+ f'{r["recoverable_from_files"]:,} memories.')
2059
+ console.print()
2060
+
2061
+ if r['unrecoverable_from_files']:
2062
+ console.print(
2063
+ f'[bold yellow]{r["unrecoverable_from_files"]:,} of '
2064
+ f'{r["memories_considered"]:,} recalled memories are in NO file in '
2065
+ f'this project.[/bold yellow]')
2066
+ console.print(' [dim]Not priced, deliberately. Without memgit these are '
2067
+ 'not found more expensively, they are not found. That is '
2068
+ 'the claim worth making, and it is a count, not a token '
2069
+ 'figure.[/dim]')
2070
+ console.print()
2071
+
2072
+ if r['top_savers']:
2073
+ t2 = Table(box=box.SIMPLE, header_style='bold',
2074
+ title='Where the saving comes from', title_justify='left')
2075
+ t2.add_column('Memory', min_width=30)
2076
+ t2.add_column('Recalls', justify='right')
2077
+ t2.add_column('Recall tok', justify='right')
2078
+ t2.add_column('Read tok', justify='right')
2079
+ for row in r['top_savers'][:8]:
2080
+ t2.add_row(row['slug'][:48], f'{row["hits"]:,}',
2081
+ f'{row["memgit_tokens_per_recall"]:,}',
2082
+ f'{row["without_tokens_per_recall"]:,}')
2083
+ console.print(t2)
2084
+ console.print()
2085
+
2086
+
1981
2087
  @cli.group(invoke_without_command=True)
1982
2088
  @click.pass_context
1983
2089
  def backup(ctx):
@@ -2587,6 +2693,13 @@ def git_init(remote):
2587
2693
 
2588
2694
  Then teammates run `memgit git pull` to get your memories.
2589
2695
  """
2696
+ if remote:
2697
+ from .repo import validate_git_remote
2698
+ bad = validate_git_remote(remote)
2699
+ if bad:
2700
+ err.print(f'[red]{bad}[/red]')
2701
+ raise SystemExit(1)
2702
+
2590
2703
  repo = _require_repo()
2591
2704
  ok = repo.git_init()
2592
2705
  if not ok:
@@ -3354,6 +3467,126 @@ if __name__ == '__main__':
3354
3467
  cli()
3355
3468
 
3356
3469
 
3470
+ # ── pro (licence-key entitlement via Polar; no extra, no telemetry) ──────────
3471
+
3472
+ @cli.group(invoke_without_command=True)
3473
+ @click.pass_context
3474
+ def pro(ctx):
3475
+ """memgit Pro — activate, inspect or remove a licence key.
3476
+
3477
+ Pro unlocks the hosted sync layer (memgit cloud) and future paid features.
3478
+ The free CLI, store and MCP server are MIT and never gated. The only bytes
3479
+ that leave this machine are the key and memgit's public organisation id,
3480
+ sent to Polar's validation endpoint; no memory content is ever sent.
3481
+ """
3482
+ if ctx.invoked_subcommand is None:
3483
+ ctx.invoke(pro_status)
3484
+
3485
+
3486
+ @pro.command('activate')
3487
+ @click.argument('key')
3488
+ @click.option('--no-cloud', is_flag=True, help='Do not also attach the key to a logged-in memgit cloud account')
3489
+ def pro_activate(key, no_cloud):
3490
+ """Store KEY and validate it with Polar. Buy one at memgit.dev/#pricing."""
3491
+ from . import license as lic_mod
3492
+ from datetime import datetime, timezone
3493
+ key = key.strip()
3494
+ if len(key) < 8:
3495
+ console.print('[red]✗[/red] that does not look like a licence key')
3496
+ sys.exit(2)
3497
+ lic = lic_mod.License(key=key)
3498
+ now = datetime.now(timezone.utc)
3499
+ lic = lic_mod.refresh(lic, now=now, force=True)
3500
+ if lic.status == 'granted':
3501
+ lic_mod.write(lic)
3502
+ console.print(f'[green]✓[/green] memgit Pro activated for key {lic.masked}'
3503
+ + (f' ({lic.customer_email})' if lic.customer_email else ''))
3504
+ if lic.expires_at:
3505
+ console.print(f' [dim]expires {lic.expires_at}[/dim]')
3506
+ elif lic.status in ('revoked', 'disabled', 'invalid'):
3507
+ console.print(f'[red]✗[/red] key {lic.masked} was rejected: {lic.error or lic.status}')
3508
+ console.print(' [dim]Keys are issued by Polar on purchase and shown in your purchases page.[/dim]')
3509
+ sys.exit(1)
3510
+ else: # unknown — offline or provider down: keep the key, verify on next run
3511
+ lic.status = 'none'
3512
+ lic_mod.write(lic)
3513
+ console.print(f'[yellow]![/yellow] could not reach the licence service ({lic.error}); '
3514
+ f'key {lic.masked} saved and will be verified on the next run')
3515
+ if not no_cloud and lic.status == 'granted':
3516
+ _pro_attach_cloud(key)
3517
+
3518
+
3519
+ def _pro_attach_cloud(key: str) -> None:
3520
+ """Best-effort: if this store is logged in to memgit cloud, tell the API about
3521
+ the key so the hosted side unlocks too. Silent when not logged in."""
3522
+ try:
3523
+ from .cloud.state import CloudState
3524
+ from .repo import Repository
3525
+ repo = Repository.find()
3526
+ if repo is None:
3527
+ return
3528
+ cstate = CloudState(repo.path)
3529
+ creds = cstate.creds
3530
+ if not creds.get('access'):
3531
+ return
3532
+ from .cloud.client import ApiClient, DEFAULT_API_URL
3533
+ import os as _os
3534
+ api_url = _os.environ.get('MEMGIT_CLOUD_API') or creds.get('api_url') or DEFAULT_API_URL
3535
+ api = ApiClient(api_url, cstate)
3536
+ api._check(api._request('POST', '/v1/billing/polar/activate', json={'license_key': key}))
3537
+ console.print('[green]✓[/green] memgit cloud account upgraded to Pro')
3538
+ except Exception as e: # never let the hosted side break local activation
3539
+ console.print(f'[dim]cloud: not attached ({str(e)[:80]}) — run `memgit cloud login` then re-activate[/dim]')
3540
+
3541
+
3542
+ @pro.command('status')
3543
+ @click.option('--json', 'fmt_json', is_flag=True, help='JSON output')
3544
+ @click.option('--offline', is_flag=True, help='Answer from the cache only; never touch the network')
3545
+ def pro_status(fmt_json, offline):
3546
+ """Show whether this machine is entitled to memgit Pro."""
3547
+ from . import license as lic_mod
3548
+ from datetime import datetime, timezone
3549
+ now = datetime.now(timezone.utc)
3550
+ lic = lic_mod.read()
3551
+ if lic.key and not offline and lic.needs_recheck(now):
3552
+ lic = lic_mod.refresh(lic, now=now)
3553
+ if lic.source == 'file':
3554
+ try:
3555
+ lic_mod.write(lic)
3556
+ except OSError:
3557
+ pass
3558
+ ent = lic.is_entitled(now)
3559
+ if fmt_json:
3560
+ import json as _j
3561
+ print(_j.dumps({'entitled': ent, 'key': lic.masked, 'status': lic.status,
3562
+ 'validated_at': lic.validated_at, 'expires_at': lic.expires_at,
3563
+ 'source': lic.source, 'error': lic.error}))
3564
+ return
3565
+ if not lic.key:
3566
+ console.print('[bold]memgit Free[/bold] — no licence key. '
3567
+ f'Pro (hosted E2E sync across machines and teams): {lic_mod.PRO_URL}')
3568
+ return
3569
+ tier = '[green]memgit Pro[/green]' if ent else '[yellow]memgit Free (key not entitled)[/yellow]'
3570
+ console.print(f'{tier} key {lic.masked} status {lic.status}'
3571
+ f'{" via $MEMGIT_LICENSE_KEY" if lic.source != "file" else ""}')
3572
+ if lic.validated_at:
3573
+ console.print(f' [dim]last verified {lic.validated_at}[/dim]')
3574
+ if lic.expires_at:
3575
+ console.print(f' [dim]expires {lic.expires_at}[/dim]')
3576
+ if lic.error:
3577
+ console.print(f' [yellow]{lic.error}[/yellow]')
3578
+
3579
+
3580
+ @pro.command('deactivate')
3581
+ def pro_deactivate():
3582
+ """Remove the stored licence key from this machine."""
3583
+ from . import license as lic_mod
3584
+ if lic_mod.clear():
3585
+ console.print('[green]✓[/green] licence key removed; memgit is back on the Free tier')
3586
+ else:
3587
+ console.print('[dim]no licence key was stored[/dim]')
3588
+
3589
+
3357
3590
  # ── cloud (E2E-encrypted team sync — optional extra) ──────────────────────────
3358
3591
 
3359
3592
  try:
@@ -136,7 +136,12 @@ def cloud():
136
136
  @cloud.command()
137
137
  @click.option('--email', prompt=True)
138
138
  def signup(email):
139
- """Create a memgit cloud account (30-day free trial)."""
139
+ """Create a memgit cloud account (free trial; the server sets the length).
140
+
141
+ The trial length is stamped by the server at signup and printed below, so this
142
+ help text does not hardcode it. It said "30-day" while the deployed server granted
143
+ 14, which is a promise broken on day 15 for every reader of --help.
144
+ """
140
145
  _require_extra()
141
146
  from . import crypto
142
147
  from nacl import utils
@@ -345,13 +350,41 @@ def _report(res, direction):
345
350
  console.print(f'[green]{icons.get(res.action, "·")}[/green] {direction}: {res.action}{detail}{head}')
346
351
 
347
352
 
353
+ def _guard_uncommitted(repo, thread: str, allow: bool) -> None:
354
+ """Refuse to transmit while staged memories are not in a checkpoint.
355
+
356
+ Sync ships CHECKPOINTS. `memgit add` stages a memory but does not create
357
+ one, so pushing straight after `add` uploads nothing of the user's — and
358
+ said "created (2 objects up)" while doing it, with the receiving machine
359
+ reporting a clean, empty, fsck-OK store (measured 2026-09-06). Nothing in
360
+ that sequence errored, which is why it reads as "sync is broken" rather
361
+ than "you have not committed". An error naming the fix is the whole point;
362
+ `--allow-uncommitted` is there for anyone who means it.
363
+ """
364
+ if allow:
365
+ return
366
+ pending = repo.uncommitted_slugs(thread)
367
+ if not pending:
368
+ return
369
+ shown = ', '.join(pending[:5]) + (f' and {len(pending) - 5} more' if len(pending) > 5 else '')
370
+ console.print(
371
+ f'[red]✗[/red] {len(pending)} memor{"y is" if len(pending) == 1 else "ies are"} '
372
+ f'not committed, and sync only ships committed memory: [bold]{shown}[/bold]')
373
+ console.print(' [dim]commit them first: memgit commit -m "what changed"[/dim]')
374
+ console.print(' [dim]or push the committed state anyway: --allow-uncommitted[/dim]')
375
+ sys.exit(1)
376
+
377
+
348
378
  @cloud.command()
349
379
  @click.option('--thread', default=None, help='thread to push (default: current)')
350
- def push(thread):
380
+ @click.option('--allow-uncommitted', is_flag=True,
381
+ help='Push committed state even though staged memories would not be sent')
382
+ def push(thread, allow_uncommitted):
351
383
  """Encrypt and upload local checkpoints, then advance the remote ref."""
352
384
  repo, cstate, api = _ctx()
353
385
  engine = _linked(repo, cstate, api)
354
386
  th = thread or repo.current_thread()
387
+ _guard_uncommitted(repo, th, allow_uncommitted)
355
388
  res = _run(lambda: engine.push(th), 'push')
356
389
  _report(res, f'push {th}')
357
390
 
@@ -369,11 +402,17 @@ def pull(thread):
369
402
 
370
403
  @cloud.command('sync')
371
404
  @click.option('--thread', default=None, help='thread to sync (default: current)')
372
- def cloud_sync(thread):
405
+ @click.option('--allow-uncommitted', is_flag=True,
406
+ help='Sync committed state even though staged memories would not be sent')
407
+ def cloud_sync(thread, allow_uncommitted):
373
408
  """Pull then push — the everyday command."""
374
409
  repo, cstate, api = _ctx()
375
410
  engine = _linked(repo, cstate, api)
376
411
  th = thread or repo.current_thread()
412
+ # Guarded before the pull as well as the push: sync is the command users
413
+ # reach for, and finding out after a merge that half of it was a no-op is
414
+ # worse than being told up front which memories are not committed yet.
415
+ _guard_uncommitted(repo, th, allow_uncommitted)
377
416
  pulled, pushed = _run(lambda: engine.sync(th), 'sync')
378
417
  _report(pulled, f'pull {th}')
379
418
  _report(pushed, f'push {th}')