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,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()