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,595 @@
|
|
|
1
|
+
"""Машинная сборка указателя CATALOG из frontmatter уроков.
|
|
2
|
+
|
|
3
|
+
Зачем: указатель, который ведут вручную, «течёт» — теряет файлы, копит рассинхрон с
|
|
4
|
+
реальным набором уроков. Этот модуль строит индексную часть указателя детерминированно
|
|
5
|
+
из самих файлов: поле `topic:` во frontmatter решает раздел, строка — из `description:`.
|
|
6
|
+
|
|
7
|
+
Принцип сосуществования рукописного и машинного: рукописная преамбула (шапка) и любой
|
|
8
|
+
рукописный хвост СОХРАНЯЮТСЯ между пересборками — заменяется только блок между маркерами
|
|
9
|
+
AUTO-INDEX. Так владелец правит прозу руками, а список уроков всегда полон и не дрейфует.
|
|
10
|
+
|
|
11
|
+
Все проектные значения (каталог памяти, таксономия тем, имена особых файлов, пороги) —
|
|
12
|
+
из конфига. Парсинг frontmatter — регэкспами, без PyYAML (локальный `pytest`).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime
|
|
17
|
+
import glob
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Dict, List, NamedTuple, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
from .config import MemoryConfig, get_config
|
|
24
|
+
from .messages import msg
|
|
25
|
+
|
|
26
|
+
# Маркеры авто-блока по умолчанию (всё ВНЕ их — рукописное). Реальные значения берутся
|
|
27
|
+
# из cfg.catalog_auto_start/end (конфигурируемы под язык/конвенцию проекта); эти
|
|
28
|
+
# константы — дефолты конфига и опорные значения для тестов.
|
|
29
|
+
AUTO_START = "<!-- AUTO-INDEX:START — managed by catalog_generate; edits between markers are overwritten -->"
|
|
30
|
+
AUTO_END = "<!-- AUTO-INDEX:END -->"
|
|
31
|
+
# Имя файла-маркера троттлинга пульса здоровья (внутренний, в memory_dir).
|
|
32
|
+
HEALTH_MARKER_NAME = "_catalog_health_marker"
|
|
33
|
+
|
|
34
|
+
# Раздел для уроков без распознанной темы — всегда последним (сигнал «припиши topic:»).
|
|
35
|
+
NO_TOPIC_KEY = "_none"
|
|
36
|
+
|
|
37
|
+
_MD_LINK_RE = re.compile(r"\]\((?!https?:)([^)]+?\.md)(?:#[^)]*)?\)")
|
|
38
|
+
# Inline-примеры формата ссылки в уроках о самой памяти — не реальные цели.
|
|
39
|
+
_PLACEHOLDER_TARGETS = frozenset({"файл.md", "file.md"})
|
|
40
|
+
_SUBLABEL_RE = re.compile(r"^\s*-\s+\*\*(.+?)\*\*")
|
|
41
|
+
_WIKILINK_RE = re.compile(r"\[\[([^\]\[]+)\]\]")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Lesson(NamedTuple):
|
|
45
|
+
filename: str
|
|
46
|
+
name: str
|
|
47
|
+
description: str
|
|
48
|
+
doc_type: str
|
|
49
|
+
topic: str # "" если не задан
|
|
50
|
+
subtopic: str # "" если не задан
|
|
51
|
+
reverify_after: str # "" если не задан
|
|
52
|
+
size: int
|
|
53
|
+
has_frontmatter: bool
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_frontmatter(text: str) -> Dict[str, str]:
|
|
57
|
+
"""Достаёт скалярные поля frontmatter регэкспом.
|
|
58
|
+
|
|
59
|
+
Поддерживает И top-level `type:`, И вложенный `metadata:\\n type:`. Возвращает
|
|
60
|
+
плоский dict только нужных полей.
|
|
61
|
+
"""
|
|
62
|
+
out: Dict[str, str] = {}
|
|
63
|
+
if not text.startswith("---"):
|
|
64
|
+
return out
|
|
65
|
+
fm = text.split("\n---", 1)[0]
|
|
66
|
+
for key in ("name", "description"):
|
|
67
|
+
m = re.search(rf"^{key}:\s*(.*)$", fm, re.MULTILINE)
|
|
68
|
+
if m:
|
|
69
|
+
out[key] = m.group(1).strip().strip('"').strip("'")
|
|
70
|
+
for key in ("topic", "subtopic", "reverify_after", "type"):
|
|
71
|
+
m = re.search(rf"^[ \t]*{key}:\s*(.*)$", fm, re.MULTILINE)
|
|
72
|
+
if m:
|
|
73
|
+
v = m.group(1).strip().strip('"').strip("'")
|
|
74
|
+
if v:
|
|
75
|
+
out[key] = v
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _skip_basenames(cfg: MemoryConfig) -> set:
|
|
80
|
+
return {cfg.core_file, cfg.catalog_file}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _lesson_paths(memory_dir: str, cfg: MemoryConfig) -> List[Tuple[str, str]]:
|
|
84
|
+
"""Пути всех уроков в корне memory_dir (без подпапок), отсортированы.
|
|
85
|
+
|
|
86
|
+
Исключает ядро/указатель и служебные `_*`-файлы. Возвращает пары (path, basename).
|
|
87
|
+
"""
|
|
88
|
+
skip = _skip_basenames(cfg)
|
|
89
|
+
out: List[Tuple[str, str]] = []
|
|
90
|
+
for path in sorted(glob.glob(os.path.join(memory_dir, "*.md"))):
|
|
91
|
+
base = os.path.basename(path)
|
|
92
|
+
if base in skip or base.startswith(cfg.private_file_prefix):
|
|
93
|
+
continue
|
|
94
|
+
out.append((path, base))
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def collect_lessons(
|
|
99
|
+
memory_dir: str,
|
|
100
|
+
cfg: Optional[MemoryConfig] = None,
|
|
101
|
+
topic_override: Optional[Dict[str, str]] = None,
|
|
102
|
+
subtopic_override: Optional[Dict[str, str]] = None,
|
|
103
|
+
) -> List[Lesson]:
|
|
104
|
+
"""Читает frontmatter всех уроков в корне memory_dir.
|
|
105
|
+
|
|
106
|
+
topic_override / subtopic_override (filename -> значение) перекрывают поля из файла
|
|
107
|
+
— нужно для ПРЕВЬЮ до миграции.
|
|
108
|
+
"""
|
|
109
|
+
cfg = cfg or get_config()
|
|
110
|
+
topic_override = topic_override or {}
|
|
111
|
+
subtopic_override = subtopic_override or {}
|
|
112
|
+
lessons: List[Lesson] = []
|
|
113
|
+
for path, base in _lesson_paths(memory_dir, cfg):
|
|
114
|
+
raw = Path(path).read_text(encoding="utf-8")
|
|
115
|
+
fm = parse_frontmatter(raw)
|
|
116
|
+
topic = topic_override.get(base, fm.get("topic", ""))
|
|
117
|
+
subtopic = subtopic_override.get(base, fm.get("subtopic", ""))
|
|
118
|
+
lessons.append(
|
|
119
|
+
Lesson(
|
|
120
|
+
filename=base,
|
|
121
|
+
name=fm.get("name", ""),
|
|
122
|
+
description=fm.get("description", ""),
|
|
123
|
+
doc_type=fm.get("type", ""),
|
|
124
|
+
topic=topic,
|
|
125
|
+
subtopic=subtopic,
|
|
126
|
+
reverify_after=fm.get("reverify_after", ""),
|
|
127
|
+
size=len(raw.encode("utf-8")),
|
|
128
|
+
has_frontmatter=raw.startswith("---"),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return lessons
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _line(lesson: Lesson, cfg: MemoryConfig) -> str:
|
|
135
|
+
"""Одна строка указателя: описание-якорь → ссылка на файл."""
|
|
136
|
+
label = lesson.description or lesson.name or lesson.filename
|
|
137
|
+
label = re.sub(r"\s+", " ", label).strip()
|
|
138
|
+
if len(label) > cfg.desc_max:
|
|
139
|
+
label = label[: cfg.desc_max - 1].rstrip() + "…"
|
|
140
|
+
label = label.replace("]", "⟧") # не сломать markdown-ссылку
|
|
141
|
+
return f"- [{label}]({lesson.filename})"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _render_group(parts: List[str], group: List[Lesson], cfg: MemoryConfig) -> None:
|
|
145
|
+
"""Уроки темы: сперва без под-группы (плоско), затем по под-группам (subtopic)."""
|
|
146
|
+
flat = sorted((x for x in group if not x.subtopic), key=lambda x: x.filename)
|
|
147
|
+
parts.extend(_line(ls, cfg) for ls in flat)
|
|
148
|
+
subs: Dict[str, List[Lesson]] = {}
|
|
149
|
+
for ls in group:
|
|
150
|
+
if ls.subtopic:
|
|
151
|
+
subs.setdefault(ls.subtopic, []).append(ls)
|
|
152
|
+
for sub in sorted(subs):
|
|
153
|
+
parts.append(f"- **{sub}:**")
|
|
154
|
+
for ls in sorted(subs[sub], key=lambda x: x.filename):
|
|
155
|
+
parts.append(" " + _line(ls, cfg))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def render_index(lessons: List[Lesson], cfg: Optional[MemoryConfig] = None) -> str:
|
|
159
|
+
"""Машинный индекс: уроки по теме (cfg.topic_order), внутри — по под-группам."""
|
|
160
|
+
cfg = cfg or get_config()
|
|
161
|
+
titles = cfg.topic_titles()
|
|
162
|
+
by_topic: Dict[str, List[Lesson]] = {}
|
|
163
|
+
for ls in lessons:
|
|
164
|
+
key = ls.topic if ls.topic in titles else NO_TOPIC_KEY
|
|
165
|
+
by_topic.setdefault(key, []).append(ls)
|
|
166
|
+
|
|
167
|
+
parts: List[str] = []
|
|
168
|
+
for key, title in cfg.topic_order:
|
|
169
|
+
group = by_topic.get(key)
|
|
170
|
+
if not group:
|
|
171
|
+
continue
|
|
172
|
+
parts.append(f"### {title}")
|
|
173
|
+
_render_group(parts, group, cfg)
|
|
174
|
+
parts.append("")
|
|
175
|
+
|
|
176
|
+
no_topic = by_topic.get(NO_TOPIC_KEY)
|
|
177
|
+
if no_topic:
|
|
178
|
+
parts.append(f"### {cfg.no_topic_title}")
|
|
179
|
+
_render_group(parts, no_topic, cfg)
|
|
180
|
+
parts.append("")
|
|
181
|
+
return "\n".join(parts).rstrip() + "\n"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def find_broken_links(memory_dir: str, cfg: Optional[MemoryConfig] = None) -> List[Tuple[str, str]]:
|
|
185
|
+
"""Битые перекрёстные ссылки МЕЖДУ уроками памяти (голым именем `](feedback_x.md)`).
|
|
186
|
+
|
|
187
|
+
Ссылки с `/` (пути в репозиторий) и плейсхолдеры-примеры — вне зоны этой проверки.
|
|
188
|
+
"""
|
|
189
|
+
cfg = cfg or get_config()
|
|
190
|
+
broken: List[Tuple[str, str]] = []
|
|
191
|
+
root = Path(memory_dir)
|
|
192
|
+
for path, base in _lesson_paths(memory_dir, cfg):
|
|
193
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
194
|
+
for m in _MD_LINK_RE.finditer(text):
|
|
195
|
+
target = m.group(1)
|
|
196
|
+
if "/" in target:
|
|
197
|
+
continue
|
|
198
|
+
if target in _PLACEHOLDER_TARGETS:
|
|
199
|
+
continue
|
|
200
|
+
if (root / target).exists():
|
|
201
|
+
continue
|
|
202
|
+
broken.append((base, target))
|
|
203
|
+
return sorted(set(broken))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def find_broken_wikilinks(memory_dir: str, cfg: Optional[MemoryConfig] = None) -> List[Tuple[str, str]]:
|
|
207
|
+
"""Битые `[[X]]`-ссылки между уроками (вторая конвенция связывания помимо `](x.md)`).
|
|
208
|
+
|
|
209
|
+
Валидируем ТОЛЬКО ссылки-на-урок: цель X начинается с префикса урока
|
|
210
|
+
(feedback_/reference_/project_). Цель валидна, если есть файл `X.md` ИЛИ урок с
|
|
211
|
+
`name: X`. Произвольные `[[...]]` (не похожие на ссылку-урок) не трогаем — нет
|
|
212
|
+
ложных срабатываний на прозе/примерах.
|
|
213
|
+
"""
|
|
214
|
+
cfg = cfg or get_config()
|
|
215
|
+
texts: Dict[str, str] = {}
|
|
216
|
+
valid: set = set()
|
|
217
|
+
for path, base in _lesson_paths(memory_dir, cfg):
|
|
218
|
+
t = Path(path).read_text(encoding="utf-8")
|
|
219
|
+
texts[base] = t
|
|
220
|
+
valid.add(base[:-3] if base.endswith(".md") else base)
|
|
221
|
+
nm = parse_frontmatter(t).get("name")
|
|
222
|
+
if nm:
|
|
223
|
+
valid.add(nm)
|
|
224
|
+
prefixes = tuple(p + "_" for p in cfg.lesson_prefixes)
|
|
225
|
+
broken: List[Tuple[str, str]] = []
|
|
226
|
+
for base, t in texts.items():
|
|
227
|
+
for m in _WIKILINK_RE.finditer(t):
|
|
228
|
+
target = m.group(1).strip()
|
|
229
|
+
if not target.startswith(prefixes):
|
|
230
|
+
continue
|
|
231
|
+
# обе конвенции записи цели: `[[feedback_x]]` и `[[feedback_x.md]]` — норму
|
|
232
|
+
# (без .md) сверяем с набором {имя_файла_без_.md} ∪ {name-слаги}.
|
|
233
|
+
norm = target[:-3] if target.endswith(".md") else target
|
|
234
|
+
if norm not in valid and target not in valid:
|
|
235
|
+
broken.append((base, target))
|
|
236
|
+
return sorted(set(broken))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def run_diagnostics(
|
|
240
|
+
memory_dir: str, lessons: List[Lesson], cfg: Optional[MemoryConfig] = None
|
|
241
|
+
) -> Dict[str, list]:
|
|
242
|
+
"""Сводка здоровья: сироты-без-темы, без описания/frontmatter, крупные, битые ссылки."""
|
|
243
|
+
cfg = cfg or get_config()
|
|
244
|
+
titles = cfg.topic_titles()
|
|
245
|
+
no_topic = sorted(ls.filename for ls in lessons if ls.topic not in titles)
|
|
246
|
+
no_desc = sorted(ls.filename for ls in lessons if not ls.description)
|
|
247
|
+
# Пустой `name` без keywords обнуляет высоковесный (×2) набор токенов заголовка —
|
|
248
|
+
# урок труднее «всплывает» в retrieve. Частый источник — нормализация frontmatter
|
|
249
|
+
# инструментом редактирования (обнуляет name); чинится восстановлением заголовка.
|
|
250
|
+
no_name = sorted(ls.filename for ls in lessons if not ls.name)
|
|
251
|
+
no_fm = sorted(ls.filename for ls in lessons if not ls.has_frontmatter)
|
|
252
|
+
oversize = sorted(
|
|
253
|
+
(ls.filename, ls.size) for ls in lessons if ls.size > cfg.oversize_bytes
|
|
254
|
+
)
|
|
255
|
+
return {
|
|
256
|
+
"total": [len(lessons)],
|
|
257
|
+
"no_topic": no_topic,
|
|
258
|
+
"no_description": no_desc,
|
|
259
|
+
"no_name": no_name,
|
|
260
|
+
"no_frontmatter": no_fm,
|
|
261
|
+
"oversize": oversize,
|
|
262
|
+
"broken_links": find_broken_links(memory_dir, cfg),
|
|
263
|
+
"broken_wikilinks": find_broken_wikilinks(memory_dir, cfg),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _split_preamble_footer(existing: str, cfg: MemoryConfig) -> Tuple[str, str]:
|
|
268
|
+
"""Делит существующий указатель на рукописную преамбулу (до маркера) и хвост (после).
|
|
269
|
+
|
|
270
|
+
Маркеры берём из cfg (проект может задать свои/локализованные) — так первая
|
|
271
|
+
пересборка узнаёт существующий файл с уже стоящими маркерами и не плодит дубль.
|
|
272
|
+
"""
|
|
273
|
+
start, end = cfg.catalog_auto_start, cfg.catalog_auto_end
|
|
274
|
+
if start in existing and end in existing:
|
|
275
|
+
pre = existing.split(start, 1)[0].rstrip()
|
|
276
|
+
post = existing.split(end, 1)[1].lstrip("\n")
|
|
277
|
+
return pre, post
|
|
278
|
+
head = existing.split("\n### ", 1)[0].rstrip() if existing else ""
|
|
279
|
+
return head, ""
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def build_catalog(
|
|
283
|
+
memory_dir: Optional[str] = None,
|
|
284
|
+
cfg: Optional[MemoryConfig] = None,
|
|
285
|
+
topic_override: Optional[Dict[str, str]] = None,
|
|
286
|
+
subtopic_override: Optional[Dict[str, str]] = None,
|
|
287
|
+
today: Optional[datetime.date] = None,
|
|
288
|
+
) -> Tuple[str, Dict[str, list]]:
|
|
289
|
+
"""Собирает полный текст указателя (преамбула + машинный индекс) и диагностику."""
|
|
290
|
+
cfg = cfg or get_config()
|
|
291
|
+
memory_dir = memory_dir or cfg.memory_dir
|
|
292
|
+
if today is None:
|
|
293
|
+
today = datetime.date.today()
|
|
294
|
+
lessons = collect_lessons(memory_dir, cfg, topic_override, subtopic_override)
|
|
295
|
+
index = render_index(lessons, cfg)
|
|
296
|
+
diag = run_diagnostics(memory_dir, lessons, cfg)
|
|
297
|
+
|
|
298
|
+
catalog_path = os.path.join(memory_dir, cfg.catalog_file)
|
|
299
|
+
existing = ""
|
|
300
|
+
if os.path.exists(catalog_path):
|
|
301
|
+
existing = Path(catalog_path).read_text(encoding="utf-8")
|
|
302
|
+
preamble, footer = _split_preamble_footer(existing, cfg)
|
|
303
|
+
if not preamble:
|
|
304
|
+
preamble = cfg.catalog_preamble
|
|
305
|
+
|
|
306
|
+
note = msg(cfg, "catalog.auto_index_note", today=today.isoformat(), count=len(lessons))
|
|
307
|
+
body = "\n".join(
|
|
308
|
+
[preamble, "", cfg.catalog_auto_start, note, "", index.rstrip(), "", cfg.catalog_auto_end]
|
|
309
|
+
)
|
|
310
|
+
if footer:
|
|
311
|
+
body += "\n\n" + footer
|
|
312
|
+
return body.rstrip() + "\n", diag
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _section_to_topic(cfg: MemoryConfig) -> Dict[str, str]:
|
|
316
|
+
"""Обратная карта «заголовок раздела → слаг темы» из cfg.topic_order (для бутстрапа)."""
|
|
317
|
+
return {title: slug for slug, title in cfg.topic_order}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def bootstrap_topics_from_catalog(
|
|
321
|
+
memory_dir: str, cfg: Optional[MemoryConfig] = None
|
|
322
|
+
) -> Tuple[Dict[str, str], Dict[str, str]]:
|
|
323
|
+
"""Строит ({filename: topic}, {filename: subtopic}) из текущего указателя (read-only).
|
|
324
|
+
|
|
325
|
+
Нужно только для ПРЕВЬЮ до миграции: показать честный вид будущего указателя до
|
|
326
|
+
появления полей `topic:`/`subtopic:` в файлах. Файлы из ядра, не попавшие в раздел
|
|
327
|
+
указателя, получают topic=core (если такой слаг есть в таксономии).
|
|
328
|
+
"""
|
|
329
|
+
cfg = cfg or get_config()
|
|
330
|
+
section_to_topic = _section_to_topic(cfg)
|
|
331
|
+
has_core = "core" in {slug for slug, _ in cfg.topic_order}
|
|
332
|
+
catalog_path = os.path.join(memory_dir, cfg.catalog_file)
|
|
333
|
+
topics: Dict[str, str] = {}
|
|
334
|
+
subtopics: Dict[str, str] = {}
|
|
335
|
+
if not os.path.exists(catalog_path):
|
|
336
|
+
return topics, subtopics
|
|
337
|
+
section = None
|
|
338
|
+
for line in Path(catalog_path).read_text(encoding="utf-8").splitlines():
|
|
339
|
+
h = re.match(r"^#{2,3}\s+(.*)$", line)
|
|
340
|
+
if h:
|
|
341
|
+
section = h.group(1).strip()
|
|
342
|
+
continue
|
|
343
|
+
topic = section_to_topic.get(section or "", "")
|
|
344
|
+
if not topic:
|
|
345
|
+
continue
|
|
346
|
+
sm = _SUBLABEL_RE.match(line)
|
|
347
|
+
sub = sm.group(1).strip().rstrip(":").strip() if sm else ""
|
|
348
|
+
for m in _MD_LINK_RE.finditer(line):
|
|
349
|
+
tgt = m.group(1)
|
|
350
|
+
if "/" not in tgt:
|
|
351
|
+
topics.setdefault(tgt, topic)
|
|
352
|
+
if sub:
|
|
353
|
+
subtopics.setdefault(tgt, sub)
|
|
354
|
+
if has_core:
|
|
355
|
+
core_path = os.path.join(memory_dir, cfg.core_file)
|
|
356
|
+
if os.path.exists(core_path):
|
|
357
|
+
for m in _MD_LINK_RE.finditer(Path(core_path).read_text(encoding="utf-8")):
|
|
358
|
+
tgt = m.group(1)
|
|
359
|
+
if "/" not in tgt:
|
|
360
|
+
topics.setdefault(tgt, "core")
|
|
361
|
+
return topics, subtopics
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def set_frontmatter_field(text: str, key: str, value: str) -> Tuple[str, bool]:
|
|
365
|
+
"""Вписывает/обновляет `key: value` во frontmatter. Идемпотентно. Нет frontmatter
|
|
366
|
+
→ возврат без изменений (False). Новое поле — после `description:`/`name:`, top-level."""
|
|
367
|
+
if not text.startswith("---"):
|
|
368
|
+
return text, False
|
|
369
|
+
lines = text.split("\n")
|
|
370
|
+
end = None
|
|
371
|
+
for i in range(1, len(lines)):
|
|
372
|
+
if lines[i].strip() == "---":
|
|
373
|
+
end = i
|
|
374
|
+
break
|
|
375
|
+
if end is None:
|
|
376
|
+
return text, False
|
|
377
|
+
fm = lines[1:end]
|
|
378
|
+
key_re = re.compile(rf"^{re.escape(key)}:\s*(.*)$")
|
|
379
|
+
for i, ln in enumerate(fm):
|
|
380
|
+
m = key_re.match(ln)
|
|
381
|
+
if m:
|
|
382
|
+
if m.group(1).strip().strip('"').strip("'") == value:
|
|
383
|
+
return text, False
|
|
384
|
+
fm[i] = f"{key}: {value}"
|
|
385
|
+
return "\n".join(lines[:1] + fm + lines[end:]), True
|
|
386
|
+
insert_at = len(fm)
|
|
387
|
+
for anchor in ("description:", "name:"):
|
|
388
|
+
for i, ln in enumerate(fm):
|
|
389
|
+
if ln.startswith(anchor):
|
|
390
|
+
insert_at = i + 1
|
|
391
|
+
break
|
|
392
|
+
else:
|
|
393
|
+
continue
|
|
394
|
+
break
|
|
395
|
+
fm.insert(insert_at, f"{key}: {value}")
|
|
396
|
+
return "\n".join(lines[:1] + fm + lines[end:]), True
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def migrate_frontmatter(
|
|
400
|
+
memory_dir: str,
|
|
401
|
+
topics: Dict[str, str],
|
|
402
|
+
subtopics: Dict[str, str],
|
|
403
|
+
apply: bool = False,
|
|
404
|
+
cfg: Optional[MemoryConfig] = None,
|
|
405
|
+
) -> Dict[str, list]:
|
|
406
|
+
"""Вписывает topic/subtopic во frontmatter всех уроков с известной темой.
|
|
407
|
+
|
|
408
|
+
apply=False — сухой прогон (только отчёт). apply=True — атомарная запись каждого
|
|
409
|
+
изменённого файла (tempfile + os.replace).
|
|
410
|
+
"""
|
|
411
|
+
cfg = cfg or get_config()
|
|
412
|
+
changed: List[str] = []
|
|
413
|
+
skipped_no_topic: List[str] = []
|
|
414
|
+
skipped_no_fm: List[str] = []
|
|
415
|
+
for path, base in _lesson_paths(memory_dir, cfg):
|
|
416
|
+
topic = topics.get(base)
|
|
417
|
+
if not topic:
|
|
418
|
+
skipped_no_topic.append(base)
|
|
419
|
+
continue
|
|
420
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
421
|
+
if not text.startswith("---"):
|
|
422
|
+
skipped_no_fm.append(base)
|
|
423
|
+
continue
|
|
424
|
+
cur = parse_frontmatter(text)
|
|
425
|
+
sub = subtopics.get(base)
|
|
426
|
+
if cur.get("topic") == topic and (not sub or cur.get("subtopic") == sub):
|
|
427
|
+
continue
|
|
428
|
+
new, c1 = (
|
|
429
|
+
set_frontmatter_field(text, "topic", topic)
|
|
430
|
+
if cur.get("topic") != topic
|
|
431
|
+
else (text, False)
|
|
432
|
+
)
|
|
433
|
+
c2 = False
|
|
434
|
+
if sub and cur.get("subtopic") != sub:
|
|
435
|
+
new, c2 = set_frontmatter_field(new, "subtopic", sub)
|
|
436
|
+
if c1 or c2:
|
|
437
|
+
changed.append(base)
|
|
438
|
+
if apply:
|
|
439
|
+
tmp = Path(path).with_name(base + ".tmp")
|
|
440
|
+
tmp.write_text(new, encoding="utf-8")
|
|
441
|
+
os.replace(tmp, path)
|
|
442
|
+
return {
|
|
443
|
+
"changed": changed,
|
|
444
|
+
"skipped_no_topic": skipped_no_topic,
|
|
445
|
+
"skipped_no_fm": skipped_no_fm,
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def format_health_pulse(diag: Dict[str, list], cfg: Optional[MemoryConfig] = None) -> str:
|
|
450
|
+
"""Компактная сводка здоровья для SessionStart. Пусто, если нет actionable-долга."""
|
|
451
|
+
cfg = cfg or get_config()
|
|
452
|
+
nt = len(diag["no_topic"])
|
|
453
|
+
nn = len(diag.get("no_name", []))
|
|
454
|
+
bl = len(diag["broken_links"])
|
|
455
|
+
wbl = len(diag.get("broken_wikilinks", []))
|
|
456
|
+
osz = len(diag["oversize"])
|
|
457
|
+
total = diag["total"][0] if diag.get("total") else 0
|
|
458
|
+
many = bool(cfg.lesson_count_warn) and total >= cfg.lesson_count_warn
|
|
459
|
+
if nt == 0 and nn == 0 and bl == 0 and wbl == 0 and not many:
|
|
460
|
+
return ""
|
|
461
|
+
parts = []
|
|
462
|
+
if nt:
|
|
463
|
+
parts.append(msg(cfg, "health.no_topic", nt=nt))
|
|
464
|
+
if nn:
|
|
465
|
+
parts.append(msg(cfg, "health.no_name", nn=nn))
|
|
466
|
+
if bl:
|
|
467
|
+
parts.append(msg(cfg, "health.broken_links", bl=bl))
|
|
468
|
+
if wbl:
|
|
469
|
+
parts.append(msg(cfg, "health.broken_wikilinks", wbl=wbl))
|
|
470
|
+
if many:
|
|
471
|
+
parts.append(msg(cfg, "health.many_lessons", total=total, limit=cfg.lesson_count_warn))
|
|
472
|
+
if osz:
|
|
473
|
+
parts.append(msg(cfg, "health.oversize", osz=osz, oversize_kb=cfg.oversize_bytes // 1000))
|
|
474
|
+
return msg(cfg, "health.pulse_prefix") + "; ".join(parts) + msg(cfg, "health.pulse_suffix")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def health_marker_path(cfg: MemoryConfig) -> str:
|
|
478
|
+
"""Путь файла-маркера троттлинга пульса (внутренний `_*` файл в memory_dir)."""
|
|
479
|
+
return os.path.join(cfg.memory_dir, HEALTH_MARKER_NAME)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def throttle_pulse(
|
|
483
|
+
pulse: str,
|
|
484
|
+
diag: Dict[str, list],
|
|
485
|
+
cfg: MemoryConfig,
|
|
486
|
+
today: Optional[datetime.date] = None,
|
|
487
|
+
marker: Optional[str] = None,
|
|
488
|
+
) -> str:
|
|
489
|
+
"""Троттлинг пульса: вернуть pulse к показу или '' (и записать маркер при показе).
|
|
490
|
+
|
|
491
|
+
Правило: не чаще раза в день; показать при смене «долга» (nt/bl) ИЛИ через 7 дней
|
|
492
|
+
при неизменном долге. cfg.health_pulse_throttle=False — отдать pulse как есть.
|
|
493
|
+
Вынесено сюда из main(--report), чтобы SessionStart-хук применял тот же троттлинг.
|
|
494
|
+
"""
|
|
495
|
+
if not pulse:
|
|
496
|
+
return ""
|
|
497
|
+
if not cfg.health_pulse_throttle:
|
|
498
|
+
return pulse
|
|
499
|
+
if today is None:
|
|
500
|
+
today = datetime.date.today()
|
|
501
|
+
marker = marker or health_marker_path(cfg)
|
|
502
|
+
_total = diag["total"][0] if diag.get("total") else 0
|
|
503
|
+
_many = 1 if (cfg.lesson_count_warn and _total >= cfg.lesson_count_warn) else 0
|
|
504
|
+
sig = (
|
|
505
|
+
f"nt{len(diag['no_topic'])}_nn{len(diag.get('no_name', []))}"
|
|
506
|
+
f"_bl{len(diag['broken_links'])}"
|
|
507
|
+
f"_wbl{len(diag.get('broken_wikilinks', []))}_many{_many}"
|
|
508
|
+
)
|
|
509
|
+
last_date = last_sig = ""
|
|
510
|
+
try:
|
|
511
|
+
last_date, last_sig = (
|
|
512
|
+
Path(marker).read_text(encoding="utf-8").strip().split("|", 1) + [""]
|
|
513
|
+
)[:2]
|
|
514
|
+
except OSError:
|
|
515
|
+
pass
|
|
516
|
+
if last_date == today.isoformat():
|
|
517
|
+
return ""
|
|
518
|
+
emit = (not last_date) or (sig != last_sig)
|
|
519
|
+
if not emit:
|
|
520
|
+
try:
|
|
521
|
+
emit = (today - datetime.date.fromisoformat(last_date)).days >= 7
|
|
522
|
+
except ValueError:
|
|
523
|
+
emit = True
|
|
524
|
+
if not emit:
|
|
525
|
+
return ""
|
|
526
|
+
try:
|
|
527
|
+
tmp = Path(marker).with_name(Path(marker).name + ".tmp")
|
|
528
|
+
tmp.write_text(f"{today.isoformat()}|{sig}", encoding="utf-8")
|
|
529
|
+
os.replace(tmp, Path(marker))
|
|
530
|
+
except OSError:
|
|
531
|
+
pass
|
|
532
|
+
return pulse
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def main() -> None:
|
|
536
|
+
import sys
|
|
537
|
+
|
|
538
|
+
cfg = get_config()
|
|
539
|
+
args = sys.argv[1:]
|
|
540
|
+
memory_dir = cfg.memory_dir
|
|
541
|
+
|
|
542
|
+
if "--report" in args:
|
|
543
|
+
idx = args.index("--report")
|
|
544
|
+
marker = (
|
|
545
|
+
args[idx + 1]
|
|
546
|
+
if idx + 1 < len(args)
|
|
547
|
+
else health_marker_path(cfg)
|
|
548
|
+
)
|
|
549
|
+
diag = run_diagnostics(memory_dir, collect_lessons(memory_dir, cfg), cfg)
|
|
550
|
+
pulse = throttle_pulse(format_health_pulse(diag, cfg), diag, cfg, marker=marker)
|
|
551
|
+
if pulse:
|
|
552
|
+
print(pulse)
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
write = "--write" in args
|
|
556
|
+
flat = "--flat" in args
|
|
557
|
+
use_bootstrap = "--bootstrap" in args
|
|
558
|
+
topic_override = subtopic_override = None
|
|
559
|
+
if use_bootstrap:
|
|
560
|
+
topic_override, subtopic_override = bootstrap_topics_from_catalog(memory_dir, cfg)
|
|
561
|
+
if flat:
|
|
562
|
+
subtopic_override = None
|
|
563
|
+
|
|
564
|
+
catalog_text, diag = build_catalog(
|
|
565
|
+
memory_dir, cfg, topic_override=topic_override, subtopic_override=subtopic_override
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
if write:
|
|
569
|
+
cat_path = Path(os.path.join(memory_dir, cfg.catalog_file))
|
|
570
|
+
tmp = cat_path.with_name(cfg.catalog_file + ".tmp")
|
|
571
|
+
tmp.write_text(catalog_text, encoding="utf-8")
|
|
572
|
+
os.replace(tmp, cat_path)
|
|
573
|
+
print(msg(cfg, "catalog.written", catalog_file=cfg.catalog_file, count=diag["total"][0]))
|
|
574
|
+
else:
|
|
575
|
+
print(catalog_text)
|
|
576
|
+
|
|
577
|
+
print("\n" + msg(cfg, "diag.separator"), file=sys.stderr)
|
|
578
|
+
print(msg(cfg, "diag.header"), file=sys.stderr)
|
|
579
|
+
print(msg(cfg, "diag.total", count=diag["total"][0]), file=sys.stderr)
|
|
580
|
+
print(msg(cfg, "diag.no_topic_count", count=len(diag["no_topic"])), file=sys.stderr)
|
|
581
|
+
for f in diag["no_topic"]:
|
|
582
|
+
print(msg(cfg, "diag.no_topic_item", f=f), file=sys.stderr)
|
|
583
|
+
print(msg(cfg, "diag.no_description", count=len(diag["no_description"])), file=sys.stderr)
|
|
584
|
+
print(msg(cfg, "diag.no_name", count=len(diag.get("no_name", []))), file=sys.stderr)
|
|
585
|
+
for f in diag.get("no_name", []):
|
|
586
|
+
print(msg(cfg, "diag.no_name_item", f=f), file=sys.stderr)
|
|
587
|
+
print(msg(cfg, "diag.no_frontmatter", count=len(diag["no_frontmatter"])), file=sys.stderr)
|
|
588
|
+
print(msg(cfg, "diag.oversize_count", oversize_bytes=cfg.oversize_bytes, count=len(diag["oversize"])), file=sys.stderr)
|
|
589
|
+
print(msg(cfg, "diag.broken_links_count", count=len(diag["broken_links"])), file=sys.stderr)
|
|
590
|
+
for src, tgt in diag["broken_links"]:
|
|
591
|
+
print(msg(cfg, "diag.broken_link_item", src=src, tgt=tgt), file=sys.stderr)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
if __name__ == "__main__":
|
|
595
|
+
main()
|