claude-memory-engine 0.6.0__py3-none-any.whl

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.
@@ -0,0 +1,16 @@
1
+ """claude-memory-engine — переиспользуемый движок «памяти уроков» для Claude Code.
2
+
3
+ Универсальная (не привязанная к конкретному проекту) часть системы памяти: файловое
4
+ хранилище уроков с frontmatter, авто-указатель CATALOG, офлайн-ретривер, всплытие
5
+ уроков по пути файла, авто-обслуживание, страж параллельных правок и страж выбора
6
+ модели суб-агентов. Все проектные значения — в claude_memory.config.MemoryConfig.
7
+
8
+ Происхождение: извлечено из инструментария реального рабочего проекта.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.6.0"
13
+
14
+ from .config import MemoryConfig, get_config, load
15
+
16
+ __all__ = ["MemoryConfig", "get_config", "load", "__version__"]
@@ -0,0 +1,152 @@
1
+ """Поиск «путь → уроки»: по пути файла находит уроки, чьи `applies_to:`-глобы
2
+ (frontmatter) совпадают с этим путём.
3
+
4
+ Раньше эта логика жила Python-вставкой внутри bash-хука `lessons_for_path.sh` и
5
+ дублировалась вызовом из ретривера. Здесь она — один Python-модуль, который зовут И
6
+ ретривер (`memory_retrieve.path_lessons`), И тонкая обёртка-хук «перед первой правкой
7
+ файла». Один источник истины, без shell-зависимости, тестируемо.
8
+
9
+ `applies_to` задаётся относительно корня проекта; матчинг идёт по пути,
10
+ релативизированному к корню проекта (а если не вышло — к git-toplevel; работает и в
11
+ worktree).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import fnmatch
16
+ import glob
17
+ import os
18
+ import re
19
+ import subprocess
20
+ from typing import List, Optional, Tuple
21
+
22
+ from .config import MemoryConfig, get_config
23
+
24
+ _APPLIES_RE = re.compile(r"^[ \t]*applies_to:[ \t]*(.*)$", re.MULTILINE)
25
+ _DESC_RE = re.compile(r"^description:\s*(.*)$", re.MULTILINE)
26
+
27
+
28
+ def read_head(path: str, cap: int = 65536) -> str:
29
+ """Начало файла до cap байт (по умолч. 64К). Покрывает весь frontmatter любого
30
+ реального урока + начало тела. Заменяет прежние фикс.окна 2000/4000, которые молча
31
+ ТЕРЯЛИ длинный frontmatter (и applies_to-глоб, и поля для ретривера). Уроки малы —
32
+ цена чтения ничтожна. OSError пробрасывается вызывающему."""
33
+ with open(path, encoding="utf-8") as f:
34
+ return f.read(cap)
35
+
36
+
37
+ def _frontmatter(path: str) -> str:
38
+ try:
39
+ head = read_head(path)
40
+ except OSError:
41
+ return ""
42
+ if not head.startswith("---"):
43
+ return ""
44
+ return head.split("\n---", 1)[0]
45
+
46
+
47
+ def _applies_globs(fm: str) -> List[str]:
48
+ """Глобы из `applies_to:` — инлайн-список `[a, b]` ИЛИ YAML-список из `- `-строк.
49
+
50
+ Ведущий [ \\t]* ловит И top-level applies_to, И вложенный под `metadata:` (нативный
51
+ формат памяти harness). После `:` — [ \\t]* (не \\s*), иначе потеряется 1-й элемент
52
+ многострочного списка.
53
+ """
54
+ m = _APPLIES_RE.search(fm)
55
+ if not m:
56
+ return []
57
+ inline = m.group(1).strip()
58
+ if inline.startswith("["):
59
+ inner = inline.strip("[]")
60
+ return [g.strip().strip("'\"") for g in inner.split(",") if g.strip()]
61
+ globs: List[str] = []
62
+ for line in fm[m.end():].splitlines():
63
+ ls = line.strip()
64
+ if ls.startswith("- "):
65
+ globs.append(ls[2:].strip().strip("'\""))
66
+ elif ls and not ls.startswith("#"):
67
+ break
68
+ return [g for g in globs if g]
69
+
70
+
71
+ def _candidates(target: str, project_root: str) -> set:
72
+ """Пути-кандидаты для матчинга глоб: исходный аргумент + rel-к-корню-проекта +
73
+ rel-к-git-toplevel.
74
+
75
+ Оба rel считаем ВСЕГДА и добавляем оба (а не «или»): в worktree-сессии путь лежит
76
+ под главным project_root (rel получится `.claude/worktrees/<wt>/app/x.py` — НЕ
77
+ матчит глоб `app/*`), но git-toplevel = корень worktree → rel `app/x.py` матчит.
78
+ Так applies_to работает одинаково и в worktree, и вне его (#memory-lib-cutover).
79
+ """
80
+ abspath = os.path.abspath(target)
81
+ # removeprefix («./»), НЕ lstrip(«./»): lstrip снимает КЛАСС символов {'.', '/'} и
82
+ # портит dotfile-пути ('.github/x.yml' → 'github/x.yml'), порождая ложный кандидат,
83
+ # способный совпасть с typo-глобом. Нужно снять ровно ведущий «./» относительного пути.
84
+ cands = {target, target.removeprefix("./")}
85
+ # 1) относительно корня проекта из конфига
86
+ try:
87
+ root_abs = os.path.abspath(project_root)
88
+ if root_abs and abspath.startswith(root_abs + os.sep):
89
+ cands.add(os.path.relpath(abspath, root_abs))
90
+ except (OSError, ValueError):
91
+ pass
92
+ # 2) относительно git-toplevel (в worktree это корень worktree → совпадает с глобами)
93
+ search_dir = abspath if os.path.isdir(abspath) else os.path.dirname(abspath)
94
+ try:
95
+ top = subprocess.check_output(
96
+ ["git", "-C", search_dir, "rev-parse", "--show-toplevel"],
97
+ stderr=subprocess.DEVNULL, text=True,
98
+ ).strip()
99
+ if top and abspath.startswith(top + os.sep):
100
+ cands.add(os.path.relpath(abspath, top))
101
+ except (OSError, subprocess.SubprocessError):
102
+ pass
103
+ return {c for c in cands if c}
104
+
105
+
106
+ def find_lessons_for_path(
107
+ target: str, cfg: Optional[MemoryConfig] = None
108
+ ) -> List[Tuple[str, str]]:
109
+ """Список (имя_файла_урока, описание) уроков, чьи applies_to-глобы матчат target.
110
+
111
+ Отсортировано по имени файла. Описание — из `description:` frontmatter ("" если нет).
112
+ """
113
+ cfg = cfg or get_config()
114
+ if not target:
115
+ return []
116
+ candidates = _candidates(target, cfg.project_root)
117
+ out: List[Tuple[str, str]] = []
118
+ for mf in sorted(glob.glob(os.path.join(cfg.memory_dir, "*.md"))):
119
+ fm = _frontmatter(mf)
120
+ if not fm:
121
+ continue
122
+ globs = _applies_globs(fm)
123
+ if not globs:
124
+ continue
125
+ if not any(
126
+ cand == g or fnmatch.fnmatch(cand, g) for g in globs for cand in candidates
127
+ ):
128
+ continue
129
+ dm = _DESC_RE.search(fm)
130
+ desc = dm.group(1).strip() if dm else ""
131
+ out.append((os.path.basename(mf), desc))
132
+ return out
133
+
134
+
135
+ def format_lines(matches: List[Tuple[str, str]]) -> str:
136
+ """Формат вывода хука/CLI: одна строка `- имя: описание` на урок."""
137
+ return "\n".join(f"- {n}: {d}" if d else f"- {n}" for n, d in matches)
138
+
139
+
140
+ def main() -> None:
141
+ import sys
142
+
143
+ target = sys.argv[1] if len(sys.argv) > 1 else ""
144
+ if not target:
145
+ return
146
+ matches = find_lessons_for_path(target)
147
+ if matches:
148
+ print(format_lines(matches))
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()
@@ -0,0 +1,74 @@
1
+ """CLI: безопасное удаление архивных уроков с истёкшим сроком хранения.
2
+
3
+ Кандидаты = `staleness.scan_archive_stale` (архивные уроки с `archived_on` старше
4
+ `archive_stale_months`). Без `--apply` — только печать (dry-run). С `--apply` — каждый
5
+ файл СНАЧАЛА копируется в бэкап `<memory_dir>/_deleted/<сегодня>/` (вне всех глобов
6
+ движка → не всплывёт снова), ПОТОМ удаляется оригинал. Память не под git → удаление
7
+ необратимо, бэкап — единственная подстраховка; чистить `_deleted/` пользователь может сам.
8
+
9
+ Память сама себя не удаляет: эту команду запускает человек осознанно (а напоминание о
10
+ кандидатах приходит из `_stale_pending` на старте сессии). 0 токенов ИИ — чистый скрипт.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import datetime
15
+ import shutil
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import List, Optional, Tuple
19
+
20
+ from .config import MemoryConfig, get_config
21
+ from .messages import msg
22
+ from . import staleness
23
+
24
+ # Бэкап удалённого — в `_`-каталоге верхнего уровня memory_dir: вне archive/** (скан
25
+ # хранения), вне `*.md` верхнего уровня (каталог/ретрив/applies_to) → не переоткроется.
26
+ BACKUP_DIR = "_deleted"
27
+
28
+
29
+ def prune(
30
+ cfg: Optional[MemoryConfig] = None,
31
+ apply: bool = False,
32
+ today: Optional[datetime.date] = None,
33
+ ) -> Tuple[List[Tuple[str, str, int, str]], List[str]]:
34
+ """(кандидаты, удалённые). Без apply — удалённые пусты. С apply — бэкап ДО удаления."""
35
+ cfg = cfg or get_config()
36
+ today = today or datetime.date.today()
37
+ cands = staleness.scan_archive_stale(cfg, today)
38
+ if not apply or not cands:
39
+ return cands, []
40
+ arc_root = Path(cfg.memory_dir) / cfg.archive_dir_name
41
+ backup_root = Path(cfg.memory_dir) / BACKUP_DIR / today.isoformat()
42
+ deleted: List[str] = []
43
+ for _d, name, _months, _desc in cands:
44
+ matches = list(arc_root.rglob(name))
45
+ if not matches:
46
+ continue
47
+ src = matches[0]
48
+ backup_root.mkdir(parents=True, exist_ok=True)
49
+ shutil.copy2(src, backup_root / name)
50
+ src.unlink()
51
+ deleted.append(name)
52
+ return cands, deleted
53
+
54
+
55
+ def main() -> None:
56
+ cfg = get_config()
57
+ apply = "--apply" in sys.argv[1:]
58
+ cands, deleted = prune(cfg, apply=apply)
59
+ if not cands:
60
+ print(msg(cfg, "archive_prune.none"))
61
+ return
62
+ if not apply:
63
+ print(msg(cfg, "archive_prune.list_header", count=len(cands)))
64
+ for d, name, months, _desc in cands:
65
+ print(msg(cfg, "archive_prune.list_item", name=name, d=d, months=months))
66
+ print(msg(cfg, "archive_prune.apply_hint"))
67
+ else:
68
+ print(msg(cfg, "archive_prune.deleted", count=len(deleted), backup_dir=BACKUP_DIR))
69
+ for name in deleted:
70
+ print(msg(cfg, "archive_prune.deleted_item", name=name))
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()