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.
- claude_memory/__init__.py +16 -0
- claude_memory/applies_to.py +152 -0
- claude_memory/archive_prune.py +74 -0
- claude_memory/catalog_generate.py +595 -0
- claude_memory/cli.py +245 -0
- claude_memory/config.py +302 -0
- claude_memory/hooks_cli.py +417 -0
- claude_memory/installer.py +178 -0
- claude_memory/memory_archive.py +324 -0
- claude_memory/memory_concurrency.py +91 -0
- claude_memory/memory_retrieve.py +263 -0
- claude_memory/messages.py +146 -0
- claude_memory/model_registry_guard.py +91 -0
- claude_memory/precedent_index.py +201 -0
- claude_memory/self_check.py +76 -0
- claude_memory/session_marker_guard.py +79 -0
- claude_memory/sqlite_index.py +191 -0
- claude_memory/staleness.py +202 -0
- claude_memory/stop_check.py +126 -0
- claude_memory/subagent_efficiency_log.py +82 -0
- claude_memory/subagent_model_guard.py +138 -0
- claude_memory_engine-0.6.0.dist-info/METADATA +338 -0
- claude_memory_engine-0.6.0.dist-info/RECORD +27 -0
- claude_memory_engine-0.6.0.dist-info/WHEEL +5 -0
- claude_memory_engine-0.6.0.dist-info/entry_points.txt +2 -0
- claude_memory_engine-0.6.0.dist-info/licenses/LICENSE +202 -0
- claude_memory_engine-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|