memplex 3.2.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.
Files changed (83) hide show
  1. memnex/__init__.py +31 -0
  2. memnex/__main__.py +6 -0
  3. memnex/_plugin/.claude-plugin/plugin.json +24 -0
  4. memnex/_plugin/.mcp.json +9 -0
  5. memnex/_plugin/__init__.py +0 -0
  6. memnex/_plugin/hooks/hooks.json +43 -0
  7. memnex/_plugin/scripts/hook-runner.py +166 -0
  8. memnex/_plugin/skills/mem-explore/SKILL.md +83 -0
  9. memnex/_plugin/skills/mem-manage/SKILL.md +92 -0
  10. memnex/_plugin/skills/mem-search/SKILL.md +85 -0
  11. memnex/_plugin/skills/mem-write/SKILL.md +78 -0
  12. memnex/adapters/__init__.py +14 -0
  13. memnex/adapters/claude_skill.py +169 -0
  14. memnex/adapters/cli.py +525 -0
  15. memnex/adapters/http_api.py +314 -0
  16. memnex/adapters/mcp_server.py +448 -0
  17. memnex/compaction.py +563 -0
  18. memnex/config.py +366 -0
  19. memnex/core/__init__.py +13 -0
  20. memnex/core/associator/__init__.py +8 -0
  21. memnex/core/associator/domain_classifier.py +75 -0
  22. memnex/core/associator/entity_aligner.py +127 -0
  23. memnex/core/associator/ref_linker.py +197 -0
  24. memnex/core/associator/term_mapper.py +77 -0
  25. memnex/core/dictionaries/__init__.py +50 -0
  26. memnex/core/engine.py +667 -0
  27. memnex/core/extractors/__init__.py +15 -0
  28. memnex/core/extractors/docx.py +97 -0
  29. memnex/core/extractors/image.py +233 -0
  30. memnex/core/extractors/markdown.py +139 -0
  31. memnex/core/extractors/pdf.py +133 -0
  32. memnex/core/extractors/vision_mapper.py +131 -0
  33. memnex/core/handlers/__init__.py +7 -0
  34. memnex/core/handlers/clipboard.py +40 -0
  35. memnex/core/handlers/file_handler.py +62 -0
  36. memnex/core/handlers/url_handler.py +132 -0
  37. memnex/llm/__init__.py +25 -0
  38. memnex/llm/enhancer.py +226 -0
  39. memnex/llm/fallback_chain.py +87 -0
  40. memnex/llm/injection_guard.py +178 -0
  41. memnex/llm/provider.py +130 -0
  42. memnex/llm/providers/__init__.py +22 -0
  43. memnex/llm/providers/anthropic.py +135 -0
  44. memnex/llm/providers/local.py +135 -0
  45. memnex/llm/providers/rule_based.py +68 -0
  46. memnex/llm/sanitizer.py +67 -0
  47. memnex/models/__init__.py +68 -0
  48. memnex/models/feedback.py +42 -0
  49. memnex/models/graph.py +33 -0
  50. memnex/models/memory.py +102 -0
  51. memnex/models/misc.py +185 -0
  52. memnex/models/paragraph.py +45 -0
  53. memnex/models/search.py +51 -0
  54. memnex/models/source.py +23 -0
  55. memnex/models/task.py +62 -0
  56. memnex/processing/__init__.py +1 -0
  57. memnex/processing/graph_builder.py +278 -0
  58. memnex/processing/merger/__init__.py +6 -0
  59. memnex/processing/merger/confidence_calculator.py +127 -0
  60. memnex/processing/merger/conflict_resolver.py +116 -0
  61. memnex/retrieval/__init__.py +1 -0
  62. memnex/retrieval/dedup.py +386 -0
  63. memnex/retrieval/embedding.py +289 -0
  64. memnex/retrieval/reranker.py +299 -0
  65. memnex/service.py +902 -0
  66. memnex/storage/__init__.py +65 -0
  67. memnex/storage/base.py +132 -0
  68. memnex/storage/changelog.py +106 -0
  69. memnex/storage/feedback.py +486 -0
  70. memnex/storage/lite/__init__.py +5 -0
  71. memnex/storage/lite/store.py +606 -0
  72. memnex/storage/vector.py +265 -0
  73. memnex/wiki/__init__.py +11 -0
  74. memnex/wiki/community.py +221 -0
  75. memnex/wiki/compiler.py +545 -0
  76. memnex/wiki/generator.py +270 -0
  77. memnex/wiki/search.py +282 -0
  78. memnex/worker.py +412 -0
  79. memplex-3.2.0.dist-info/METADATA +37 -0
  80. memplex-3.2.0.dist-info/RECORD +83 -0
  81. memplex-3.2.0.dist-info/WHEEL +5 -0
  82. memplex-3.2.0.dist-info/entry_points.txt +2 -0
  83. memplex-3.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,545 @@
