memgit 0.10.0__tar.gz → 0.12.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 (73) hide show
  1. {memgit-0.10.0 → memgit-0.12.0}/PKG-INFO +77 -3
  2. {memgit-0.10.0 → memgit-0.12.0}/README.md +75 -1
  3. {memgit-0.10.0 → memgit-0.12.0}/memgit/__init__.py +1 -1
  4. memgit-0.12.0/memgit/audit.py +363 -0
  5. {memgit-0.10.0 → memgit-0.12.0}/memgit/cli.py +101 -2
  6. {memgit-0.10.0 → memgit-0.12.0}/memgit/cloud/commands.py +42 -3
  7. memgit-0.12.0/memgit/env.py +88 -0
  8. {memgit-0.10.0 → memgit-0.12.0}/memgit/mcp_server.py +1 -1
  9. {memgit-0.10.0 → memgit-0.12.0}/memgit/models.py +6 -0
  10. memgit-0.12.0/memgit/org.py +320 -0
  11. {memgit-0.10.0 → memgit-0.12.0}/memgit/project.py +44 -7
  12. {memgit-0.10.0 → memgit-0.12.0}/memgit/repo.py +175 -16
  13. {memgit-0.10.0 → memgit-0.12.0}/memgit/savings.py +21 -2
  14. memgit-0.12.0/memgit/tiers.py +210 -0
  15. {memgit-0.10.0 → memgit-0.12.0}/memgit/toon.py +13 -0
  16. {memgit-0.10.0 → memgit-0.12.0}/memgit.egg-info/PKG-INFO +77 -3
  17. {memgit-0.10.0 → memgit-0.12.0}/memgit.egg-info/SOURCES.txt +14 -1
  18. {memgit-0.10.0 → memgit-0.12.0}/memgit.egg-info/requires.txt +1 -1
  19. {memgit-0.10.0 → memgit-0.12.0}/pyproject.toml +23 -9
  20. memgit-0.12.0/tests/test_audit.py +231 -0
  21. memgit-0.12.0/tests/test_cloud_guard.py +66 -0
  22. memgit-0.12.0/tests/test_dependency_floor.py +78 -0
  23. memgit-0.12.0/tests/test_env_inventory.py +86 -0
  24. memgit-0.12.0/tests/test_git_args.py +71 -0
  25. memgit-0.12.0/tests/test_lock_exclusion.py +87 -0
  26. memgit-0.12.0/tests/test_org.py +188 -0
  27. {memgit-0.10.0 → memgit-0.12.0}/tests/test_savings.py +31 -0
  28. {memgit-0.10.0 → memgit-0.12.0}/tests/test_server_lifecycle.py +11 -1
  29. memgit-0.12.0/tests/test_tiers.py +159 -0
  30. {memgit-0.10.0 → memgit-0.12.0}/tests/test_v040.py +45 -5
  31. memgit-0.12.0/tests/test_version_stamps.py +83 -0
  32. {memgit-0.10.0 → memgit-0.12.0}/LICENSE +0 -0
  33. {memgit-0.10.0 → memgit-0.12.0}/memgit/backup.py +0 -0
  34. {memgit-0.10.0 → memgit-0.12.0}/memgit/cloud/__init__.py +0 -0
  35. {memgit-0.10.0 → memgit-0.12.0}/memgit/cloud/client.py +0 -0
  36. {memgit-0.10.0 → memgit-0.12.0}/memgit/cloud/crypto.py +0 -0
  37. {memgit-0.10.0 → memgit-0.12.0}/memgit/cloud/state.py +0 -0
  38. {memgit-0.10.0 → memgit-0.12.0}/memgit/cloud/sync.py +0 -0
  39. {memgit-0.10.0 → memgit-0.12.0}/memgit/delivery.py +0 -0
  40. {memgit-0.10.0 → memgit-0.12.0}/memgit/evaluate.py +0 -0
  41. {memgit-0.10.0 → memgit-0.12.0}/memgit/gitdigest.py +0 -0
  42. {memgit-0.10.0 → memgit-0.12.0}/memgit/graph.py +0 -0
  43. {memgit-0.10.0 → memgit-0.12.0}/memgit/hooks.py +0 -0
  44. {memgit-0.10.0 → memgit-0.12.0}/memgit/http_server.py +0 -0
  45. {memgit-0.10.0 → memgit-0.12.0}/memgit/importer.py +0 -0
  46. {memgit-0.10.0 → memgit-0.12.0}/memgit/license.py +0 -0
  47. {memgit-0.10.0 → memgit-0.12.0}/memgit/links.py +0 -0
  48. {memgit-0.10.0 → memgit-0.12.0}/memgit/metrics.py +0 -0
  49. {memgit-0.10.0 → memgit-0.12.0}/memgit/sanitize.py +0 -0
  50. {memgit-0.10.0 → memgit-0.12.0}/memgit/scorer.py +0 -0
  51. {memgit-0.10.0 → memgit-0.12.0}/memgit/store.py +0 -0
  52. {memgit-0.10.0 → memgit-0.12.0}/memgit/tokens.py +0 -0
  53. {memgit-0.10.0 → memgit-0.12.0}/memgit/usage.py +0 -0
  54. {memgit-0.10.0 → memgit-0.12.0}/memgit.egg-info/dependency_links.txt +0 -0
  55. {memgit-0.10.0 → memgit-0.12.0}/memgit.egg-info/entry_points.txt +0 -0
  56. {memgit-0.10.0 → memgit-0.12.0}/memgit.egg-info/top_level.txt +0 -0
  57. {memgit-0.10.0 → memgit-0.12.0}/setup.cfg +0 -0
  58. {memgit-0.10.0 → memgit-0.12.0}/tests/test_accrue.py +0 -0
  59. {memgit-0.10.0 → memgit-0.12.0}/tests/test_advanced.py +0 -0
  60. {memgit-0.10.0 → memgit-0.12.0}/tests/test_aliases.py +0 -0
  61. {memgit-0.10.0 → memgit-0.12.0}/tests/test_backup.py +0 -0
  62. {memgit-0.10.0 → memgit-0.12.0}/tests/test_core.py +0 -0
  63. {memgit-0.10.0 → memgit-0.12.0}/tests/test_delivery.py +0 -0
  64. {memgit-0.10.0 → memgit-0.12.0}/tests/test_license.py +0 -0
  65. {memgit-0.10.0 → memgit-0.12.0}/tests/test_setup.py +0 -0
  66. {memgit-0.10.0 → memgit-0.12.0}/tests/test_store_repo.py +0 -0
  67. {memgit-0.10.0 → memgit-0.12.0}/tests/test_toon.py +0 -0
  68. {memgit-0.10.0 → memgit-0.12.0}/tests/test_v020.py +0 -0
  69. {memgit-0.10.0 → memgit-0.12.0}/tests/test_v030.py +0 -0
  70. {memgit-0.10.0 → memgit-0.12.0}/tests/test_v060.py +0 -0
  71. {memgit-0.10.0 → memgit-0.12.0}/tests/test_v070.py +0 -0
  72. {memgit-0.10.0 → memgit-0.12.0}/tests/test_v080.py +0 -0
  73. {memgit-0.10.0 → memgit-0.12.0}/tests/test_v081.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgit
