memkernel 0.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.
@@ -0,0 +1,165 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Wiki page model plus a dependency-free frontmatter parser/renderer.
4
+
5
+ The page format follows the schema used by ``code-copilot-team/knowledge/wiki``
6
+ (page_type, slug, title, status, last_reviewed, sources) so that pages compiled
7
+ by MemKernel pass that wiki's structural linter unchanged.
8
+
9
+ Only the YAML subset those pages use is supported: top-level scalars and a
10
+ ``sources:`` list whose items are flat ``key: value`` mappings.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Any
17
+
18
+ from pydantic import BaseModel, Field
19
+
20
+ _FRONTMATTER_RE = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*\n?", re.DOTALL)
21
+ _SLUG_RE = re.compile(r"[^a-z0-9]+")
22
+
23
+
24
+ class WikiPage(BaseModel):
25
+ """A single markdown page with its frontmatter."""
26
+
27
+ page_type: str
28
+ slug: str
29
+ title: str
30
+ status: str = "draft"
31
+ last_reviewed: str = ""
32
+ sources: list[dict[str, str]] = Field(default_factory=list)
33
+ body: str = ""
34
+ extra: dict[str, str] = Field(default_factory=dict)
35
+
36
+
37
+ def slugify(text: str, max_len: int = 60) -> str:
38
+ """Return a kebab-case slug suitable for a wiki filename."""
39
+ slug = _SLUG_RE.sub("-", text.lower()).strip("-")
40
+ if len(slug) > max_len:
41
+ slug = slug[:max_len].rstrip("-")
42
+ return slug or "untitled"
43
+
44
+
45
+ def _unquote(value: str) -> str:
46
+ value = value.strip()
47
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
48
+ return value[1:-1]
49
+ return value
50
+
51
+
52
+ def _parse_frontmatter(block: str) -> tuple[dict[str, str], list[dict[str, str]]]:
53
+ """Parse the supported YAML subset into (scalars, sources)."""
54
+ scalars: dict[str, str] = {}
55
+ sources: list[dict[str, str]] = []
56
+ in_sources = False
57
+ current: dict[str, str] | None = None
58
+ for raw in block.splitlines():
59
+ if not raw.strip() or raw.lstrip().startswith("#"):
60
+ continue
61
+ indented = raw[0] in " \t"
62
+ line = raw.strip()
63
+ if not indented:
64
+ in_sources = False
65
+ current = None
66
+ key, sep, value = line.partition(":")
67
+ if not sep:
68
+ continue
69
+ key = key.strip()
70
+ if key == "sources":
71
+ in_sources = True
72
+ continue
73
+ scalars[key] = _unquote(value)
74
+ continue
75
+ if not in_sources:
76
+ continue
77
+ if line.startswith("- "):
78
+ current = {}
79
+ sources.append(current)
80
+ line = line[2:].strip()
81
+ if current is None:
82
+ continue
83
+ key, sep, value = line.partition(":")
84
+ if sep:
85
+ current[key.strip()] = _unquote(value)
86
+ return scalars, sources
87
+
88
+
89
+ def parse_page(text: str) -> WikiPage | None:
90
+ """Parse a markdown document with YAML frontmatter. Returns None if it has none."""
91
+ match = _FRONTMATTER_RE.match(text)
92
+ if match is None:
93
+ return None
94
+ scalars, sources = _parse_frontmatter(match.group(1))
95
+ body = text[match.end() :]
96
+ known = {"page_type", "slug", "title", "status", "last_reviewed"}
97
+ page_type = scalars.get("page_type", "")
98
+ slug = scalars.get("slug", "")
99
+ if not page_type or not slug:
100
+ return None
101
+ return WikiPage(
102
+ page_type=page_type,
103
+ slug=slug,
104
+ title=scalars.get("title", slug),
105
+ status=scalars.get("status", "draft"),
106
+ last_reviewed=scalars.get("last_reviewed", ""),
107
+ sources=sources,
108
+ body=body.strip("\n") + "\n" if body.strip() else "",
109
+ extra={k: v for k, v in scalars.items() if k not in known},
110
+ )
111
+
112
+
113
+ def _quote(value: str) -> str:
114
+ """Quote a scalar when YAML would otherwise misread it."""
115
+ if value == "" or any(ch in value for ch in ":#\"'[]{}") or value != value.strip():
116
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
117
+ return value
118
+
119
+
120
+ def render_page(page: WikiPage) -> str:
121
+ """Render a WikiPage back to markdown with frontmatter."""
122
+ lines = [
123
+ "---",
124
+ f"page_type: {page.page_type}",
125
+ f"slug: {page.slug}",
126
+ f"title: {_quote(page.title)}",
127
+ f"status: {page.status}",
128
+ f"last_reviewed: {page.last_reviewed}",
129
+ ]
130
+ for key, value in page.extra.items():
131
+ lines.append(f"{key}: {_quote(value)}")
132
+ if page.sources:
133
+ lines.append("sources:")
134
+ for source in page.sources:
135
+ first = True
136
+ for key, value in source.items():
137
+ prefix = " - " if first else " "
138
+ lines.append(f"{prefix}{key}: {_quote(value)}")
139
+ first = False
140
+ lines.append("---")
141
+ lines.append("")
142
+ return "\n".join(lines) + page.body
143
+
144
+
145
+ def body_sections(body: str) -> dict[str, str]:
146
+ """Split a page body into its ``##`` sections (heading -> text)."""
147
+ sections: dict[str, str] = {}
148
+ current: str | None = None
149
+ buffer: list[str] = []
150
+ for line in body.splitlines():
151
+ if line.startswith("## "):
152
+ if current is not None:
153
+ sections[current] = "\n".join(buffer).strip()
154
+ current = line[3:].strip()
155
+ buffer = []
156
+ elif current is not None:
157
+ buffer.append(line)
158
+ if current is not None:
159
+ sections[current] = "\n".join(buffer).strip()
160
+ return sections
161
+
162
+
163
+ def page_to_dict(page: WikiPage) -> dict[str, Any]:
164
+ """JSON-friendly view of a page (used by the MCP tools)."""
165
+ return page.model_dump()
memkernel/wiki/sync.py ADDED
@@ -0,0 +1,228 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Export long-term memories to wiki pages and import wiki pages as memories.
4
+
5
+ Direction 1 — ``WikiExporter`` ("compile"): every long-term memory that has no
6
+ wiki page yet becomes a *draft* page in the matching section, with the page
7
+ sections the wiki schema requires. The exporter is deliberately LLM-free: it
8
+ produces a reviewable draft (the memory content in the primary section,
9
+ placeholders elsewhere) and leaves synthesis to the agent or curator, mirroring
10
+ the proposal → promote flow of ``code-copilot-team/scripts/wiki``.
11
+
12
+ Direction 2 — ``WikiImporter`` ("index"): every curated page becomes a
13
+ long-term memory (type by page_type) so ``recall`` can find it. Re-running the
14
+ importer is idempotent: unchanged pages dedup on content hash, changed pages
15
+ supersede their previous version.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from dataclasses import dataclass, field
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING
25
+
26
+ from memkernel.core.models import MemoryTier, MemoryType
27
+ from memkernel.wiki.pages import WikiPage, parse_page, render_page, slugify
28
+
29
+ if TYPE_CHECKING:
30
+ from memkernel.core.models import Memory
31
+ from memkernel.core.services import MemoryService
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ WIKI_TAG = "wiki"
36
+ INDEX_FILE = "index.md"
37
+ LOG_FILE = "log.md"
38
+
39
+ # MemoryType -> (page_type, directory). CODE is not exported: code lives in the repo.
40
+ EXPORT_MAP: dict[MemoryType, tuple[str, str]] = {
41
+ MemoryType.DECISION: ("decision", "decisions"),
42
+ MemoryType.CONVENTION: ("concept", "concepts"),
43
+ MemoryType.FACT: ("concept", "concepts"),
44
+ MemoryType.PROCEDURE: ("workflow", "workflows"),
45
+ }
46
+
47
+ # page_type -> MemoryType for import. Pages without a mapping are skipped.
48
+ IMPORT_MAP: dict[str, MemoryType] = {
49
+ "decision": MemoryType.DECISION,
50
+ "concept": MemoryType.FACT,
51
+ "overview": MemoryType.FACT,
52
+ "glossary": MemoryType.FACT,
53
+ "incident": MemoryType.FACT,
54
+ "open-question": MemoryType.FACT,
55
+ "workflow": MemoryType.PROCEDURE,
56
+ "playbook": MemoryType.PROCEDURE,
57
+ }
58
+
59
+ # Required H2 sections per page type (first one receives the memory content).
60
+ PAGE_SECTIONS: dict[str, list[str]] = {
61
+ "decision": ["Decision", "Context", "Alternatives considered", "Consequences"],
62
+ "concept": ["Summary", "Key ideas", "Where this shows up", "Related"],
63
+ "workflow": ["When to use this", "Steps", "Verification", "Related"],
64
+ }
65
+
66
+ PLACEHOLDER = "_Not recorded in memory — curate before marking this page stable._"
67
+
68
+
69
+ def _today() -> str:
70
+ return datetime.now(UTC).date().isoformat()
71
+
72
+
73
+ @dataclass
74
+ class ExportReport:
75
+ """Outcome of an export run."""
76
+
77
+ written: list[str] = field(default_factory=list)
78
+ skipped: int = 0
79
+
80
+
81
+ @dataclass
82
+ class ImportReport:
83
+ """Outcome of an import run."""
84
+
85
+ imported: list[str] = field(default_factory=list)
86
+ skipped: list[str] = field(default_factory=list)
87
+
88
+
89
+ class WikiExporter:
90
+ """Compile long-term memories into draft wiki pages under ``wiki_dir``."""
91
+
92
+ def __init__(self, service: MemoryService, wiki_dir: Path) -> None:
93
+ self._service = service
94
+ self._wiki_dir = Path(wiki_dir)
95
+
96
+ def export(self, memories: list[Memory] | None = None, update_index: bool = True) -> ExportReport:
97
+ """Write one page per exportable memory that is not already a wiki page.
98
+
99
+ Memories that came from the wiki (tagged ``wiki``), chunk records, or
100
+ memories a newer memory supersedes are skipped. Existing pages with
101
+ the same slug are left untouched: the wiki, once written, is curated
102
+ by humans/agents, not overwritten by the store.
103
+ """
104
+ report = ExportReport()
105
+ candidates = memories if memories is not None else self._service.list_memories(tier=MemoryTier.LONG_TERM)
106
+ superseded = {m.supersedes for m in candidates if m.supersedes}
107
+ for memory in candidates:
108
+ mapping = EXPORT_MAP.get(memory.type)
109
+ if mapping is None or WIKI_TAG in memory.tags or memory.ref_id in superseded or memory.chunk_refs:
110
+ report.skipped += 1
111
+ continue
112
+ page_type, directory = mapping
113
+ page = self._page_for(memory, page_type)
114
+ target = self._wiki_dir / directory / f"{page.slug}.md"
115
+ if target.exists():
116
+ report.skipped += 1
117
+ continue
118
+ target.parent.mkdir(parents=True, exist_ok=True)
119
+ target.write_text(render_page(page), encoding="utf-8")
120
+ rel = f"{directory}/{page.slug}.md"
121
+ report.written.append(rel)
122
+ if update_index:
123
+ self._append_index(rel, page)
124
+ self._append_log(page, "created")
125
+ return report
126
+
127
+ def _page_for(self, memory: Memory, page_type: str) -> WikiPage:
128
+ title = memory.content.strip().splitlines()[0][:80] if memory.content.strip() else memory.ref_id
129
+ slug = slugify(title)
130
+ sections = PAGE_SECTIONS[page_type]
131
+ body_lines = [f"# {title}", ""]
132
+ for i, heading in enumerate(sections):
133
+ body_lines.append(f"## {heading}")
134
+ body_lines.append("")
135
+ body_lines.append(memory.content.strip() if i == 0 else PLACEHOLDER)
136
+ body_lines.append("")
137
+ sources: list[dict[str, str]] = [
138
+ {"url": f"memkernel://{memory.project_id}/{memory.ref_id}", "retrieved": _today()}
139
+ ]
140
+ if memory.source:
141
+ sources.append({"path": memory.source})
142
+ return WikiPage(
143
+ page_type=page_type,
144
+ slug=slug,
145
+ title=title,
146
+ status="draft",
147
+ last_reviewed=_today(),
148
+ sources=sources,
149
+ body="\n".join(body_lines),
150
+ extra={"memkernel_ref": memory.ref_id, "memory_type": memory.type.value},
151
+ )
152
+
153
+ def _append_index(self, rel: str, page: WikiPage) -> None:
154
+ index = self._wiki_dir / INDEX_FILE
155
+ line = f"- [{page.title}]({rel}) — draft compiled from memkernel ({page.extra.get('memory_type', '')})."
156
+ if not index.exists():
157
+ index.write_text(
158
+ "---\npage_type: index\nslug: index\ntitle: Wiki Index\nstatus: draft\n"
159
+ f"last_reviewed: {_today()}\n---\n\n# Wiki Index\n\n## Compiled from memkernel\n\n",
160
+ encoding="utf-8",
161
+ )
162
+ text = index.read_text(encoding="utf-8")
163
+ if f"]({rel})" in text:
164
+ return
165
+ if not text.endswith("\n"):
166
+ text += "\n"
167
+ index.write_text(text + line + "\n", encoding="utf-8")
168
+
169
+ def _append_log(self, page: WikiPage, verb: str) -> None:
170
+ log = self._wiki_dir / LOG_FILE
171
+ if not log.exists():
172
+ log.write_text(
173
+ "---\npage_type: log\nslug: log\ntitle: Wiki Edit Log\nstatus: draft\n"
174
+ f"last_reviewed: {_today()}\n---\n\n# Wiki Edit Log\n\n## Entries\n\n",
175
+ encoding="utf-8",
176
+ )
177
+ ref = page.extra.get("memkernel_ref", "")
178
+ entry = f"- {_today()} — {verb} `{page.slug}` ({page.page_type}): compiled from memkernel {ref}\n"
179
+ with log.open("a", encoding="utf-8") as fh:
180
+ fh.write(entry)
181
+
182
+
183
+ class WikiImporter:
184
+ """Index curated wiki pages as long-term memories."""
185
+
186
+ def __init__(self, service: MemoryService, wiki_dir: Path) -> None:
187
+ self._service = service
188
+ self._wiki_dir = Path(wiki_dir)
189
+
190
+ def import_pages(self, session_id: str | None = None) -> ImportReport:
191
+ """Retain every page with a mapped page_type; return what was imported/skipped."""
192
+ report = ImportReport()
193
+ existing = {
194
+ self._page_tag(m): m
195
+ for m in self._service.list_memories(tier=MemoryTier.LONG_TERM)
196
+ if WIKI_TAG in m.tags and self._page_tag(m)
197
+ }
198
+ for path in sorted(self._wiki_dir.rglob("*.md")):
199
+ rel = path.relative_to(self._wiki_dir).as_posix()
200
+ if rel.startswith(("schema/", "scripts/", ".")):
201
+ continue
202
+ page = parse_page(path.read_text(encoding="utf-8"))
203
+ memory_type = IMPORT_MAP.get(page.page_type) if page else None
204
+ if page is None or memory_type is None or page.status == "deprecated":
205
+ report.skipped.append(rel)
206
+ continue
207
+ content = f"{page.title}\n\n{page.body}".strip()
208
+ tags = [WIKI_TAG, f"page:{page.slug}", f"page_type:{page.page_type}"]
209
+ previous = existing.get(f"page:{page.slug}")
210
+ supersedes = previous.ref_id if previous is not None and previous.content != content else None
211
+ self._service.retain(
212
+ content=content,
213
+ type=memory_type,
214
+ session_id=session_id,
215
+ source=rel,
216
+ importance=0.9 if page.status == "stable" else 0.7,
217
+ tags=tags,
218
+ supersedes=supersedes,
219
+ )
220
+ report.imported.append(rel)
221
+ return report
222
+
223
+ @staticmethod
224
+ def _page_tag(memory: Memory) -> str:
225
+ for tag in memory.tags:
226
+ if tag.startswith("page:"):
227
+ return tag
228
+ return ""