1
+ """WikiCompiler -- compile MemoryNode instances into Wiki markdown pages.
2
+
3
+ Responsibilities:
4
+ - Convert Function / Fact / Preference / Observation into WikiPage objects
5
+ - Generate index.md (directory of all pages)
6
+ - Lint all wiki pages for consistency
7
+ - Delegate search to DualIndexSearch with fallback to filename+grep
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import re
14
+ import threading
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import List, Optional, TYPE_CHECKING
18
+
19
+ from memnex.models import (
20
+ Fact,
21
+ FieldValue,
22
+ Function,
23
+ LintIssue,
24
+ LintResult,
25
+ MemoryNode,
26
+ Observation,
27
+ Preference,
28
+ SearchResult,
29
+ SourceType,
30
+ WikiPage,
31
+ validate_func_id,
32
+ )
33
+
34
+ if TYPE_CHECKING:
35
+ from memnex.storage.base import MemoryStore
36
+ from memnex.wiki.search import DualIndexSearch
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # Default wiki directory
41
+ DEFAULT_WIKI_DIR = Path("~/.memnex/wiki/").expanduser()
42
+
43
+ # Maximum log lines before rotation
44
+ MAX_LOG_LINES = 1000
45
+ MAX_LOG_ARCHIVES = 5
46
+
47
+ # Wikilink pattern
48
+ _WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
49
+
50
+
51
+ class WikiCompiler:
52
+ """Compile memory nodes into Wiki markdown pages.
53
+
54
+ Parameters
55
+ ----------
56
+ store:
57
+ MemoryStore backend for reading memory nodes.
58
+ wiki_dir:
59
+ Root directory for wiki files (default ``~/.memnex/wiki/``).
60
+ dual_index:
61
+ Optional :class:`DualIndexSearch` for hybrid search.
62
+ When ``None``, search falls back to filename + grep.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ store: MemoryStore,
68
+ wiki_dir: Path = DEFAULT_WIKI_DIR,
69
+ dual_index: Optional["DualIndexSearch"] = None,
70
+ ) -> None:
71
+ self._store = store
72
+ self.wiki_dir = wiki_dir
73
+ self.dual_index = dual_index
74
+ self._lock = threading.Lock()
75
+
76
+ # ── Page compilation ──────────────────────────────────────────────
77
+
78
+ def compile_function(self, func: Function) -> WikiPage:
79
+ """Generate an Entity Page from a Function.
80
+
81
+ The page includes YAML frontmatter and a structured body with
82
+ trigger / condition / action / benefit sections plus cross-references
83
+ as ``[[target_name]]`` wikilinks.
84
+ """
85
+ now = datetime.utcnow().isoformat()
86
+ frontmatter = self._build_frontmatter(
87
+ page_id=func.id,
88
+ name=func.name,
89
+ domain=func.domain or "uncategorized",
90
+ memory_type=func.memory_type,
91
+ confidence=func.confidence,
92
+ created_at=func.created_at or now,
93
+ updated_at=func.updated_at or now,
94
+ )
95
+
96
+ body_lines: list[str] = [f"# {func.id}", ""]
97
+ body_lines.append(f"**Domain:** {func.domain or 'uncategorized'}")
98
+ body_lines.append(f"**Confidence:** {func.confidence:.2f}")
99
+ if func.source_paragraphs:
100
+ body_lines.append(
101
+ f"**Source Paragraphs:** [{', '.join(func.source_paragraphs)}]"
102
+ )
103
+ body_lines.append("")
104
+
105
+ # Field sections
106
+ body_lines.extend(
107
+ self._field_section("Trigger", func.trigger)
108
+ )
109
+ body_lines.extend(
110
+ self._field_section("Condition", func.condition)
111
+ )
112
+ body_lines.extend(
113
+ self._field_section("Action", func.action)
114
+ )
115
+ body_lines.extend(
116
+ self._field_section("Benefit", func.benefit)
117
+ )
118
+
119
+ # Cross-references
120
+ if func.cross_references:
121
+ body_lines.append("## Cross-References")
122
+ for ref in func.cross_references:
123
+ if isinstance(ref, dict):
124
+ target = ref.get("target", ref.get("target_id", ""))
125
+ reason = ref.get("reason", "")
126
+ if target:
127
+ link = f"[[{target}]]"
128
+ body_lines.append(
129
+ f"- {link}"
130
+ + (f" -- {reason}" if reason else "")
131
+ )
132
+ body_lines.append("")
133
+
134
+ content = frontmatter + "\n".join(body_lines)
135
+ return WikiPage(
136
+ page_id=func.id,
137
+ content=content,
138
+ metadata={
139
+ "type": "entity",
140
+ "domain": func.domain,
141
+ "confidence": func.confidence,
142
+ },
143
+ )
144
+
145
+ def compile_fact(self, fact: Fact) -> WikiPage:
146
+ """Generate a Wiki page for a Fact node."""
147
+ now = datetime.utcnow().isoformat()
148
+ frontmatter = self._build_frontmatter(
149
+ page_id=fact.id,
150
+ name=fact.name or f"{fact.subject} {fact.predicate} {fact.object_}",
151
+ domain=fact.domain or "uncategorized",
152
+ memory_type=fact.memory_type,
153
+ confidence=fact.confidence,
154
+ created_at=fact.created_at or now,
155
+ updated_at=fact.updated_at or now,
156
+ )
157
+
158
+ body_lines: list[str] = [f"# {fact.id}", ""]
159
+ body_lines.append(f"**Domain:** {fact.domain or 'uncategorized'}")
160
+ body_lines.append(f"**Confidence:** {fact.confidence:.2f}")
161
+ if fact.valid_until:
162
+ body_lines.append(f"**Valid Until:** {fact.valid_until}")
163
+ body_lines.append("")
164
+ body_lines.append("## Fact")
165
+ body_lines.append(
166
+ f"**{fact.subject}** {fact.predicate} **{fact.object_}**"
167
+ )
168
+ body_lines.append("")
169
+
170
+ content = frontmatter + "\n".join(body_lines)
171
+ return WikiPage(
172
+ page_id=fact.id,
173
+ content=content,
174
+ metadata={
175
+ "type": "entity",
176
+ "memory_type": "fact",
177
+ "domain": fact.domain,
178
+ },
179
+ )
180
+
181
+ def compile_preference(self, pref: Preference) -> WikiPage:
182
+ """Generate a Wiki page for a Preference node."""
183
+ now = datetime.utcnow().isoformat()
184
+ frontmatter = self._build_frontmatter(
185
+ page_id=pref.id,
186
+ name=pref.name or f"pref_{pref.aspect}",
187
+ domain=pref.domain or "uncategorized",
188
+ memory_type=pref.memory_type,
189
+ confidence=pref.confidence,
190
+ created_at=pref.created_at or now,
191
+ updated_at=pref.updated_at or now,
192
+ )
193
+
194
+ body_lines: list[str] = [f"# {pref.id}", ""]
195
+ body_lines.append(f"**Domain:** {pref.domain or 'uncategorized'}")
196
+ body_lines.append(f"**Confidence:** {pref.confidence:.2f}")
197
+ if pref.subject_id:
198
+ body_lines.append(f"**Subject:** {pref.subject_id}")
199
+ body_lines.append("")
200
+ body_lines.append("## Preference")
201
+ body_lines.append(f"**Aspect:** {pref.aspect}")
202
+ body_lines.append(f"**Preference:** {pref.preference}")
203
+ body_lines.append("")
204
+
205
+ content = frontmatter + "\n".join(body_lines)
206
+ return WikiPage(
207
+ page_id=pref.id,
208
+ content=content,
209
+ metadata={
210
+ "type": "entity",
211
+ "memory_type": "preference",
212
+ "domain": pref.domain,
213
+ },
214
+ )
215
+
216
+ def compile_observation(self, obs: Observation) -> WikiPage:
217
+ """Generate a Wiki page for an Observation node."""
218
+ now = datetime.utcnow().isoformat()
219
+ frontmatter = self._build_frontmatter(
220
+ page_id=obs.id,
221
+ name=obs.name or f"obs_{obs.event[:30]}",
222
+ domain=obs.domain or "uncategorized",
223
+ memory_type=obs.memory_type,
224
+ confidence=obs.confidence,
225
+ created_at=obs.created_at or now,
226
+ updated_at=obs.updated_at or now,
227
+ )
228
+
229
+ body_lines: list[str] = [f"# {obs.id}", ""]
230
+ body_lines.append(f"**Domain:** {obs.domain or 'uncategorized'}")
231
+ body_lines.append(f"**Confidence:** {obs.confidence:.2f}")
232
+ body_lines.append(f"**Actor:** {obs.actor}")
233
+ if obs.observed_at:
234
+ body_lines.append(f"**Observed At:** {obs.observed_at}")
235
+ body_lines.append("")
236
+ body_lines.append("## Event")
237
+ body_lines.append(obs.event)
238
+ if obs.context:
239
+ body_lines.append("")
240
+ body_lines.append("## Context")
241
+ body_lines.append(obs.context)
242
+ body_lines.append("")
243
+
244
+ content = frontmatter + "\n".join(body_lines)
245
+ return WikiPage(
246
+ page_id=obs.id,
247
+ content=content,
248
+ metadata={
249
+ "type": "entity",
250
+ "memory_type": "observation",
251
+ "domain": obs.domain,
252
+ },
253
+ )
254
+
255
+ def compile_index(self) -> WikiPage:
256
+ """Generate the ``index.md`` directory page.
257
+
258
+ Lists all Functions in a table, grouped by domain, with recent changes.
259
+ """
260
+ all_funcs = self._store.list_functions(limit=100000)
261
+
262
+ lines: list[str] = [
263
+ "# MemNex Knowledge Base",
264
+ "",
265
+ "## Entities (sorted by name)",
266
+ "",
267
+ "| Name | Domain | Confidence | Last Updated |",
268
+ "|------|--------|------------|--------------|",
269
+ ]
270
+ for func in sorted(all_funcs, key=lambda f: f.name):
271
+ updated = func.updated_at or "-"
272
+ lines.append(
273
+ f"| [[{func.id}]] | {func.domain or '-'} "
274
+ f"| {func.confidence:.2f} | {updated} |"
275
+ )
276
+ lines.append("")
277
+
278
+ # Domains
279
+ domain_groups: dict[str, list[Function]] = {}
280
+ for func in all_funcs:
281
+ domain = func.domain or "uncategorized"
282
+ domain_groups.setdefault(domain, []).append(func)
283
+
284
+ lines.append("## Domains")
285
+ lines.append("")
286
+ for domain, funcs in sorted(domain_groups.items()):
287
+ lines.append(f"- [[domain_{domain}]] ({len(funcs)} functions)")
288
+ lines.append("")
289
+
290
+ # Recent changes
291
+ lines.append("## Recent Changes")
292
+ lines.append("")
293
+ sorted_funcs = sorted(
294
+ all_funcs, key=lambda f: f.updated_at or "", reverse=True
295
+ )[:10]
296
+ for func in sorted_funcs:
297
+ lines.append(
298
+ f"- {func.updated_at or '-'}: "
299
+ f"Updated `{func.name}` ({func.memory_type})"
300
+ )
301
+ lines.append("")
302
+
303
+ now = datetime.utcnow().isoformat()
304
+ frontmatter = self._build_frontmatter(
305
+ page_id="index",
306
+ name="MemNex Knowledge Base",
307
+ domain="",
308
+ memory_type="index",
309
+ confidence=1.0,
310
+ created_at=now,
311
+ updated_at=now,
312
+ )
313
+ content = frontmatter + "\n".join(lines)
314
+ return WikiPage(
315
+ page_id="index",
316
+ content=content,
317
+ metadata={"type": "index", "total_entities": len(all_funcs)},
318
+ )
319
+
320
+ def compile_all(self) -> List[WikiPage]:
321
+ """Compile all memory nodes from the store into Wiki pages."""
322
+ pages: List[WikiPage] = []
323
+
324
+ # Compile functions
325
+ funcs = self._store.list_functions(limit=100000)
326
+ for func in funcs:
327
+ pages.append(self.compile_function(func))
328
+
329
+ # Compile index
330
+ pages.append(self.compile_index())
331
+ return pages
332
+
333
+ # ── Search ────────────────────────────────────────────────────────
334
+
335
+ def search(self, query: str, top_k: int = 10) -> List[SearchResult]:
336
+ """Search wiki pages via DualIndexSearch or fallback to file scan."""
337
+ if self.dual_index is not None:
338
+ return self.dual_index.search(query, top_k)
339
+ return self._fallback_search(query, top_k)
340
+
341
+ # ── Lint ──────────────────────────────────────────────────────────
342
+
343
+ def lint(self) -> LintResult:
344
+ """Validate all wiki pages for consistency.
345
+
346
+ Checks:
347
+ - Every wiki page has valid YAML frontmatter
348
+ - Wikilinks point to existing pages or store entries
349
+ - Orphaned pages (no inbound links) produce warnings
350
+ """
351
+ issues: List[LintIssue] = []
352
+
353
+ # Read all wiki pages from disk
354
+ pages = self._read_all_pages()
355
+ page_ids = {p.page_id for p in pages}
356
+ all_funcs = {
357
+ f.id for f in self._store.list_functions(limit=100000)
358
+ }
359
+
360
+ for page in pages:
361
+ content = page.content
362
+
363
+ # Check frontmatter
364
+ if not content.startswith("---"):
365
+ issues.append(LintIssue(
366
+ page_id=page.page_id,
367
+ severity="error",
368
+ message="Missing YAML frontmatter",
369
+ ))
370
+ continue
371
+
372
+ # Parse frontmatter boundaries
373
+ parts = content.split("---")
374
+ if len(parts) < 3:
375
+ issues.append(LintIssue(
376
+ page_id=page.page_id,
377
+ severity="error",
378
+ message="Malformed frontmatter (missing closing ---)",
379
+ ))
380
+
381
+ # Check wikilinks
382
+ links = _WIKILINK_RE.findall(content)
383
+ for link_target in links:
384
+ # Skip domain links and non-function links
385
+ if link_target.startswith("domain_"):
386
+ continue
387
+ if link_target not in page_ids and link_target not in all_funcs:
388
+ issues.append(LintIssue(
389
+ page_id=page.page_id,
390
+ severity="warning",
391
+ message=f"Broken wikilink: [[{link_target}]]",
392
+ ))
393
+
394
+ total = len(pages)
395
+ return LintResult(
396
+ total_pages=total,
397
+ issues=issues,
398
+ passed=all(i.severity != "error" for i in issues),
399
+ )
400
+
401
+ # ── File I/O helpers ──────────────────────────────────────────────
402
+
403
+ def write_page(self, page: WikiPage) -> Path:
404
+ """Write a WikiPage to disk under ``wiki_dir/entities/``."""
405
+ entities_dir = self.wiki_dir / "entities"
406
+ entities_dir.mkdir(parents=True, exist_ok=True)
407
+ path = entities_dir / f"{page.page_id}.md"
408
+ with self._lock:
409
+ path.write_text(page.content, encoding="utf-8")
410
+ return path
411
+
412
+ def write_index(self, page: WikiPage) -> Path:
413
+ """Write the index page to ``wiki_dir/index.md``."""
414
+ self.wiki_dir.mkdir(parents=True, exist_ok=True)
415
+ path = self.wiki_dir / "index.md"
416
+ with self._lock:
417
+ path.write_text(page.content, encoding="utf-8")
418
+ return path
419
+
420
+ def append_log(self, message: str) -> Path:
421
+ """Append a line to ``wiki_dir/log.md`` with rotation.
422
+
423
+ If log.md exceeds ``MAX_LOG_LINES``, rotate into archives
424
+ (log.archive.1.md, log.archive.2.md, ...). Keep at most
425
+ ``MAX_LOG_ARCHIVES`` archives.
426
+ """
427
+ self.wiki_dir.mkdir(parents=True, exist_ok=True)
428
+ log_path = self.wiki_dir / "log.md"
429
+
430
+ with self._lock:
431
+ # Rotate if needed
432
+ if log_path.exists():
433
+ line_count = sum(1 for _ in open(log_path, encoding="utf-8"))
434
+ if line_count >= MAX_LOG_LINES:
435
+ self._rotate_log(log_path)
436
+
437
+ timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
438
+ with open(log_path, "a", encoding="utf-8") as f:
439
+ f.write(f"- {timestamp}: {message}\n")
440
+
441
+ return log_path
442
+
443
+ # ── Private helpers ───────────────────────────────────────────────
444
+
445
+ @staticmethod
446
+ def _build_frontmatter(
447
+ *,
448
+ page_id: str,
449
+ name: str,
450
+ domain: str,
451
+ memory_type: str,
452
+ confidence: float,
453
+ created_at: str,
454
+ updated_at: str,
455
+ ) -> str:
456
+ """Build YAML frontmatter block for a wiki page."""
457
+ escaped_name = name.replace('"', '\\"').replace("\n", "\\n")
458
+ lines = [
459
+ "---",
460
+ f'id: "{page_id}"',
461
+ f'name: "{escaped_name}"',
462
+ f'domain: "{domain}"',
463
+ f'memory_type: "{memory_type}"',
464
+ f"confidence: {confidence}",
465
+ f'created_at: "{created_at}"',
466
+ f'updated_at: "{updated_at}"',
467
+ "---",
468
+ "",
469
+ ]
470
+ return "\n".join(lines)
471
+
472
+ @staticmethod
473
+ def _field_section(heading: str, values: List[FieldValue]) -> list[str]:
474
+ """Build markdown lines for a FieldValue list."""
475
+ if not values:
476
+ return []
477
+ lines: list[str] = [f"## {heading}"]
478
+ if len(values) == 1:
479
+ lines.append(values[0].desc)
480
+ else:
481
+ for fv in values:
482
+ lines.append(f"- {fv.desc}")
483
+ lines.append("")
484
+ return lines
485
+
486
+ def _fallback_search(self, query: str, top_k: int) -> List[SearchResult]:
487
+ """Degraded search via filename matching and grep.
488
+
489
+ Used when DualIndexSearch is not available (e.g. Lite backend).
490
+ """
491
+ results: List[SearchResult] = []
492
+ query_lower = query.lower()
493
+
494
+ # Search in store
495
+ funcs = self._store.list_functions(limit=100000)
496
+ for func in funcs:
497
+ score = 0.0
498
+ if query_lower in func.name.lower():
499
+ score += 0.8
500
+ if func.domain and query_lower in func.domain.lower():
501
+ score += 0.4
502
+ for fv in func.trigger + func.action + func.benefit:
503
+ if query_lower in fv.desc.lower():
504
+ score += 0.3
505
+ break
506
+ if score > 0:
507
+ results.append(SearchResult(
508
+ func_id=func.id,
509
+ name=func.name,
510
+ domain=func.domain or "",
511
+ relevance_score=min(score, 1.0),
512
+ summary="; ".join(
513
+ fv.desc for fv in func.action[:2]
514
+ ),
515
+ source_type=SourceType.WIKI,
516
+ created_at=func.created_at,
517
+ updated_at=func.updated_at,
518
+ ))
519
+
520
+ results.sort(key=lambda r: r.relevance_score, reverse=True)
521
+ return results[:top_k]
522
+
523
+ def _read_all_pages(self) -> List[WikiPage]:
524
+ """Read all entity pages from ``wiki_dir/entities/``."""
525
+ pages: List[WikiPage] = []
526
+ entities_dir = self.wiki_dir / "entities"
527
+ if not entities_dir.exists():
528
+ return pages
529
+ for md_file in entities_dir.glob("*.md"):
530
+ page_id = md_file.stem
531
+ content = md_file.read_text(encoding="utf-8")
532
+ pages.append(WikiPage(page_id=page_id, content=content))
533
+ return pages
534
+
535
+ def _rotate_log(self, log_path: Path) -> None:
536
+ """Rotate log.md into numbered archives."""
537
+ for i in range(MAX_LOG_ARCHIVES - 1, 0, -1):
538
+ src = self.wiki_dir / f"log.archive.{i}.md"
539
+ dst = self.wiki_dir / f"log.archive.{i + 1}.md"
540
+ if src.exists():
541
+ if i + 1 > MAX_LOG_ARCHIVES:
542
+ src.unlink()
543
+ else:
544
+ src.rename(dst)
545
+ log_path.rename(self.wiki_dir / "log.archive.1.md")