3
- Version: 0.10.0
3
+ Version: 0.12.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<2,>=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"
@@ -411,7 +411,9 @@ memgit eval run --misses 10 # inspect the cases where nothing relevant sur
411
411
  memgit squash # compress old history (archives what it collapses)
412
412
  memgit gc # reclaim disk: sweep unreachable objects + stale session caches
413
413
  memgit stats # measured context costs + disk usage
414
- memgit doctor # hygiene report: quarantined/_unknown memories, stale caches, orphaned usage
414
+ memgit doctor # hygiene + scope losses: split labels, quarantined, stale caches, orphaned usage
415
+ memgit doctor --audit # every split and stranded label, the save landing rate, throughput
416
+ memgit doctor --audit --json # the same report as JSON
415
417
  memgit doctor --relabel map.json # bulk re-project memories ({"slug": "Label" | ""}); one checkpoint
416
418
  memgit lint # validate all memories (flags unknown provenance)
417
419
  memgit fsck # verify store integrity
@@ -475,6 +477,78 @@ memgit thread list / switch / create
475
477
 
476
478
  ---
477
479
 
480
+ ## What memgit reads from your environment
481
+
482
+ memgit runs inside your agent, so the honest answer to "what does this process
483
+ read from my environment?" should be checkable rather than a promise. The table
484
+ below is generated from [`memgit/env.py`](memgit/env.py), and
485
+ `tests/test_env_inventory.py` walks the package on every test run and fails if
486
+ the code reads a name this list does not carry — or carries a name nothing
487
+ reads. The list cannot drift away from the code.
488
+
489
+ memgit reads these and nothing else. It writes none of them. None of them turn
490
+ network access on: the cloud endpoints are overrides for a sync you have already
491
+ opted into with `memgit cloud login`, and with no licence and no cloud login,
492
+ memgit talks to nothing.
493
+
494
+ | | Variable | What memgit does with it |
495
+ |---|---|---|
496
+ | 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. |
497
+ | | `MEMGIT_HOME` | Base directory for memgit's per-person state, currently the licence file (default ~/.memgit, mode 0600). |
498
+ | | `MEMGIT_PROJECT` | Forces the project label instead of detecting it from the working directory. |
499
+ | Attribution | `MEMGIT_AUTHOR` | Explicit author stamped on checkpoints, for multi-agent jobs where several writers share one machine account. Wins over MEMGIT_CLIENT. |
500
+ | | `MEMGIT_CLIENT` | The host that launched this process (claude-code, cursor, ...), stamped into checkpoints so a memory says where it came from. |
501
+ | | `USER` | Fallback author name when neither MEMGIT_AUTHOR nor MEMGIT_CLIENT is set. |
502
+ | | `USERNAME` | Windows fallback for USER. |
503
+ | Licensing (memgit Pro) | `MEMGIT_LICENSE_KEY` | memgit Pro licence key. Never printed in full — `memgit pro status` shows the last four characters. |
504
+ | | `MEMGIT_POLAR_API` | Overrides the licence-validation API base URL (testing). |
505
+ | | `MEMGIT_POLAR_ORG_ID` | Overrides the Polar organisation the licence is checked against. |
506
+ | Cloud sync | `MEMGIT_CLOUD_API` | Overrides the memgit cloud API base URL. |
507
+ | | `MEMGIT_CLOUD_APP` | Overrides the memgit cloud web app base URL used in printed links. |
508
+ | | `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. |
509
+ | Tuning | `MEMGIT_LOCK_TIMEOUT` | Seconds a writer waits for the store lock before giving up (default 10). |
510
+ | | `MEMGIT_IDLE_EVICT_SECONDS` | Idle seconds before the MCP server drops its in-memory caches while staying connected (default 900). |
511
+ | | `MEMGIT_HOUSEKEEPING_INTERVAL` | Seconds between the MCP server's housekeeping passes. |
512
+ | 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. |
513
+ | | `PYTEST_CURRENT_TEST` | Set by pytest. Its presence suppresses automatic backups, so a test run never writes into a real backup location. |
514
+
515
+ ### How the project label is decided
516
+
517
+ Memories are project-scoped, so a wrong label files a memory where you will not
518
+ find it again. The label comes from the first of these that yields one, and
519
+ there is exactly one detection path shared by the MCP server, the CLI and the
520
+ hooks — so a label derived at save time and one derived at recall time cannot
521
+ disagree:
522
+
523
+ 1. `MEMGIT_PROJECT` — a forced label, taken verbatim
524
+ 2. the `cwd` a host reports in its hook payload (the real workspace, even when
525
+ the process working directory is elsewhere)
526
+ 3. `CLAUDE_PROJECT_DIR`
527
+ 4. the process working directory
528
+
529
+ The label keeps `[A-Za-z0-9-]` and turns everything else into a dash, exactly as
530
+ Claude Code names its `projects/` directories — including the `_` character,
531
+ which becomes a dash. Labels written before v0.12.0 kept the `_`; they are
532
+ folded onto the dash form when two labels are *compared*, so nothing written
533
+ under the old form stops resolving, and nothing stored is rewritten.
534
+
535
+ A project whose memories have **split across two labels** raises no error
536
+ anywhere: the save succeeds, `fsck` stays clean, and the only symptom is an
537
+ answer that is missing things. `memgit doctor` reports splits, stranded labels
538
+ and the **save landing rate** by default; `memgit doctor --audit` adds every
539
+ row. A repair heals toward the label whose **directory exists**, never toward
540
+ the one holding more memories.
541
+
542
+ When none of them yields a label — running from `$HOME`, for instance — a write
543
+ is **quarantined** under `_unknown` rather than filed globally, and
544
+ `memgit doctor --relabel` moves it once you say where it belongs. Folding a
545
+ free-text label onto an existing project (`log-report` → `Downloads-log-report`)
546
+ happens only when the input is a trailing segment of exactly one known label and
547
+ that label already holds memories; anything ambiguous is left alone, and every
548
+ fold is reported rather than done silently.
549
+
550
+ ---
551
+
478
552
  ## TOON format — compact, readable, diffable
479
553
 
480
554
  Standard markdown memory file:
@@ -377,7 +377,9 @@ memgit eval run --misses 10 # inspect the cases where nothing relevant sur
377
377
  memgit squash # compress old history (archives what it collapses)
378
378
  memgit gc # reclaim disk: sweep unreachable objects + stale session caches
379
379
  memgit stats # measured context costs + disk usage
380
- memgit doctor # hygiene report: quarantined/_unknown memories, stale caches, orphaned usage
380
+ memgit doctor # hygiene + scope losses: split labels, quarantined, stale caches, orphaned usage
381
+ memgit doctor --audit # every split and stranded label, the save landing rate, throughput
382
+ memgit doctor --audit --json # the same report as JSON
381
383
  memgit doctor --relabel map.json # bulk re-project memories ({"slug": "Label" | ""}); one checkpoint
382
384
  memgit lint # validate all memories (flags unknown provenance)
383
385
  memgit fsck # verify store integrity
@@ -441,6 +443,78 @@ memgit thread list / switch / create
441
443
 
442
444
  ---
443
445
 
446
+ ## What memgit reads from your environment
447
+
448
+ memgit runs inside your agent, so the honest answer to "what does this process
449
+ read from my environment?" should be checkable rather than a promise. The table
450
+ below is generated from [`memgit/env.py`](memgit/env.py), and
451
+ `tests/test_env_inventory.py` walks the package on every test run and fails if
452
+ the code reads a name this list does not carry — or carries a name nothing
453
+ reads. The list cannot drift away from the code.
454
+
455
+ memgit reads these and nothing else. It writes none of them. None of them turn
456
+ network access on: the cloud endpoints are overrides for a sync you have already
457
+ opted into with `memgit cloud login`, and with no licence and no cloud login,
458
+ memgit talks to nothing.
459
+
460
+ | | Variable | What memgit does with it |
461
+ |---|---|---|
462
+ | 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. |
463
+ | | `MEMGIT_HOME` | Base directory for memgit's per-person state, currently the licence file (default ~/.memgit, mode 0600). |
464
+ | | `MEMGIT_PROJECT` | Forces the project label instead of detecting it from the working directory. |
465
+ | Attribution | `MEMGIT_AUTHOR` | Explicit author stamped on checkpoints, for multi-agent jobs where several writers share one machine account. Wins over MEMGIT_CLIENT. |
466
+ | | `MEMGIT_CLIENT` | The host that launched this process (claude-code, cursor, ...), stamped into checkpoints so a memory says where it came from. |
467
+ | | `USER` | Fallback author name when neither MEMGIT_AUTHOR nor MEMGIT_CLIENT is set. |
468
+ | | `USERNAME` | Windows fallback for USER. |
469
+ | Licensing (memgit Pro) | `MEMGIT_LICENSE_KEY` | memgit Pro licence key. Never printed in full — `memgit pro status` shows the last four characters. |
470
+ | | `MEMGIT_POLAR_API` | Overrides the licence-validation API base URL (testing). |
471
+ | | `MEMGIT_POLAR_ORG_ID` | Overrides the Polar organisation the licence is checked against. |
472
+ | Cloud sync | `MEMGIT_CLOUD_API` | Overrides the memgit cloud API base URL. |
473
+ | | `MEMGIT_CLOUD_APP` | Overrides the memgit cloud web app base URL used in printed links. |
474
+ | | `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. |
475
+ | Tuning | `MEMGIT_LOCK_TIMEOUT` | Seconds a writer waits for the store lock before giving up (default 10). |
476
+ | | `MEMGIT_IDLE_EVICT_SECONDS` | Idle seconds before the MCP server drops its in-memory caches while staying connected (default 900). |
477
+ | | `MEMGIT_HOUSEKEEPING_INTERVAL` | Seconds between the MCP server's housekeeping passes. |
478
+ | 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. |
479
+ | | `PYTEST_CURRENT_TEST` | Set by pytest. Its presence suppresses automatic backups, so a test run never writes into a real backup location. |
480
+
481
+ ### How the project label is decided
482
+
483
+ Memories are project-scoped, so a wrong label files a memory where you will not
484
+ find it again. The label comes from the first of these that yields one, and
485
+ there is exactly one detection path shared by the MCP server, the CLI and the
486
+ hooks — so a label derived at save time and one derived at recall time cannot
487
+ disagree:
488
+
489
+ 1. `MEMGIT_PROJECT` — a forced label, taken verbatim
490
+ 2. the `cwd` a host reports in its hook payload (the real workspace, even when
491
+ the process working directory is elsewhere)
492
+ 3. `CLAUDE_PROJECT_DIR`
493
+ 4. the process working directory
494
+
495
+ The label keeps `[A-Za-z0-9-]` and turns everything else into a dash, exactly as
496
+ Claude Code names its `projects/` directories — including the `_` character,
497
+ which becomes a dash. Labels written before v0.12.0 kept the `_`; they are
498
+ folded onto the dash form when two labels are *compared*, so nothing written
499
+ under the old form stops resolving, and nothing stored is rewritten.
500
+
501
+ A project whose memories have **split across two labels** raises no error
502
+ anywhere: the save succeeds, `fsck` stays clean, and the only symptom is an
503
+ answer that is missing things. `memgit doctor` reports splits, stranded labels
504
+ and the **save landing rate** by default; `memgit doctor --audit` adds every
505
+ row. A repair heals toward the label whose **directory exists**, never toward
506
+ the one holding more memories.
507
+
508
+ When none of them yields a label — running from `$HOME`, for instance — a write
509
+ is **quarantined** under `_unknown` rather than filed globally, and
510
+ `memgit doctor --relabel` moves it once you say where it belongs. Folding a
511
+ free-text label onto an existing project (`log-report` → `Downloads-log-report`)
512
+ happens only when the input is a trailing segment of exactly one known label and
513
+ that label already holds memories; anything ambiguous is left alone, and every
514
+ fold is reported rather than done silently.
515
+
516
+ ---
517
+
444
518
  ## TOON format — compact, readable, diffable
