memgit 0.11.0__tar.gz → 0.13.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.
- {memgit-0.11.0 → memgit-0.13.0}/PKG-INFO +21 -2
- {memgit-0.11.0 → memgit-0.13.0}/README.md +20 -1
- {memgit-0.11.0 → memgit-0.13.0}/memgit/__init__.py +1 -1
- memgit-0.13.0/memgit/audit.py +363 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/cli.py +163 -1
- {memgit-0.11.0 → memgit-0.13.0}/memgit/delivery.py +3 -1
- {memgit-0.11.0 → memgit-0.13.0}/memgit/hooks.py +5 -2
- {memgit-0.11.0 → memgit-0.13.0}/memgit/http_server.py +5 -0
- memgit-0.13.0/memgit/markup.py +278 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/mcp_server.py +78 -9
- {memgit-0.11.0 → memgit-0.13.0}/memgit/models.py +6 -0
- memgit-0.13.0/memgit/org.py +320 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/project.py +44 -7
- {memgit-0.11.0 → memgit-0.13.0}/memgit/repo.py +8 -5
- memgit-0.13.0/memgit/tiers.py +210 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/toon.py +13 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit.egg-info/PKG-INFO +21 -2
- {memgit-0.11.0 → memgit-0.13.0}/memgit.egg-info/SOURCES.txt +8 -0
- {memgit-0.11.0 → memgit-0.13.0}/pyproject.toml +1 -1
- memgit-0.13.0/tests/test_audit.py +231 -0
- memgit-0.13.0/tests/test_markup.py +233 -0
- memgit-0.13.0/tests/test_org.py +188 -0
- memgit-0.13.0/tests/test_tiers.py +159 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_v040.py +45 -5
- {memgit-0.11.0 → memgit-0.13.0}/LICENSE +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/backup.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/cloud/__init__.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/cloud/client.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/cloud/commands.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/cloud/crypto.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/cloud/state.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/cloud/sync.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/env.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/evaluate.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/gitdigest.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/graph.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/importer.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/license.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/links.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/metrics.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/sanitize.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/savings.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/scorer.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/store.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/tokens.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit/usage.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit.egg-info/dependency_links.txt +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit.egg-info/entry_points.txt +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit.egg-info/requires.txt +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/memgit.egg-info/top_level.txt +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/setup.cfg +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_accrue.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_advanced.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_aliases.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_backup.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_cloud_guard.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_core.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_delivery.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_dependency_floor.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_env_inventory.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_git_args.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_license.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_lock_exclusion.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_savings.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_server_lifecycle.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_setup.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_store_repo.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_toon.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_v020.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_v030.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_v060.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_v070.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_v080.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_v081.py +0 -0
- {memgit-0.11.0 → memgit-0.13.0}/tests/test_version_stamps.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memgit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.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
|
|
@@ -284,6 +284,8 @@ Once registered via MCP, every AI tool gets 6 tools:
|
|
|
284
284
|
|
|
285
285
|
The tool descriptions teach the AI **judgment** — "does this request depend on state you don't have in context?" — rather than keyword triggers. Measured cost of the whole tool surface: ~1,150 tokens once per session; a `resume_session` reply is ~335.
|
|
286
286
|
|
|
287
|
+
**A save that arrives damaged is refused, not stored** (v0.13.0). A model sometimes closes a value with a tag named after the field (`</rule>`), and the host then delivers the next parameters inside that value: `why`, `when`, `tags` and `type_code` never arrive, and the memory is stored with defaults. On one machine that was 748 of 3,025 saves. `save_memory` now refuses any value carrying tool-call markup, names the field and the parameters it swallowed, and the model retries in the same turn. Markup quoted on purpose inside backticks is allowed. Every successful save echoes the length of each field it stored, which fields arrived empty, and which values came from a default. Damaged memories already in a store are never served back into context: the digest, the recall hook and read results show each field cut at the damage. `memgit doctor` counts them, and `memgit doctor --repair-markup` splits them back into their fields.
|
|
288
|
+
|
|
287
289
|
---
|
|
288
290
|
|
|
289
291
|
## Core operating guide (v0.5.0)
|
|
@@ -411,8 +413,12 @@ memgit eval run --misses 10 # inspect the cases where nothing relevant sur
|
|
|
411
413
|
memgit squash # compress old history (archives what it collapses)
|
|
412
414
|
memgit gc # reclaim disk: sweep unreachable objects + stale session caches
|
|
413
415
|
memgit stats # measured context costs + disk usage
|
|
414
|
-
memgit doctor # hygiene
|
|
416
|
+
memgit doctor # hygiene + scope losses: split labels, quarantined, stale caches, orphaned usage
|
|
417
|
+
memgit doctor --audit # every split and stranded label, the save landing rate, throughput
|
|
418
|
+
memgit doctor --audit --json # the same report as JSON
|
|
415
419
|
memgit doctor --relabel map.json # bulk re-project memories ({"slug": "Label" | ""}); one checkpoint
|
|
420
|
+
memgit doctor --repair-markup # dry run: memories whose fields swallowed other parameters on save
|
|
421
|
+
memgit doctor --repair-markup --yes [--project LABEL] # split them back into their fields; one checkpoint
|
|
416
422
|
memgit lint # validate all memories (flags unknown provenance)
|
|
417
423
|
memgit fsck # verify store integrity
|
|
418
424
|
|
|
@@ -524,6 +530,19 @@ disagree:
|
|
|
524
530
|
3. `CLAUDE_PROJECT_DIR`
|
|
525
531
|
4. the process working directory
|
|
526
532
|
|
|
533
|
+
The label keeps `[A-Za-z0-9-]` and turns everything else into a dash, exactly as
|
|
534
|
+
Claude Code names its `projects/` directories — including the `_` character,
|
|
535
|
+
which becomes a dash. Labels written before v0.12.0 kept the `_`; they are
|
|
536
|
+
folded onto the dash form when two labels are *compared*, so nothing written
|
|
537
|
+
under the old form stops resolving, and nothing stored is rewritten.
|
|
538
|
+
|
|
539
|
+
A project whose memories have **split across two labels** raises no error
|
|
540
|
+
anywhere: the save succeeds, `fsck` stays clean, and the only symptom is an
|
|
541
|
+
answer that is missing things. `memgit doctor` reports splits, stranded labels
|
|
542
|
+
and the **save landing rate** by default; `memgit doctor --audit` adds every
|
|
543
|
+
row. A repair heals toward the label whose **directory exists**, never toward
|
|
544
|
+
the one holding more memories.
|
|
545
|
+
|
|
527
546
|
When none of them yields a label — running from `$HOME`, for instance — a write
|
|
528
547
|
is **quarantined** under `_unknown` rather than filed globally, and
|
|
529
548
|
`memgit doctor --relabel` moves it once you say where it belongs. Folding a
|
|
@@ -250,6 +250,8 @@ Once registered via MCP, every AI tool gets 6 tools:
|
|
|
250
250
|
|
|
251
251
|
The tool descriptions teach the AI **judgment** — "does this request depend on state you don't have in context?" — rather than keyword triggers. Measured cost of the whole tool surface: ~1,150 tokens once per session; a `resume_session` reply is ~335.
|
|
252
252
|
|
|
253
|
+
**A save that arrives damaged is refused, not stored** (v0.13.0). A model sometimes closes a value with a tag named after the field (`</rule>`), and the host then delivers the next parameters inside that value: `why`, `when`, `tags` and `type_code` never arrive, and the memory is stored with defaults. On one machine that was 748 of 3,025 saves. `save_memory` now refuses any value carrying tool-call markup, names the field and the parameters it swallowed, and the model retries in the same turn. Markup quoted on purpose inside backticks is allowed. Every successful save echoes the length of each field it stored, which fields arrived empty, and which values came from a default. Damaged memories already in a store are never served back into context: the digest, the recall hook and read results show each field cut at the damage. `memgit doctor` counts them, and `memgit doctor --repair-markup` splits them back into their fields.
|
|
254
|
+
|
|
253
255
|
---
|
|
254
256
|
|
|
255
257
|
## Core operating guide (v0.5.0)
|
|
@@ -377,8 +379,12 @@ memgit eval run --misses 10 # inspect the cases where nothing relevant sur
|
|
|
377
379
|
memgit squash # compress old history (archives what it collapses)
|
|
378
380
|
memgit gc # reclaim disk: sweep unreachable objects + stale session caches
|
|
379
381
|
memgit stats # measured context costs + disk usage
|
|
380
|
-
memgit doctor # hygiene
|
|
382
|
+
memgit doctor # hygiene + scope losses: split labels, quarantined, stale caches, orphaned usage
|
|
383
|
+
memgit doctor --audit # every split and stranded label, the save landing rate, throughput
|
|
384
|
+
memgit doctor --audit --json # the same report as JSON
|
|
381
385
|
memgit doctor --relabel map.json # bulk re-project memories ({"slug": "Label" | ""}); one checkpoint
|
|
386
|
+
memgit doctor --repair-markup # dry run: memories whose fields swallowed other parameters on save
|
|
387
|
+
memgit doctor --repair-markup --yes [--project LABEL] # split them back into their fields; one checkpoint
|
|
382
388
|
memgit lint # validate all memories (flags unknown provenance)
|
|
383
389
|
memgit fsck # verify store integrity
|
|
384
390
|
|
|
@@ -490,6 +496,19 @@ disagree:
|
|
|
490
496
|
3. `CLAUDE_PROJECT_DIR`
|
|
491
497
|
4. the process working directory
|
|
492
498
|
|
|
499
|
+
The label keeps `[A-Za-z0-9-]` and turns everything else into a dash, exactly as
|
|
500
|
+
Claude Code names its `projects/` directories — including the `_` character,
|
|
501
|
+
which becomes a dash. Labels written before v0.12.0 kept the `_`; they are
|
|
502
|
+
folded onto the dash form when two labels are *compared*, so nothing written
|
|
503
|
+
under the old form stops resolving, and nothing stored is rewritten.
|
|
504
|
+
|
|
505
|
+
A project whose memories have **split across two labels** raises no error
|
|
506
|
+
anywhere: the save succeeds, `fsck` stays clean, and the only symptom is an
|
|
507
|
+
answer that is missing things. `memgit doctor` reports splits, stranded labels
|
|
508
|
+
and the **save landing rate** by default; `memgit doctor --audit` adds every
|
|
509
|
+
row. A repair heals toward the label whose **directory exists**, never toward
|
|
510
|
+
the one holding more memories.
|
|
511
|
+
|
|
493
512
|
When none of them yields a label — running from `$HOME`, for instance — a write
|
|
494
513
|
is **quarantined** under `_unknown` rather than filed globally, and
|
|
495
514
|
`memgit doctor --relabel` moves it once you say where it belongs. Folding a
|
|
@@ -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
|
+
}
|
|
@@ -193,6 +193,15 @@ def add(slug, rule, type_code, why, when, tags, priority, body, project,
|
|
|
193
193
|
tag_list = [t.strip() for t in tags.split(',')] if tags else []
|
|
194
194
|
if body == '-':
|
|
195
195
|
body = sys.stdin.read().strip() or None
|
|
196
|
+
from .markup import check_save_arguments
|
|
197
|
+
problems = check_save_arguments(
|
|
198
|
+
{'slug': slug, 'rule': rule, 'why': why, 'when': when, 'body': body,
|
|
199
|
+
'tags': tag_list})
|
|
200
|
+
if problems:
|
|
201
|
+
err.print('[red]refused: ' + '; '.join(problems) + '. Nothing was '
|
|
202
|
+
'saved. Wrap markup in backticks to quote it on '
|
|
203
|
+
'purpose.[/red]')
|
|
204
|
+
sys.exit(1)
|
|
196
205
|
# Same scoping semantics as MCP save_memory: absent → this workspace,
|
|
197
206
|
# --global (or explicit empty string) → deliberately global. Detection
|
|
198
207
|
# failing must never silently produce a global memory — quarantine it
|
|
@@ -2486,7 +2495,20 @@ def gc(dry_run, squash_keep, reflog_keep, fmt_json):
|
|
|
2486
2495
|
@click.option('--prune-session', 'prune_sessions', multiple=True, metavar='ID',
|
|
2487
2496
|
help='Delete the named session-cache files across all cache '
|
|
2488
2497
|
'kinds (e.g. leftover test artifacts; repeatable)')
|
|
2489
|
-
|
|
2498
|
+
@click.option('--audit', 'full_audit', is_flag=True,
|
|
2499
|
+
help='Full scope audit: every split and stranded label, the '
|
|
2500
|
+
'save landing rate, and throughput')
|
|
2501
|
+
@click.option('--json', 'fmt_json', is_flag=True,
|
|
2502
|
+
help='Emit the audit as JSON')
|
|
2503
|
+
@click.option('--repair-markup', is_flag=True,
|
|
2504
|
+
help='Split memories whose fields carry tool-call markup back '
|
|
2505
|
+
'into their fields. Dry run unless --yes is given')
|
|
2506
|
+
@click.option('--project', 'repair_project', default=None, metavar='LABEL',
|
|
2507
|
+
help='With --repair-markup: only this project')
|
|
2508
|
+
@click.option('--yes', 'confirm', is_flag=True,
|
|
2509
|
+
help='With --repair-markup: write the repair as one checkpoint')
|
|
2510
|
+
def doctor(relabel_path, prune_slugs, clean_caches, prune_sessions,
|
|
2511
|
+
full_audit, fmt_json, repair_markup, repair_project, confirm):
|
|
2490
2512
|
"""Diagnose and repair store hygiene: provenance, usage ledger, caches.
|
|
2491
2513
|
|
|
2492
2514
|
With no options, prints a report: quarantined (`_unknown`) and
|
|
@@ -2569,6 +2591,10 @@ def doctor(relabel_path, prune_slugs, clean_caches, prune_sessions):
|
|
|
2569
2591
|
console.print(f'[green]doctor[/green] removed {deleted} stale + '
|
|
2570
2592
|
f'{pruned} named session-cache file(s)')
|
|
2571
2593
|
|
|
2594
|
+
if repair_markup:
|
|
2595
|
+
acted = True
|
|
2596
|
+
_repair_markup(repo, repair_project, confirm)
|
|
2597
|
+
|
|
2572
2598
|
if acted:
|
|
2573
2599
|
return
|
|
2574
2600
|
|
|
@@ -2613,6 +2639,142 @@ def doctor(relabel_path, prune_slugs, clean_caches, prune_sessions):
|
|
|
2613
2639
|
else:
|
|
2614
2640
|
console.print('usage ledger clean — every entry maps to a live memory')
|
|
2615
2641
|
|
|
2642
|
+
# Scope losses run in the DEFAULT report, not behind the flag. A split
|
|
2643
|
+
# raises no error and passes fsck, so the only way anyone learns of one is
|
|
2644
|
+
# a report that volunteers it. `--audit` adds the detail, never the finding.
|
|
2645
|
+
from .markup import TEXT_FIELDS, has_markup
|
|
2646
|
+
damaged = [m for m in mnemonics
|
|
2647
|
+
if any(has_markup(getattr(m, f)) for f in TEXT_FIELDS)]
|
|
2648
|
+
if damaged:
|
|
2649
|
+
by_proj = Counter(m.project or '(global)' for m in damaged)
|
|
2650
|
+
console.print(f'[yellow]{len(damaged)} memories carry tool-call markup '
|
|
2651
|
+
f'in their fields[/yellow] — parameters swallowed on '
|
|
2652
|
+
f'save; repair with `memgit doctor --repair-markup` '
|
|
2653
|
+
f'(dry run), then add --yes')
|
|
2654
|
+
console.print(' by project: ' + ' · '.join(
|
|
2655
|
+
f'{p} ({n})' for p, n in by_proj.most_common(8)))
|
|
2656
|
+
else:
|
|
2657
|
+
console.print('no memory carries tool-call markup in its fields')
|
|
2658
|
+
|
|
2659
|
+
from .audit import audit as _audit
|
|
2660
|
+
rep = _audit(repo)
|
|
2661
|
+
_render_scope_losses(rep, full_audit)
|
|
2662
|
+
if fmt_json:
|
|
2663
|
+
console.print_json(data=rep)
|
|
2664
|
+
|
|
2665
|
+
|
|
2666
|
+
def _repair_markup(repo, project, confirm) -> None:
|
|
2667
|
+
"""Split damaged memories back into their fields; dry run without confirm.
|
|
2668
|
+
|
|
2669
|
+
Covers superseded memories too: a swallowed supersedes inside one of them
|
|
2670
|
+
is still a broken chain. One checkpoint for the whole repair, so a single
|
|
2671
|
+
`memgit rollback` undoes it while no other save has landed since.
|
|
2672
|
+
"""
|
|
2673
|
+
from .markup import repair
|
|
2674
|
+
mems = repo.list()
|
|
2675
|
+
if project is not None:
|
|
2676
|
+
mems = [m for m in mems if (m.project or '') == project]
|
|
2677
|
+
reps = [r for r in (repair(m) for m in mems) if r is not None]
|
|
2678
|
+
fixed = [r for r in reps if r.fixed is not None]
|
|
2679
|
+
failed = [r for r in reps if r.fixed is None]
|
|
2680
|
+
retyped = [r for r in fixed if r.type_changed]
|
|
2681
|
+
conflicts = [r for r in fixed if r.conflicts]
|
|
2682
|
+
mode = 'repaired' if confirm else 'would repair'
|
|
2683
|
+
console.print(f'[bold]doctor --repair-markup[/bold] {mode} {len(fixed)} '
|
|
2684
|
+
f'memor{"ies" if len(fixed) != 1 else "y"}'
|
|
2685
|
+
+ (f' in {project}' if project is not None else ''))
|
|
2686
|
+
for r in retyped:
|
|
2687
|
+
console.print(f' type {r.slug}: {r.type_changed[0]} → '
|
|
2688
|
+
f'{r.type_changed[1]}')
|
|
2689
|
+
for r in conflicts:
|
|
2690
|
+
console.print(f' [yellow]merged[/yellow] {r.slug}: '
|
|
2691
|
+
+ ', '.join(f'{a}→{b}' for a, b in r.conflicts)
|
|
2692
|
+
+ ' held different text; both kept')
|
|
2693
|
+
for r in failed:
|
|
2694
|
+
console.print(f' [red]skipped[/red] {r.slug}: '
|
|
2695
|
+
+ '; '.join(r.unparsed))
|
|
2696
|
+
if not fixed:
|
|
2697
|
+
return
|
|
2698
|
+
if not confirm:
|
|
2699
|
+
console.print('[dim]dry run — nothing written; add --yes to apply[/dim]')
|
|
2700
|
+
return
|
|
2701
|
+
for r in fixed:
|
|
2702
|
+
repo.add(r.fixed)
|
|
2703
|
+
sha = repo.commit(
|
|
2704
|
+
message=f'doctor: repair {len(fixed)} memories with tool-call markup '
|
|
2705
|
+
'in their fields', trigger='explicit')
|
|
2706
|
+
console.print(f'[green]doctor[/green] checkpoint {(sha or "")[:8]}')
|
|
2707
|
+
|
|
2708
|
+
|
|
2709
|
+
def _render_scope_losses(rep: dict, full: bool) -> None:
|
|
2710
|
+
"""Print the split, strand and landing-rate findings from an audit."""
|
|
2711
|
+
splits, stranded = rep['splits'], rep['stranded']
|
|
2712
|
+
rate = rep['save_rate']
|
|
2713
|
+
homed = rep['home_labels']
|
|
2714
|
+
orphaned = [r for r in stranded if not r['successor']]
|
|
2715
|
+
|
|
2716
|
+
if not (splits or stranded or homed):
|
|
2717
|
+
console.print('[green]no split or stranded labels[/green] — every '
|
|
2718
|
+
'memory is reachable from its own workspace')
|
|
2719
|
+
return
|
|
2720
|
+
|
|
2721
|
+
lost = rate['misfiled']
|
|
2722
|
+
console.print(f'\n[yellow]{lost} of {rep["total"]} memories do not surface '
|
|
2723
|
+
f'in the workspace that owns them[/yellow] '
|
|
2724
|
+
f'(landing rate {rate["landing_rate"]:.1%}) — '
|
|
2725
|
+
f'{rate["recoverable"]} of them are one relabel away')
|
|
2726
|
+
|
|
2727
|
+
if splits:
|
|
2728
|
+
console.print(f'[bold]{len(splits)} split label(s)[/bold] — one project, '
|
|
2729
|
+
'two separate stores:')
|
|
2730
|
+
for s in splits if full else splits[:5]:
|
|
2731
|
+
live = '' if s['keep_is_live'] else ' [dim](neither is live)[/dim]'
|
|
2732
|
+
console.print(f' [dim]{s["kind"]:12s}[/dim] {s["drop"]} '
|
|
2733
|
+
f'({s["drop_count"]}) → {s["keep"]} '
|
|
2734
|
+
f'({s["keep_count"]}){live}')
|
|
2735
|
+
if not full and len(splits) > 5:
|
|
2736
|
+
console.print(f' [dim]… and {len(splits) - 5} more, see --audit[/dim]')
|
|
2737
|
+
|
|
2738
|
+
# A moved directory trips both detectors, so the same label would print
|
|
2739
|
+
# twice under two headings. The split row already names where it heals to.
|
|
2740
|
+
already = {s['drop'] for s in splits} | {s['keep'] for s in splits}
|
|
2741
|
+
healable = [r for r in stranded if r['successor'] and r['label'] not in already]
|
|
2742
|
+
if healable:
|
|
2743
|
+
console.print(f'[bold]{len(healable)} stranded label(s)[/bold] with a '
|
|
2744
|
+
'successor — the directory moved:')
|
|
2745
|
+
for r in healable if full else healable[:5]:
|
|
2746
|
+
console.print(f' {r["label"]} ({r["count"]}) → {r["successor"]}')
|
|
2747
|
+
|
|
2748
|
+
if homed:
|
|
2749
|
+
for r in homed:
|
|
2750
|
+
console.print(f'[yellow]{r["count"]} memories labelled '
|
|
2751
|
+
f'{r["label"]}[/yellow] — that is the home directory, '
|
|
2752
|
+
'not a project; they belong in global scope')
|
|
2753
|
+
|
|
2754
|
+
if orphaned:
|
|
2755
|
+
console.print(f'{len(orphaned)} label(s) with no directory and no '
|
|
2756
|
+
'successor, holding '
|
|
2757
|
+
f'{sum(r["count"] for r in orphaned)} memories')
|
|
2758
|
+
if full:
|
|
2759
|
+
for r in orphaned:
|
|
2760
|
+
console.print(f' [dim]{r["label"]} ({r["count"]})[/dim]')
|
|
2761
|
+
|
|
2762
|
+
d = rep['dangling']
|
|
2763
|
+
n_d = len(d['supersedes']) + len(d['related'])
|
|
2764
|
+
if n_d:
|
|
2765
|
+
console.print(f'{n_d} dangling link(s) '
|
|
2766
|
+
f'({len(d["supersedes"])} supersedes, '
|
|
2767
|
+
f'{len(d["related"])} related) point at slugs that do '
|
|
2768
|
+
'not exist')
|
|
2769
|
+
|
|
2770
|
+
if full:
|
|
2771
|
+
console.print(f'\n[bold]throughput[/bold] {rate["total"]} total · '
|
|
2772
|
+
f'{rate["last_30d"]} in 30 days · '
|
|
2773
|
+
f'{rate["per_day_30d"]}/day')
|
|
2774
|
+
top = list(rate['by_project_30d'].items())[:8]
|
|
2775
|
+
for label, n in top:
|
|
2776
|
+
console.print(f' {n:6d} {label}')
|
|
2777
|
+
|
|
2616
2778
|
|
|
2617
2779
|
# ── merge ─────────────────────────────────────────────────────────────────────
|
|
2618
2780
|
|
|
@@ -31,6 +31,8 @@ from dataclasses import dataclass, field
|
|
|
31
31
|
from pathlib import Path
|
|
32
32
|
from typing import Callable, Optional
|
|
33
33
|
|
|
34
|
+
from .markup import clean_for_context
|
|
35
|
+
|
|
34
36
|
MARKER_START = "<!-- MEMGIT:BEGIN -->"
|
|
35
37
|
MARKER_END = "<!-- MEMGIT:END -->"
|
|
36
38
|
|
|
@@ -420,7 +422,7 @@ def compute_auto_section(repo, project, now, curated: str = "") -> str:
|
|
|
420
422
|
]
|
|
421
423
|
total = 0
|
|
422
424
|
for _score, m in candidates:
|
|
423
|
-
rule = (m.rule
|
|
425
|
+
rule = clean_for_context(m.rule).strip()
|
|
424
426
|
line = f"- [{m.slug}] {rule[:120]}"
|
|
425
427
|
if len(lines) - 2 >= _AUTO_MAX_ITEMS or total + len(line) > _AUTO_MAX_CHARS:
|
|
426
428
|
break
|