445
519
 
446
520
  Standard markdown memory file:
@@ -1,3 +1,3 @@
1
1
  """memgit — git for AI memory."""
2
2
 
3
- __version__ = "0.10.0"
3
+ __version__ = "0.12.0"
@@ -0,0 +1,363 @@
1
+ """Store audit: the end-to-end health read behind memgit doctor --audit.
2
+
3
+ doctor answers four narrow questions (quarantine, globals, stale caches, orphan
4
+ usage entries). It cannot see the failure that actually costs recall: a project
5
+ whose memories have SPLIT across two labels, so half the store is invisible from
6
+ the workspace that owns it. A split reports no error anywhere. Every save
7
+ succeeds, fsck stays clean, and the only symptom is an answer that is missing
8
+ things.
9
+
10
+ Four split mechanisms are detected here, each measured on the live store before
11
+ being written down:
12
+
13
+ * the directory MOVED, so new saves land on a new label and the old ones are
14
+ stranded (1,819 memories on a path that no longer exists);
15
+ * a label was typed by hand and misspelled (75 memories on a transposition);
16
+ * a short label was used where the workspace label was meant, which
17
+ canonical_project only heals while the short one has no memories of its
18
+ own (98 against 234);
19
+ * the two label derivations DISAGREE. project_label_from_path keeps the
20
+ _ character; Claude Code rewrites it as a dash, so
21
+ project_label_from_munged returns a different label for the same
22
+ directory. project.py's own docstring asserts these agree byte for byte.
23
+
24
+ Reads only. Nothing here writes to the store.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ from collections import Counter
31
+ from datetime import datetime, timedelta, timezone
32
+ from pathlib import Path
33
+ from typing import Iterable, Optional
34
+
35
+ from .project import (UNKNOWN_PROJECT, munge, normalize_label,
36
+ project_label_from_path, same_project_family)
37
+
38
+ #: How deep to walk below home looking for workspace directories. Three levels
39
+ #: reaches a component inside a project inside a client folder, which is the
40
+ #: deepest real workspace on the machine this was measured against.
41
+ SCAN_DEPTH = 3
42
+
43
+ #: Directories that never hold a workspace and cost the most to walk.
44
+ SKIP_DIRS = frozenset({
45
+ 'Library', 'node_modules', 'Applications', 'Movies', 'Music', 'Pictures',
46
+ 'Public', 'venv', '.venv', '__pycache__', 'dist', 'build', 'target',
47
+ })
48
+
49
+
50
+ def _iter_workspace_dirs(home: Path, depth: int = SCAN_DEPTH):
51
+ """Yield candidate workspace directories below home.
52
+
53
+ Hidden directories are skipped: a label derived from one is a tool's own
54
+ state directory, never a workspace someone works in.
55
+ """
56
+ for root, dirs, _files in os.walk(home):
57
+ here = Path(root)
58
+ try:
59
+ level = len(here.relative_to(home).parts)
60
+ except ValueError:
61
+ dirs[:] = []
62
+ continue
63
+ if level >= depth:
64
+ dirs[:] = []
65
+ else:
66
+ dirs[:] = [d for d in dirs
67
+ if not d.startswith('.') and d not in SKIP_DIRS]
68
+ if level:
69
+ yield here
70
+
71
+
72
+ def live_labels(home: Optional[Path] = None, depth: int = SCAN_DEPTH) -> dict:
73
+ """{label: path} for every directory below home that could be a workspace."""
74
+ home = home or Path.home()
75
+ out: dict = {}
76
+ for d in _iter_workspace_dirs(home, depth):
77
+ label = project_label_from_path(d, home)
78
+ if label and label not in out:
79
+ out[label] = str(d)
80
+ return out
81
+
82
+
83
+ def _is_live(label: str, live: dict) -> bool:
84
+ """True when a directory exists for this label, comparing NORMALIZED forms.
85
+
86
+ A label written before the munge fix carries the _ character and detection
87
+ now returns the dash form, so a raw string comparison calls every one of
88
+ them stranded. Measured on the live store the moment the fixed binary was
89
+ installed: the strand count jumped by 266 memories across three projects
90
+ that resolve perfectly well, because comparison folds the two forms
91
+ everywhere except here.
92
+ """
93
+ return normalize_label(label) in {normalize_label(k) for k in live}
94
+
95
+
96
+ def _edit_distance(a: str, b: str, cap: int = 2) -> int:
97
+ """Levenshtein distance, giving up once it passes cap.
98
+
99
+ Bounded because the only question asked of it is "is this a typo of that",
100
+ and an unbounded distance over 45 labels squared is wasted work.
101
+ """
102
+ if abs(len(a) - len(b)) > cap:
103
+ return cap + 1
104
+ prev = list(range(len(b) + 1))
105
+ for i, ca in enumerate(a, 1):
106
+ cur = [i]
107
+ for j, cb in enumerate(b, 1):
108
+ cur.append(min(prev[j] + 1, cur[j - 1] + 1,
109
+ prev[j - 1] + (ca != cb)))
110
+ if min(cur) > cap:
111
+ return cap + 1
112
+ prev = cur
113
+ return prev[-1]
114
+
115
+
116
+ def _dash_form(label: str) -> str:
117
+ """The label Claude Code would produce for the same path.
118
+
119
+ Claude Code keeps only letters, digits and dashes, so it rewrites the
120
+ _ character that project_label_from_path preserves.
121
+ """
122
+ return label.replace('_', '-')
123
+
124
+
125
+ def label_splits(counts: dict, live: Optional[dict] = None) -> list:
126
+ """Label pairs that name one project and hold two separate stores.
127
+
128
+ counts is {label: memory count}, live the {label: path} of directories
129
+ that exist. Each result carries the mechanism, so a repair can be
130
+ mechanical rather than a judgement.
131
+
132
+ Which label survives is decided by the DIRECTORY, not by the memory count.
133
+ The count is the wrong signal and the live store shows why three times
134
+ over: the dead label Downloads-log-report holds 1,819 memories against 18
135
+ on the live one, and the misspelled masagrti-Networking-pro holds 75
136
+ against 18. Healing toward the bigger pile would keep the label no session
137
+ will ever run under and strand the work a second time. The count breaks
138
+ ties only when both labels are live, or neither is.
139
+
140
+ Family pairs are NOT splits. A label and its own sub-path already see each
141
+ other through same_project_family, so reporting them would bury the real
142
+ findings under every component of every project.
143
+ """
144
+ live = live or {}
145
+ labels = [l for l in counts if l and l != UNKNOWN_PROJECT]
146
+ seen: set = set()
147
+ out: list = []
148
+ for i, a in enumerate(labels):
149
+ for b in labels[i + 1:]:
150
+ # The munge check runs BEFORE the family skip. Comparison folds the
151
+ # two forms together, so a munge pair is family and no longer costs
152
+ # recall, but it is still two labels in the store and a repair
153
+ # should still collapse it. It reports with costs_recall False.
154
+ kind = None
155
+ if _dash_form(a) == _dash_form(b) and a != b:
156
+ kind = 'munge'
157
+ elif same_project_family(a, b):
158
+ continue
159
+ elif a.lower() == b.lower():
160
+ kind = 'case'
161
+ elif a.endswith('-' + b) or b.endswith('-' + a):
162
+ kind = 'short-label'
163
+ elif _edit_distance(a.lower(), b.lower()) <= 2:
164
+ kind = 'typo'
165
+ if not kind:
166
+ continue
167
+ pair = tuple(sorted((a, b)))
168
+ if pair in seen:
169
+ continue
170
+ seen.add(pair)
171
+ keep = _survivor(a, b, kind, counts, live)
172
+ drop = b if keep == a else a
173
+ out.append({
174
+ 'kind': kind,
175
+ 'keep': keep,
176
+ 'drop': drop,
177
+ 'keep_count': counts.get(keep, 0),
178
+ 'drop_count': counts.get(drop, 0),
179
+ 'keep_is_live': _is_live(keep, live),
180
+ 'costs_recall': kind != 'munge',
181
+ })
182
+ out.sort(key=lambda r: -r['drop_count'])
183
+ return out
184
+
185
+
186
+ def _survivor(a: str, b: str, kind: str, counts: dict, live: dict) -> str:
187
+ """Which of two split labels the memories should be healed onto.
188
+
189
+ A munge disagreement is decided by the format, not by the filesystem: only
190
+ the dash form is reachable from both label derivations, so it wins even
191
+ when its directory carries the _ character.
192
+ """
193
+ if kind == 'munge':
194
+ return a if '_' not in a else b
195
+ a_live, b_live = _is_live(a, live), _is_live(b, live)
196
+ if a_live != b_live:
197
+ return a if a_live else b
198
+ return a if counts.get(a, 0) >= counts.get(b, 0) else b
199
+
200
+
201
+ def _trailing_containment(a: str, b: str) -> bool:
202
+ """True when one label is exactly a trailing segment run of the other.
203
+
204
+ This is the relation a move or a short label produces: FittyMe against
205
+ Freelance-FittyMe, log-report against Downloads-log-report. Matching on
206
+ the last segment alone is far too loose and the live store proves it.
207
+ Freelance-logistics-crm and Freelance-FittyMe-fittyme-crm share the
208
+ segment crm and are two different clients.
209
+ """
210
+ a, b = normalize_label(a) or '', normalize_label(b) or ''
211
+ return a == b or a.endswith('-' + b) or b.endswith('-' + a)
212
+
213
+
214
+ def stranded_labels(counts: dict, live: dict) -> list:
215
+ """Labels with memories but no directory, and where the work went.
216
+
217
+ A label whose directory is gone is where a project's history goes to die:
218
+ every new save lands on the label of wherever the work moved to, and
219
+ nothing points back. successor names the live label that most likely
220
+ owns the work now, and is None when no single candidate stands out,
221
+ because guessing files a memory under the wrong project.
222
+
223
+ A candidate has to clear two bars. It must be a trailing segment run of the
224
+ stranded label or contain one, and it must already HOLD memories, because a
225
+ successor is where the work continued and a directory nobody has saved
226
+ against is not that. The second bar is what keeps a dated recovery copy of
227
+ an archived project from being named the successor of the original.
228
+
229
+ What survives both is then ranked by how deep the directory sits below
230
+ home, and the shallowest unique one wins. A project that moved sits at the
231
+ top of the tree; copies of it inside a recovery folder carry the same name
232
+ one level down. The live store holds three copies of log-report, so without
233
+ the depth rank the largest split in the store reports no successor at all.
234
+ """
235
+ out: list = []
236
+ for label, n in counts.items():
237
+ if not label or label == UNKNOWN_PROJECT or _is_live(label, live):
238
+ continue
239
+ cands = [l for l in live
240
+ if l != label and counts.get(l, 0) > 0
241
+ and _trailing_containment(label, l)]
242
+ ranked = sorted(cands, key=lambda l: (str(live[l]).count(os.sep), l))
243
+ successor = None
244
+ if len(ranked) == 1:
245
+ successor = ranked[0]
246
+ elif len(ranked) > 1:
247
+ top = str(live[ranked[0]]).count(os.sep)
248
+ if str(live[ranked[1]]).count(os.sep) > top:
249
+ successor = ranked[0]
250
+ out.append({
251
+ 'label': label,
252
+ 'count': n,
253
+ 'successor': successor,
254
+ 'candidates': ranked,
255
+ })
256
+ out.sort(key=lambda r: -r['count'])
257
+ return out
258
+
259
+
260
+ def home_labels(counts: dict, home: Optional[Path] = None) -> list:
261
+ """Labels that are the munged home path itself.
262
+
263
+ Home is not a project. project_label_from_path returns None for home
264
+ exactly, but a label can still reach the store from another machine, from
265
+ an explicit project argument, or from a path that resolved differently.
266
+ Such a memory is a machine-level fact filed under a project no session ever
267
+ runs in, so it surfaces nowhere. It belongs in global scope.
268
+ """
269
+ home = home or Path.home()
270
+ munged = munge(str(home)).lstrip('-')
271
+ return [{'label': l, 'count': n} for l, n in counts.items()
272
+ if l and l == munged]
273
+
274
+
275
+ def dangling_links(mnemonics: Iterable) -> dict:
276
+ """Supersedes and related edges pointing at slugs that do not exist.
277
+
278
+ A dangling supersedes is the worse half: the chain resolver cannot tell
279
+ that this memory retired something, so a reader cannot tell which claim
280
+ won.
281
+ """
282
+ mems = list(mnemonics)
283
+ known = {m.slug for m in mems}
284
+ sup = [{'from': m.slug, 'to': s} for m in mems
285
+ for s in (m.supersedes or []) if s not in known]
286
+ rel = [{'from': m.slug, 'to': s} for m in mems
287
+ for s in (m.related or []) if s not in known]
288
+ return {'supersedes': sup, 'related': rel}
289
+
290
+
291
+ def save_rate(mnemonics: Iterable, now: Optional[datetime] = None) -> dict:
292
+ """Throughput and landing quality.
293
+
294
+ landing_rate is the share of saves that reached a real project scope.
295
+ Quarantined saves did not, and neither did saves on a stranded or home
296
+ label, so all three are counted against it. A raw quarantine count hides
297
+ the splits, which are the larger loss.
298
+ """
299
+ mems = list(mnemonics)
300
+ now = now or datetime.now(timezone.utc)
301
+ by_day = Counter(m.timestamp.strftime('%Y-%m-%d') for m in mems)
302
+ by_month = Counter(m.timestamp.strftime('%Y-%m') for m in mems)
303
+ cutoff = now - timedelta(days=30)
304
+ recent = [m for m in mems if m.timestamp >= cutoff]
305
+ return {
306
+ 'total': len(mems),
307
+ 'by_day': dict(sorted(by_day.items())),
308
+ 'by_month': dict(sorted(by_month.items())),
309
+ 'last_30d': len(recent),
310
+ 'per_day_30d': round(len(recent) / 30.0, 1),
311
+ 'by_project_30d': dict(Counter(m.project or '(global)'
312
+ for m in recent).most_common()),
313
+ }
314
+
315
+
316
+ def audit(repo, home: Optional[Path] = None, depth: int = SCAN_DEPTH) -> dict:
317
+ """The whole health read. Reads the store, writes nothing."""
318
+ home = home or Path.home()
319
+ mems = repo.list()
320
+ counts = Counter(m.project for m in mems if m.project)
321
+ live = live_labels(home, depth)
322
+
323
+ splits = label_splits(counts, live)
324
+ stranded = stranded_labels(counts, live)
325
+ homed = home_labels(counts, home)
326
+ dangling = dangling_links(mems)
327
+ quarantined = [m.slug for m in mems if m.project == UNKNOWN_PROJECT]
328
+
329
+ # Three ways a save misses its scope, reported separately because they have
330
+ # different fixes and different severity. Collapsing them into one
331
+ # percentage hides that almost all of it is one relabel away, and reads as
332
+ # if half the store were unrecoverable.
333
+ split_drops = {r['drop'] for r in splits if r['costs_recall']}
334
+ stranded_set = {r['label'] for r in stranded}
335
+ home_set = {r['label'] for r in homed}
336
+ by_cause = {'quarantined': set(quarantined), 'split': set(), 'stranded': set()}
337
+ for m in mems:
338
+ if m.project in split_drops:
339
+ by_cause['split'].add(m.slug)
340
+ elif m.project in stranded_set or m.project in home_set:
341
+ by_cause['stranded'].add(m.slug)
342
+ lost = set().union(*by_cause.values())
343
+
344
+ total = len(mems)
345
+ rate = save_rate(mems)
346
+ rate['misfiled'] = len(lost)
347
+ rate['misfiled_by_cause'] = {k: len(v) for k, v in by_cause.items()}
348
+ rate['recoverable'] = len(by_cause['split']) + len(by_cause['stranded'])
349
+ rate['landing_rate'] = round(1 - len(lost) / total, 4) if total else 1.0
350
+
351
+ return {
352
+ 'total': total,
353
+ 'labels': len(counts),
354
+ 'live_labels': len(live),
355
+ 'by_project': dict(counts.most_common()),
356
+ 'splits': splits,
357
+ 'stranded': stranded,
358
+ 'home_labels': homed,
359
+ 'quarantined': quarantined,
360
+ 'dangling': dangling,
361
+ 'save_rate': rate,
362
+ 'globals': sum(1 for m in mems if m.project is None),
363
+ }