memkernel 0.4.1__tar.gz → 0.5.0__tar.gz

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 (43) hide show
  1. {memkernel-0.4.1 → memkernel-0.5.0}/PKG-INFO +13 -3
  2. {memkernel-0.4.1 → memkernel-0.5.0}/README.md +10 -2
  3. {memkernel-0.4.1 → memkernel-0.5.0}/pyproject.toml +7 -2
  4. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/__init__.py +1 -1
  5. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/config.py +3 -0
  6. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/mcp/tools/wiki.py +27 -2
  7. memkernel-0.5.0/src/memkernel/wiki/studio.py +193 -0
  8. memkernel-0.5.0/src/memkernel/wiki/sync.py +425 -0
  9. memkernel-0.4.1/src/memkernel/wiki/sync.py +0 -228
  10. {memkernel-0.4.1 → memkernel-0.5.0}/LICENSE +0 -0
  11. {memkernel-0.4.1 → memkernel-0.5.0}/NOTICE +0 -0
  12. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/adapters/__init__.py +0 -0
  13. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/adapters/rlmstudio.py +0 -0
  14. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/api/__init__.py +0 -0
  15. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/api/app.py +0 -0
  16. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/api/routes/__init__.py +0 -0
  17. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/api/routes/admin.py +0 -0
  18. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/core/__init__.py +0 -0
  19. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/core/models.py +0 -0
  20. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/core/ports.py +0 -0
  21. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/core/services.py +0 -0
  22. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/factory.py +0 -0
  23. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/lifecycle.py +0 -0
  24. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/mcp/__init__.py +0 -0
  25. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/mcp/server.py +0 -0
  26. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/mcp/tools/__init__.py +0 -0
  27. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/mcp/tools/memory.py +0 -0
  28. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/memory/__init__.py +0 -0
  29. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/memory/dedup.py +0 -0
  30. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/memory/lifecycle.py +0 -0
  31. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/memory/types.py +0 -0
  32. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/py.typed +0 -0
  33. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/retrieval/__init__.py +0 -0
  34. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/retrieval/chunking.py +0 -0
  35. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/retrieval/embeddings.py +0 -0
  36. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/retrieval/reasoner.py +0 -0
  37. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/retrieval/search.py +0 -0
  38. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/retrieval/sparse.py +0 -0
  39. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/storage/__init__.py +0 -0
  40. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/storage/chroma.py +0 -0
  41. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/storage/documents.py +0 -0
  42. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/wiki/__init__.py +0 -0
  43. {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/wiki/pages.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memkernel
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: Code-aware memory server for AI coding copilots
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Topic :: Software Development :: Libraries
16
+ Provides-Extra: wiki
16
17
  Requires-Dist: chromadb (>=1.0,<2)
17
18
  Requires-Dist: fastapi (>=0.115,<1)
18
19
  Requires-Dist: mcp (>=1.25,<2)
@@ -22,6 +23,7 @@ Requires-Dist: rank-bm25 (>=0.2)
22
23
  Requires-Dist: sentence-transformers (>=4.0,<6)
23
24
  Requires-Dist: tree-sitter (>=0.22,<1)
24
25
  Requires-Dist: tree-sitter-python (>=0.23)
26
+ Requires-Dist: wiki-studio (>=0.3.1,<1) ; extra == "wiki"
25
27
  Project-URL: Documentation, https://memkernel.dev
26
28
  Project-URL: Homepage, https://memkernel.dev
27
29
  Project-URL: Repository, https://github.com/gosha70/memkernel
@@ -63,7 +65,7 @@ MemKernel gives Claude Code (and any MCP-compatible copilot) persistent memory a
63
65
  - **An index of the whole repository.** `code` memories are the snapshots the agent chose to retain, not an automatic scan of every file.
64
66
  - **A cross-project knowledge base.** One store per project id. Sharing is deliberate: a second server with a shared id, or the wiki bridge.
65
67
 
66
- **Evidence.** `eval/run_eval.py` measures retrieval (recall@k, precision@k, MRR). An agent-level benchmark that runs the same agent with and without MemKernel, including a negative control where files already carry all state, is tracked in [#17](https://github.com/gosha70/memkernel/issues/17). Results will be published per minor release, including the cases with no gain.
68
+ **Evidence.** `eval/run_eval.py` measures retrieval (recall@k, precision@k, MRR). The 0.5 lifecycle evaluation, a **maintainer-run supplemental evaluation** (memkernel's own runner, preregistered and announced in [#17](https://github.com/gosha70/memkernel/issues/17) before the run), ran the same agent in seven arms on 20 tasks: [report](eval/lifecycle-suite/report-0.5.0.md). **No gain is claimed.** Against files alone (`claude_md`), memkernel's memory store was ahead on 6 tasks, behind on none and tied on 14 (Holm-adjusted p 0.0625: "no detectable difference"; with so many ties this task set could not have reached significance); against earlier transcripts (`fs_grep`) it was equivalent. Two of the four task groups hit a ceiling, every arm passing every trial. The report's disclosures list its deviations and known defects. Results are published per minor release, including the cases with no gain.
67
69
 
68
70
  ---
69
71
 
@@ -84,6 +86,9 @@ MemKernel is a Python package on PyPI. Its `memkernel` command is the MCP server
84
86
  pipx install memkernel
85
87
  # if pipx's default interpreter is outside 3.11–3.13:
86
88
  pipx install --python python3.11 memkernel
89
+ # optional: wiki-studio lint and health findings on wiki_export (ADR-010)
90
+ pipx install "memkernel[wiki]"
91
+ pipx inject memkernel "wiki-studio>=0.3.1,<1" # add it to an existing install
87
92
  ```
88
93
 
89
94
  **As a library inside a project**, install it into that project's virtual environment:
@@ -272,6 +277,7 @@ setting is logged as a warning at startup.
272
277
  | `MEMKERNEL_RECALL_WEIGHT_RECENCY` | `0.3` | Weight of recency in the final recall score |
273
278
  | `MEMKERNEL_RECALL_WEIGHT_IMPORTANCE` | `0.3` | Weight of importance in the final recall score |
274
279
  | `MEMKERNEL_WIKI_DIR` | `` | Directory of the LLM wiki used by `wiki_export` / `wiki_import` (empty = pass `wiki_dir` explicitly) |
280
+ | `MEMKERNEL_WIKI_PROPOSALS_DIR` | `""` | Where wiki-studio patch-sets are written before promote (`memkernel[wiki]`); empty means `<project root>/.memkernel/wiki-proposals`. Setting it requests the gate: without wiki-studio installed, a warning is logged. |
275
281
  | `MEMKERNEL_TRANSPORT` | `stdio` | `stdio` or `streamable-http` |
276
282
  | `MEMKERNEL_HOST` | `127.0.0.1` | Host for `streamable-http` transport |
277
283
  | `MEMKERNEL_PORT` | `8765` | Port for `streamable-http` transport |
@@ -351,7 +357,11 @@ Long-term memory has a human-readable twin: a markdown wiki in the [LLM Wiki](ht
351
357
  - `wiki_export` compiles every long-term memory that has no page yet into a **draft** page under `decisions/`, `concepts/` or `workflows/`, with the required sections and a `memkernel://<project>/<ref_id>` citation. Existing pages are never overwritten.
352
358
  - `wiki_import` indexes curated pages as long-term memories so `recall` finds the reviewed version; a changed page supersedes its earlier memory.
353
359
 
354
- Set `MEMKERNEL_WIKI_DIR` (for example `knowledge/wiki`) or pass `wiki_dir` to the tools. The standalone maintainer that owns this page schema is [wiki-studio](https://github.com/gosha70/wiki-studio) (`pip install wiki-studio`; `wiki-studio lint --health` checks what `wiki_export` writes). Rationale: [`docs/adr/ADR-008-llm-wiki-as-semantic-layer.md`](docs/adr/ADR-008-llm-wiki-as-semantic-layer.md).
360
+ Set `MEMKERNEL_WIKI_DIR` (for example `knowledge/wiki`) or pass `wiki_dir` to the tools. The standalone maintainer that owns this page schema is [wiki-studio](https://github.com/gosha70/wiki-studio). With the `memkernel[wiki]` extra installed, every `wiki_export` response carries wiki-studio's lint and health findings for the whole wiki after the export, curated pages included, under `lint` (`violations`, `audit_errors`, `pages_checked`, `health` items with `kind`, `severity`, `pages`, `description`); without it `lint` is `null` and `lint_hint` says how to enable it; if the lint pass itself fails, `lint` is `null` and `lint_error` says why. The files written are the same in every case.
361
+
362
+ With wiki-studio 0.3.1 or later installed, `wiki_export` writes through wiki-studio's promote gate: it first writes `index.md` and `log.md` if they are missing (the gate cannot create them), then proposes every new page with its index and log lines as one patch-set and promotes it. A rejected export writes nothing and the response carries `gate_error`; so does an installed wiki-studio that fails to import or to build the patch-set, rather than falling back to the direct writer. A failed export leaves its patch-set under the proposals directory for inspection; delete it once the cause is fixed. Patch-sets go to `MEMKERNEL_WIKI_PROPOSALS_DIR`, by default `.memkernel/wiki-proposals/` under the project root. Commit `<wiki>/.audit/`, wiki-studio's audit trail of promoted proposals; ignore `.memkernel/wiki-proposals/`.
363
+
364
+ Rationale: [`docs/adr/ADR-008-llm-wiki-as-semantic-layer.md`](docs/adr/ADR-008-llm-wiki-as-semantic-layer.md) and [`docs/adr/ADR-010-wiki-studio-as-wiki-maintainer.md`](docs/adr/ADR-010-wiki-studio-as-wiki-maintainer.md).
355
365
 
356
366
  ### Revalidation and store health
357
367
 
@@ -34,7 +34,7 @@ MemKernel gives Claude Code (and any MCP-compatible copilot) persistent memory a
34
34
  - **An index of the whole repository.** `code` memories are the snapshots the agent chose to retain, not an automatic scan of every file.
35
35
  - **A cross-project knowledge base.** One store per project id. Sharing is deliberate: a second server with a shared id, or the wiki bridge.
36
36
 
37
- **Evidence.** `eval/run_eval.py` measures retrieval (recall@k, precision@k, MRR). An agent-level benchmark that runs the same agent with and without MemKernel, including a negative control where files already carry all state, is tracked in [#17](https://github.com/gosha70/memkernel/issues/17). Results will be published per minor release, including the cases with no gain.
37
+ **Evidence.** `eval/run_eval.py` measures retrieval (recall@k, precision@k, MRR). The 0.5 lifecycle evaluation, a **maintainer-run supplemental evaluation** (memkernel's own runner, preregistered and announced in [#17](https://github.com/gosha70/memkernel/issues/17) before the run), ran the same agent in seven arms on 20 tasks: [report](eval/lifecycle-suite/report-0.5.0.md). **No gain is claimed.** Against files alone (`claude_md`), memkernel's memory store was ahead on 6 tasks, behind on none and tied on 14 (Holm-adjusted p 0.0625: "no detectable difference"; with so many ties this task set could not have reached significance); against earlier transcripts (`fs_grep`) it was equivalent. Two of the four task groups hit a ceiling, every arm passing every trial. The report's disclosures list its deviations and known defects. Results are published per minor release, including the cases with no gain.
38
38
 
39
39
  ---
40
40
 
@@ -55,6 +55,9 @@ MemKernel is a Python package on PyPI. Its `memkernel` command is the MCP server
55
55
  pipx install memkernel
56
56
  # if pipx's default interpreter is outside 3.11–3.13:
57
57
  pipx install --python python3.11 memkernel
58
+ # optional: wiki-studio lint and health findings on wiki_export (ADR-010)
59
+ pipx install "memkernel[wiki]"
60
+ pipx inject memkernel "wiki-studio>=0.3.1,<1" # add it to an existing install
58
61
  ```
59
62
 
60
63
  **As a library inside a project**, install it into that project's virtual environment:
@@ -243,6 +246,7 @@ setting is logged as a warning at startup.
243
246
  | `MEMKERNEL_RECALL_WEIGHT_RECENCY` | `0.3` | Weight of recency in the final recall score |
244
247
  | `MEMKERNEL_RECALL_WEIGHT_IMPORTANCE` | `0.3` | Weight of importance in the final recall score |
245
248
  | `MEMKERNEL_WIKI_DIR` | `` | Directory of the LLM wiki used by `wiki_export` / `wiki_import` (empty = pass `wiki_dir` explicitly) |
249
+ | `MEMKERNEL_WIKI_PROPOSALS_DIR` | `""` | Where wiki-studio patch-sets are written before promote (`memkernel[wiki]`); empty means `<project root>/.memkernel/wiki-proposals`. Setting it requests the gate: without wiki-studio installed, a warning is logged. |
246
250
  | `MEMKERNEL_TRANSPORT` | `stdio` | `stdio` or `streamable-http` |
247
251
  | `MEMKERNEL_HOST` | `127.0.0.1` | Host for `streamable-http` transport |
248
252
  | `MEMKERNEL_PORT` | `8765` | Port for `streamable-http` transport |
@@ -322,7 +326,11 @@ Long-term memory has a human-readable twin: a markdown wiki in the [LLM Wiki](ht
322
326
  - `wiki_export` compiles every long-term memory that has no page yet into a **draft** page under `decisions/`, `concepts/` or `workflows/`, with the required sections and a `memkernel://<project>/<ref_id>` citation. Existing pages are never overwritten.
323
327
  - `wiki_import` indexes curated pages as long-term memories so `recall` finds the reviewed version; a changed page supersedes its earlier memory.
324
328
 
325
- Set `MEMKERNEL_WIKI_DIR` (for example `knowledge/wiki`) or pass `wiki_dir` to the tools. The standalone maintainer that owns this page schema is [wiki-studio](https://github.com/gosha70/wiki-studio) (`pip install wiki-studio`; `wiki-studio lint --health` checks what `wiki_export` writes). Rationale: [`docs/adr/ADR-008-llm-wiki-as-semantic-layer.md`](docs/adr/ADR-008-llm-wiki-as-semantic-layer.md).
329
+ Set `MEMKERNEL_WIKI_DIR` (for example `knowledge/wiki`) or pass `wiki_dir` to the tools. The standalone maintainer that owns this page schema is [wiki-studio](https://github.com/gosha70/wiki-studio). With the `memkernel[wiki]` extra installed, every `wiki_export` response carries wiki-studio's lint and health findings for the whole wiki after the export, curated pages included, under `lint` (`violations`, `audit_errors`, `pages_checked`, `health` items with `kind`, `severity`, `pages`, `description`); without it `lint` is `null` and `lint_hint` says how to enable it; if the lint pass itself fails, `lint` is `null` and `lint_error` says why. The files written are the same in every case.
330
+
331
+ With wiki-studio 0.3.1 or later installed, `wiki_export` writes through wiki-studio's promote gate: it first writes `index.md` and `log.md` if they are missing (the gate cannot create them), then proposes every new page with its index and log lines as one patch-set and promotes it. A rejected export writes nothing and the response carries `gate_error`; so does an installed wiki-studio that fails to import or to build the patch-set, rather than falling back to the direct writer. A failed export leaves its patch-set under the proposals directory for inspection; delete it once the cause is fixed. Patch-sets go to `MEMKERNEL_WIKI_PROPOSALS_DIR`, by default `.memkernel/wiki-proposals/` under the project root. Commit `<wiki>/.audit/`, wiki-studio's audit trail of promoted proposals; ignore `.memkernel/wiki-proposals/`.
332
+
333
+ Rationale: [`docs/adr/ADR-008-llm-wiki-as-semantic-layer.md`](docs/adr/ADR-008-llm-wiki-as-semantic-layer.md) and [`docs/adr/ADR-010-wiki-studio-as-wiki-maintainer.md`](docs/adr/ADR-010-wiki-studio-as-wiki-maintainer.md).
326
334
 
327
335
  ### Revalidation and store health
328
336
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "memkernel"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "Code-aware memory server for AI coding copilots"
5
5
  authors = ["EGOGE"]
6
6
  license = "Apache-2.0"
@@ -31,6 +31,11 @@ tree-sitter-python = ">=0.23"
31
31
  pydantic = ">=2.0,<3"
32
32
  pydantic-settings = ">=2.0,<3"
33
33
  fastapi = ">=0.115,<1"
34
+ # Optional integrations (ADR-010): installed only through an extra; imported lazily.
35
+ wiki-studio = { version = ">=0.3.1,<1", optional = true }
36
+
37
+ [tool.poetry.extras]
38
+ wiki = ["wiki-studio"]
34
39
 
35
40
  [tool.poetry.group.dev.dependencies]
36
41
  pytest = ">=8"
@@ -40,7 +45,7 @@ ruff = ">=0.9"
40
45
  mypy = ">=1.13"
41
46
  httpx = ">=0.27"
42
47
  pip-audit = "^2.10.0"
43
- wiki-studio = ">=0.1,<1"
48
+ wiki-studio = ">=0.3.1,<1"
44
49
 
45
50
  [build-system]
46
51
  requires = ["poetry-core"]
@@ -2,4 +2,4 @@
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
  """MemKernel — Code-aware memory server for AI coding copilots."""
4
4
 
5
- __version__ = "0.4.1"
5
+ __version__ = "0.5.0"
@@ -76,6 +76,9 @@ class MemKernelSettings(BaseSettings):
76
76
  # LLM wiki bridge: directory holding the markdown wiki (index.md, decisions/, ...).
77
77
  # Empty = no wiki configured; wiki_export/wiki_import then require an explicit path.
78
78
  wiki_dir: str = ""
79
+ # Where wiki-studio patch-sets are written before promote (memkernel[wiki], ADR-010).
80
+ # Empty = <project_root>/.memkernel/wiki-proposals; set it to request the gate explicitly.
81
+ wiki_proposals_dir: str = ""
79
82
 
80
83
  # Server
81
84
  transport: Literal["stdio", "streamable-http"] = "stdio"
@@ -11,6 +11,7 @@ from typing import Any
11
11
  from mcp.server.fastmcp import FastMCP # noqa: TC002 — runtime import required by FastMCP introspection
12
12
  from mcp.server.fastmcp.server import Context # noqa: TC002
13
13
 
14
+ from memkernel.wiki.studio import INSTALL_HINT
14
15
  from memkernel.wiki.sync import WikiExporter, WikiImporter
15
16
 
16
17
 
@@ -38,11 +39,35 @@ def register_wiki_tools(server: FastMCP[Any]) -> None:
38
39
  """Compile long-term memories (decision, convention, fact, procedure) that have no wiki
39
40
  page yet into draft markdown pages under wiki_dir (default MEMKERNEL_WIKI_DIR), and
40
41
  append them to index.md and log.md. Review and curate the drafts before marking stable.
42
+ With the memkernel[wiki] extra installed, "lint" carries wiki-studio's violations, audit
43
+ errors and health findings for the whole wiki after the export (curated pages included);
44
+ without the extra it is null and "lint_hint" says how to enable it; if the lint pass itself
45
+ failed it is null and "lint_error" says why. With wiki-studio >= 0.3 the pages go through its
46
+ promote gate (index.md and log.md are scaffolded first); a rejected export, or an installed but
47
+ broken wiki-studio, writes nothing and reports "gate_error". The pages, index.md and log.md
48
+ written are the same either way.
41
49
  """
42
50
  app_ctx = _context(ctx)
43
- exporter = WikiExporter(app_ctx.memory_service, _resolve_wiki_dir(wiki_dir, app_ctx.settings))
51
+ proposals = app_ctx.settings.wiki_proposals_dir
52
+ exporter = WikiExporter(
53
+ app_ctx.memory_service,
54
+ _resolve_wiki_dir(wiki_dir, app_ctx.settings),
55
+ project_root=Path(app_ctx.settings.project_root),
56
+ proposals_dir=Path(proposals) if proposals else None,
57
+ )
44
58
  report = exporter.export()
45
- return json.dumps({"written": report.written, "skipped": report.skipped})
59
+ payload: dict[str, Any] = {"written": report.written, "skipped": report.skipped}
60
+ if report.gate_error is not None:
61
+ payload["gate_error"] = report.gate_error
62
+ if report.lint is not None:
63
+ payload["lint"] = report.lint.model_dump(mode="json")
64
+ elif report.lint_error is not None:
65
+ payload["lint"] = None
66
+ payload["lint_error"] = report.lint_error
67
+ else:
68
+ payload["lint"] = None
69
+ payload["lint_hint"] = INSTALL_HINT
70
+ return json.dumps(payload)
46
71
 
47
72
  @server.tool()
48
73
  async def wiki_import(
@@ -0,0 +1,193 @@
1
+ # Copyright (c) 2026 EGOGE
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Optional wiki-studio integration (the ``memkernel[wiki]`` extra, ADR-010).
4
+
5
+ wiki-studio owns the wiki page schema and its lint and health pass. This
6
+ module is the only place memkernel imports it, lazily: without the extra
7
+ every function here returns ``None`` and logs nothing, because a missing
8
+ optional extra is a normal state. A warning is logged only when the caller
9
+ asked for wiki-studio explicitly (``require_studio=True``). Nothing here ever calls
10
+ wiki-studio's ``ingest``, ``query`` or a copilot-CLI backend: those shell out
11
+ to a copilot CLI, and memkernel runs inside one.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from pydantic import BaseModel, Field
21
+
22
+ if TYPE_CHECKING:
23
+ from pathlib import Path
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ INSTALL_HINT = "install memkernel[wiki] for lint and health"
28
+ GATE_BACKEND = "memkernel"
29
+ GATE_RATIONALE = "memkernel export"
30
+
31
+
32
+ class HealthItem(BaseModel):
33
+ """One knowledge-health finding, field for field as wiki-studio's ``HealthFinding``."""
34
+
35
+ kind: str
36
+ severity: str
37
+ pages: list[str]
38
+ description: str
39
+
40
+
41
+ class LintSummary(BaseModel):
42
+ """wiki-studio's structural lint, audit-format and health results for one wiki directory."""
43
+
44
+ violations: list[str] = Field(default_factory=list)
45
+ audit_errors: list[str] = Field(default_factory=list)
46
+ pages_checked: int = 0
47
+ health: list[HealthItem] | None = None
48
+
49
+
50
+ class LintError(RuntimeError):
51
+ """wiki-studio is installed but its lint pass failed; the export itself is unaffected."""
52
+
53
+
54
+ def load_studio(*, require_studio: bool = False) -> Any | None:
55
+ """Return the ``wiki_studio.api`` module, or ``None`` when the extra is not installed.
56
+
57
+ Only a missing ``wiki_studio`` package counts as absence. Any other import
58
+ failure means the package is present but broken or incompatible, which is
59
+ raised as :class:`LintError` so it is reported rather than mistaken for the
60
+ extra being absent.
61
+ """
62
+ try:
63
+ import wiki_studio.api as api
64
+ except ModuleNotFoundError as exc:
65
+ if exc.name == "wiki_studio":
66
+ if require_studio:
67
+ logger.warning("wiki-studio is not installed; %s", INSTALL_HINT)
68
+ return None
69
+ raise LintError(f"wiki-studio is installed but broken: {exc}") from exc
70
+ except Exception as exc: # ImportError or any error raised while the package initialises
71
+ raise LintError(f"wiki-studio is installed but broken: {exc}") from exc
72
+ return api
73
+
74
+
75
+ def lint_wiki(wiki_dir: Path, project_root: Path, *, require_studio: bool = False) -> LintSummary | None:
76
+ """Run wiki-studio's lint with the health checks over the whole wiki in ``wiki_dir``.
77
+
78
+ Returns ``None`` without the extra. Raises :class:`LintError` for every failure
79
+ of an installed wiki-studio: a broken or incompatible package, a page that is
80
+ not valid UTF-8, a malformed audit tree, a result whose shape memkernel does
81
+ not recognise. The caller decides what the failure means for its own result.
82
+ ``project_root`` is the root ``path:`` sources resolve against (memkernel's
83
+ ``MEMKERNEL_PROJECT_ROOT``). No backend is passed, so the LLM-dependent
84
+ contradiction check is skipped.
85
+ """
86
+ try:
87
+ api = load_studio(require_studio=require_studio)
88
+ if api is None:
89
+ return None
90
+ from wiki_studio.paths import WikiPaths
91
+
92
+ result = api.lint(WikiPaths.for_root(project_root, wiki_dir=wiki_dir), health=True)
93
+ health = (
94
+ [
95
+ HealthItem(
96
+ kind=str(finding.kind),
97
+ severity=str(finding.severity),
98
+ pages=list(finding.pages),
99
+ description=finding.description,
100
+ )
101
+ for finding in result.health.findings
102
+ ]
103
+ if result.health is not None
104
+ else None
105
+ )
106
+ return LintSummary(
107
+ violations=list(result.violations),
108
+ audit_errors=list(result.audit_errors),
109
+ pages_checked=result.pages_checked,
110
+ health=health,
111
+ )
112
+ except LintError as exc: # broken installation, already wrapped by load_studio
113
+ logger.warning("wiki-studio lint failed for %s: %s", wiki_dir, exc)
114
+ raise
115
+ except Exception as exc: # third-party failure of an installed package: wrapped, never silent
116
+ logger.warning("wiki-studio lint failed for %s: %s", wiki_dir, exc)
117
+ raise LintError(f"wiki-studio lint failed: {exc}") from exc
118
+
119
+
120
+ class GateError(RuntimeError):
121
+ """wiki-studio's gate rejected or failed to apply an export; the pages were not written."""
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class GatedPage:
126
+ """One page of an export as the gate needs it: the page itself plus its index and log lines."""
127
+
128
+ path: str # wiki-relative, e.g. "decisions/use-arq.md"
129
+ content: str
130
+ index_line: str | None # one line of the export's single append-index edit; None = no entry
131
+ log_line: str | None # one line of the export's single append-log edit; None = no entry
132
+
133
+
134
+ def gate_available() -> Any | None:
135
+ """Return ``wiki_studio.api`` when it offers the proposal gate (wiki-studio >= 0.3), else ``None``.
136
+
137
+ ``None`` means absent or an older wiki-studio without the gate, where the
138
+ direct writer is the supported fallback. A present but broken package
139
+ raises :class:`GateError`: the validator is installed, so its failure must
140
+ not be mistaken for its absence and let pages through unchecked.
141
+ """
142
+ try:
143
+ api = load_studio()
144
+ except LintError as exc:
145
+ raise GateError(str(exc)) from exc
146
+ if api is None or not hasattr(api, "propose") or not hasattr(api, "PageEdit"):
147
+ return None
148
+ return api
149
+
150
+
151
+ def propose_and_promote(
152
+ api: Any, pages: list[GatedPage], wiki_dir: Path, project_root: Path, proposals_dir: Path, name: str
153
+ ) -> None:
154
+ """Write ``pages`` as one patch-set and apply it through wiki-studio's promote gate.
155
+
156
+ ``index.md`` and ``log.md`` must already exist: the gate appends to them but
157
+ refuses to create them. Raises :class:`GateError` on any rejection or
158
+ failure; wiki-studio validates before writing, so a rejected patch-set
159
+ leaves the wiki as it was.
160
+ """
161
+ # One append per file: wiki-studio 0.3.1 keeps only the last of several appends to the same file in a
162
+ # patch-set (fixed in 0.4.0, wiki-studio#18; kept while the floor is 0.3.1), so every index line goes into a
163
+ # single append-index edit and every log line into one append-log.
164
+ index_lines = [page.index_line for page in pages if page.index_line is not None]
165
+ log_lines = [page.log_line for page in pages if page.log_line is not None]
166
+ try: # every wiki-studio call is inside the boundary, setup included, so the caller's cleanup always runs
167
+ from wiki_studio.paths import WikiPaths
168
+
169
+ edits = [
170
+ api.PageEdit(path=page.path, action="create", new_content=page.content, rationale=GATE_RATIONALE)
171
+ for page in pages
172
+ ]
173
+ if index_lines:
174
+ edits.append(
175
+ api.PageEdit(
176
+ path="index.md",
177
+ action="append-index",
178
+ new_content="\n".join(index_lines),
179
+ rationale=GATE_RATIONALE,
180
+ )
181
+ )
182
+ if log_lines:
183
+ edits.append(
184
+ api.PageEdit(
185
+ path="log.md", action="append-log", new_content="\n".join(log_lines), rationale=GATE_RATIONALE
186
+ )
187
+ )
188
+ paths = WikiPaths.for_root(project_root, wiki_dir=wiki_dir, proposals_dir=proposals_dir)
189
+ proposal = api.propose(edits, paths, name=name, source="memkernel wiki_export", backend=GATE_BACKEND)
190
+ api.promote(proposal, paths)
191
+ except Exception as exc: # any rejection or failure of the installed gate is reported, never raised raw
192
+ logger.warning("wiki-studio gate rejected the export to %s: %s", wiki_dir, exc)
193
+ raise GateError(f"wiki-studio gate rejected the export: {exc}") from exc
@@ -0,0 +1,425 @@
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 contextlib
21
+ import logging
22
+ import uuid
23
+ from dataclasses import dataclass, field
24
+ from datetime import UTC, datetime
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING
27
+
28
+ from memkernel.core.models import MemoryTier, MemoryType
29
+ from memkernel.wiki.pages import WikiPage, parse_page, render_page, slugify
30
+ from memkernel.wiki.studio import (
31
+ GatedPage,
32
+ GateError,
33
+ LintError,
34
+ LintSummary,
35
+ gate_available,
36
+ lint_wiki,
37
+ load_studio,
38
+ propose_and_promote,
39
+ )
40
+
41
+ if TYPE_CHECKING:
42
+ from memkernel.core.models import Memory
43
+ from memkernel.core.services import MemoryService
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ WIKI_TAG = "wiki"
48
+ INDEX_FILE = "index.md"
49
+ LOG_FILE = "log.md"
50
+ DEFAULT_PROPOSALS_SUBDIR = Path(".memkernel") / "wiki-proposals"
51
+
52
+ # MemoryType -> (page_type, directory). CODE is not exported: code lives in the
53
+ # repo. DOCUMENT is not exported either: documents are sources, not wiki pages.
54
+ EXPORT_MAP: dict[MemoryType, tuple[str, str]] = {
55
+ MemoryType.DECISION: ("decision", "decisions"),
56
+ MemoryType.CONVENTION: ("concept", "concepts"),
57
+ MemoryType.FACT: ("concept", "concepts"),
58
+ MemoryType.PROCEDURE: ("workflow", "workflows"),
59
+ }
60
+
61
+ # page_type -> MemoryType for import. Pages without a mapping are skipped.
62
+ IMPORT_MAP: dict[str, MemoryType] = {
63
+ "decision": MemoryType.DECISION,
64
+ "concept": MemoryType.FACT,
65
+ "overview": MemoryType.FACT,
66
+ "glossary": MemoryType.FACT,
67
+ "incident": MemoryType.FACT,
68
+ "open-question": MemoryType.FACT,
69
+ "workflow": MemoryType.PROCEDURE,
70
+ "playbook": MemoryType.PROCEDURE,
71
+ }
72
+
73
+ # Required H2 sections per page type (first one receives the memory content).
74
+ PAGE_SECTIONS: dict[str, list[str]] = {
75
+ "decision": ["Decision", "Context", "Alternatives considered", "Consequences"],
76
+ "concept": ["Summary", "Key ideas", "Where this shows up", "Related"],
77
+ "workflow": ["When to use this", "Steps", "Verification", "Related"],
78
+ }
79
+
80
+ PLACEHOLDER = "_Not recorded in memory — curate before marking this page stable._"
81
+
82
+
83
+ def _today() -> str:
84
+ return datetime.now(UTC).date().isoformat()
85
+
86
+
87
+ @dataclass
88
+ class ExportReport:
89
+ """Outcome of an export run.
90
+
91
+ ``lint`` holds wiki-studio's lint and health results for the **whole wiki**
92
+ as it is after the export (curated pages included), or ``None`` when the
93
+ ``memkernel[wiki]`` extra is not installed or its lint pass failed; in the
94
+ latter case ``lint_error`` says why. The written files are the same in
95
+ every case.
96
+ """
97
+
98
+ written: list[str] = field(default_factory=list)
99
+ skipped: int = 0
100
+ lint: LintSummary | None = None
101
+ lint_error: str | None = None
102
+ gated: bool = False # the pages went through wiki-studio's promote gate
103
+ gate_error: str | None = None # the gate rejected the export; nothing was written
104
+
105
+
106
+ @dataclass
107
+ class ImportReport:
108
+ """Outcome of an import run."""
109
+
110
+ imported: list[str] = field(default_factory=list)
111
+ skipped: list[str] = field(default_factory=list)
112
+
113
+
114
+ class WikiExporter:
115
+ """Compile long-term memories into draft wiki pages under ``wiki_dir``."""
116
+
117
+ def __init__(
118
+ self,
119
+ service: MemoryService,
120
+ wiki_dir: Path,
121
+ project_root: Path | None = None,
122
+ proposals_dir: Path | None = None,
123
+ ) -> None:
124
+ self._service = service
125
+ self._wiki_dir = Path(wiki_dir)
126
+ # The root ``path:`` sources resolve against; memkernel's project root when the tool passes it.
127
+ self._project_root = Path(project_root) if project_root is not None else self._wiki_dir.parent
128
+ # An explicit proposals directory is a request for the gate (FR-006): its absence is then worth a warning.
129
+ self._proposals_requested = proposals_dir is not None
130
+ self._proposals_dir = (
131
+ Path(proposals_dir) if proposals_dir is not None else self._project_root / DEFAULT_PROPOSALS_SUBDIR
132
+ )
133
+
134
+ def export(self, memories: list[Memory] | None = None, update_index: bool = True) -> ExportReport:
135
+ """Write one page per exportable memory that is not already a wiki page.
136
+
137
+ Memories that came from the wiki (tagged ``wiki``), chunk records, or
138
+ memories a newer memory supersedes (``superseded_by`` set) are skipped. Existing pages with
139
+ the same slug are left untouched: the wiki, once written, is curated
140
+ by humans/agents, not overwritten by the store. With the ``memkernel[wiki]``
141
+ extra installed the report carries wiki-studio's lint and health findings
142
+ for the whole wiki as it is after the export; a lint failure is reported
143
+ in ``lint_error`` and never fails the export.
144
+ """
145
+ report = ExportReport()
146
+ candidates = memories if memories is not None else self._service.list_memories(tier=MemoryTier.LONG_TERM)
147
+ planned: list[tuple[str, WikiPage]] = []
148
+ for memory in candidates:
149
+ mapping = EXPORT_MAP.get(memory.type)
150
+ if mapping is None or WIKI_TAG in memory.tags or memory.superseded_by or memory.chunk_refs:
151
+ report.skipped += 1
152
+ continue
153
+ page_type, directory = mapping
154
+ page = self._page_for(memory, page_type)
155
+ rel = f"{directory}/{page.slug}.md"
156
+ if (self._wiki_dir / rel).exists() or any(r == rel for r, _ in planned):
157
+ report.skipped += 1
158
+ continue
159
+ planned.append((rel, page))
160
+
161
+ # Pages without index entries are orphans, which the gate rejects by design; update_index=False
162
+ # therefore always takes the direct writer.
163
+ api = None
164
+ try:
165
+ api = gate_available() if planned and update_index else None
166
+ except GateError as exc: # wiki-studio installed but broken: never fall back to unchecked writes
167
+ report.gate_error = str(exc)
168
+ report.skipped += len(planned)
169
+ planned = []
170
+ if self._proposals_requested and api is None and report.gate_error is None:
171
+ if planned and not update_index:
172
+ logger.warning(
173
+ "wiki gate requested (proposals directory set) but update_index=False bypasses it: "
174
+ "pages without index entries cannot pass the gate; writing directly"
175
+ )
176
+ else:
177
+ # logs once that the requested gate is unavailable; a broken installation with nothing
178
+ # to write is reported by the lint pass below
179
+ with contextlib.suppress(LintError):
180
+ load_studio(require_studio=True)
181
+ if api is not None:
182
+ self._export_gated(api, planned, report)
183
+ else:
184
+ for rel, page in planned:
185
+ target = self._wiki_dir / rel
186
+ target.parent.mkdir(parents=True, exist_ok=True)
187
+ target.write_text(render_page(page), encoding="utf-8")
188
+ report.written.append(rel)
189
+ if update_index:
190
+ self._append_index(rel, page)
191
+ self._append_log(page, "created")
192
+ try:
193
+ report.lint = lint_wiki(self._wiki_dir, self._project_root)
194
+ except LintError as exc:
195
+ report.lint_error = str(exc)
196
+ return report
197
+
198
+ def _export_gated(self, api: object, planned: list[tuple[str, WikiPage]], report: ExportReport) -> None:
199
+ """Scaffold first (the gate cannot create index.md or log.md), then every page through propose + promote."""
200
+ index_path = self._wiki_dir / INDEX_FILE
201
+ try:
202
+ index_text = index_path.read_text(encoding="utf-8") if index_path.exists() else ""
203
+ except (OSError, UnicodeDecodeError) as exc: # reported like every other gate failure, nothing written
204
+ report.gate_error = f"could not read {INDEX_FILE}: {exc}"
205
+ report.skipped += len(planned)
206
+ logger.warning("wiki export to %s failed: %s", self._wiki_dir, report.gate_error)
207
+ return
208
+ pages = [
209
+ GatedPage(
210
+ path=rel,
211
+ content=render_page(page),
212
+ # same rule as the direct writer: no second index entry for a page already linked
213
+ index_line=self._index_line(rel, page) if f"]({rel})" not in index_text else None,
214
+ log_line=self._log_line(page, "created"),
215
+ )
216
+ for rel, page in planned
217
+ ]
218
+ name = f"memkernel-export-{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}"
219
+ snapshot: _WikiSnapshot | None = None
220
+ try:
221
+ snapshot = _WikiSnapshot.take(self._wiki_dir)
222
+ self._ensure_scaffold()
223
+ propose_and_promote(api, pages, self._wiki_dir, self._project_root, self._proposals_dir, name)
224
+ except (GateError, OSError) as exc:
225
+ message = str(exc) if isinstance(exc, GateError) else f"the wiki could not be prepared for the gate: {exc}"
226
+ if isinstance(exc, OSError):
227
+ logger.warning("wiki export to %s failed: %s", self._wiki_dir, message)
228
+ problems = snapshot.restore() if snapshot is not None else []
229
+ if problems:
230
+ message += "; the wiki could not be fully restored: " + "; ".join(problems)
231
+ logger.warning("wiki export to %s left changes behind: %s", self._wiki_dir, "; ".join(problems))
232
+ report.gate_error = message
233
+ report.skipped += len(pages)
234
+ return
235
+ report.gated = True
236
+ report.written.extend(page.path for page in pages)
237
+
238
+ def _ensure_scaffold(self) -> None:
239
+ self._wiki_dir.mkdir(parents=True, exist_ok=True)
240
+ for name, text in ((INDEX_FILE, _index_scaffold()), (LOG_FILE, _log_scaffold())):
241
+ path = self._wiki_dir / name
242
+ if not path.exists():
243
+ path.write_text(text, encoding="utf-8")
244
+
245
+ @staticmethod
246
+ def _index_line(rel: str, page: WikiPage) -> str:
247
+ return f"- [{page.title}]({rel}) — draft compiled from memkernel ({page.extra.get('memory_type', '')})."
248
+
249
+ @staticmethod
250
+ def _log_line(page: WikiPage, verb: str) -> str:
251
+ ref = page.extra.get("memkernel_ref", "")
252
+ return f"- {_today()} — {verb} `{page.slug}` ({page.page_type}): compiled from memkernel {ref}"
253
+
254
+ def _page_for(self, memory: Memory, page_type: str) -> WikiPage:
255
+ title = memory.content.strip().splitlines()[0][:80] if memory.content.strip() else memory.ref_id
256
+ slug = slugify(title)
257
+ sections = PAGE_SECTIONS[page_type]
258
+ body_lines = [f"# {title}", ""]
259
+ for i, heading in enumerate(sections):
260
+ body_lines.append(f"## {heading}")
261
+ body_lines.append("")
262
+ body_lines.append(memory.content.strip() if i == 0 else PLACEHOLDER)
263
+ body_lines.append("")
264
+ sources: list[dict[str, str]] = [
265
+ {"url": f"memkernel://{memory.project_id}/{memory.ref_id}", "retrieved": _today()}
266
+ ]
267
+ if memory.source:
268
+ sources.append({"path": memory.source})
269
+ return WikiPage(
270
+ page_type=page_type,
271
+ slug=slug,
272
+ title=title,
273
+ status="draft",
274
+ last_reviewed=_today(),
275
+ sources=sources,
276
+ body="\n".join(body_lines),
277
+ extra={"memkernel_ref": memory.ref_id, "memory_type": memory.type.value},
278
+ )
279
+
280
+ def _append_index(self, rel: str, page: WikiPage) -> None:
281
+ index = self._wiki_dir / INDEX_FILE
282
+ if not index.exists():
283
+ index.write_text(_index_scaffold(), encoding="utf-8")
284
+ text = index.read_text(encoding="utf-8")
285
+ if f"]({rel})" in text:
286
+ return
287
+ if not text.endswith("\n"):
288
+ text += "\n"
289
+ index.write_text(text + self._index_line(rel, page) + "\n", encoding="utf-8")
290
+
291
+ def _append_log(self, page: WikiPage, verb: str) -> None:
292
+ log = self._wiki_dir / LOG_FILE
293
+ if not log.exists():
294
+ log.write_text(_log_scaffold(), encoding="utf-8")
295
+ with log.open("a", encoding="utf-8") as fh:
296
+ fh.write(self._log_line(page, verb) + "\n")
297
+
298
+
299
+ @dataclass
300
+ class _WikiSnapshot:
301
+ """Every file a gated export can write, taken before it starts, so a failure restores exactly that.
302
+
303
+ wiki-studio's promote before 0.4.1 (gosha70/wiki-studio#21) rolls back only
304
+ the writes that completed, and only on ``OSError``: a page or ``index.md`` whose
305
+ write failed part-way stays as it was left. Its commit step writes every ``*.md`` of the staged wiki that
306
+ differs from the live one, plus ``.audit/**/plan.json``, so the snapshot holds
307
+ all of those as bytes (not only the files memkernel's edits change), and any
308
+ such file that appears during the export is removed again. The cost is one
309
+ read of every page per gated export, which is negligible at the size of a
310
+ project wiki (tens to hundreds of pages).
311
+ """
312
+
313
+ wiki_dir: Path
314
+ files: dict[Path, bytes]
315
+ dirs: set[Path] # directories that existed, the wiki directory included when it did
316
+
317
+ @classmethod
318
+ def take(cls, wiki_dir: Path) -> _WikiSnapshot:
319
+ files = {path: path.read_bytes() for path in cls._tracked(wiki_dir)}
320
+ dirs = {path for path in wiki_dir.rglob("*") if path.is_dir()} if wiki_dir.is_dir() else set()
321
+ if wiki_dir.is_dir():
322
+ dirs.add(wiki_dir)
323
+ return cls(wiki_dir, files, dirs)
324
+
325
+ @staticmethod
326
+ def _tracked(wiki_dir: Path) -> list[Path]:
327
+ if not wiki_dir.is_dir():
328
+ return []
329
+ return [
330
+ path
331
+ for path in wiki_dir.rglob("*")
332
+ if path.is_file() and (path.suffix == ".md" or (path.name == "plan.json" and ".audit" in path.parts))
333
+ ]
334
+
335
+ def restore(self) -> list[str]:
336
+ """Put every tracked file back; returns what could not be restored (empty = the wiki is as it was)."""
337
+ problems: list[str] = []
338
+ for path in self._tracked(self.wiki_dir):
339
+ if path not in self.files:
340
+ try:
341
+ path.unlink()
342
+ except OSError as exc:
343
+ problems.append(self._problem(path, exc))
344
+ for path, original in self.files.items():
345
+ try:
346
+ if not path.is_file() or path.read_bytes() != original:
347
+ path.parent.mkdir(parents=True, exist_ok=True)
348
+ path.write_bytes(original)
349
+ except OSError as exc:
350
+ problems.append(self._problem(path, exc))
351
+ created = [path for path in self.wiki_dir.rglob("*") if path.is_dir() and path not in self.dirs]
352
+ if self.wiki_dir.is_dir() and self.wiki_dir not in self.dirs:
353
+ created.append(self.wiki_dir)
354
+ for directory in sorted(created, key=lambda d: len(d.parts), reverse=True):
355
+ try:
356
+ if not any(directory.iterdir()):
357
+ directory.rmdir()
358
+ except OSError as exc:
359
+ problems.append(self._problem(directory, exc))
360
+ return problems
361
+
362
+ def _problem(self, path: Path, exc: OSError) -> str:
363
+ return f"{path.relative_to(self.wiki_dir.parent).as_posix()}: {exc}"
364
+
365
+
366
+ def _index_scaffold() -> str:
367
+ return (
368
+ "---\npage_type: index\nslug: index\ntitle: Wiki Index\nstatus: draft\n"
369
+ f"last_reviewed: {_today()}\n---\n\n# Wiki Index\n\n## Compiled from memkernel\n\n"
370
+ )
371
+
372
+
373
+ def _log_scaffold() -> str:
374
+ return (
375
+ "---\npage_type: log\nslug: log\ntitle: Wiki Edit Log\nstatus: draft\n"
376
+ f"last_reviewed: {_today()}\n---\n\n# Wiki Edit Log\n\n## Entries\n\n"
377
+ )
378
+
379
+
380
+ class WikiImporter:
381
+ """Index curated wiki pages as long-term memories."""
382
+
383
+ def __init__(self, service: MemoryService, wiki_dir: Path) -> None:
384
+ self._service = service
385
+ self._wiki_dir = Path(wiki_dir)
386
+
387
+ def import_pages(self, session_id: str | None = None) -> ImportReport:
388
+ """Retain every page with a mapped page_type; return what was imported/skipped."""
389
+ report = ImportReport()
390
+ existing = {
391
+ self._page_tag(m): m
392
+ for m in self._service.list_memories(tier=MemoryTier.LONG_TERM)
393
+ if WIKI_TAG in m.tags and self._page_tag(m)
394
+ }
395
+ for path in sorted(self._wiki_dir.rglob("*.md")):
396
+ rel = path.relative_to(self._wiki_dir).as_posix()
397
+ if rel.startswith(("schema/", "scripts/", ".")):
398
+ continue
399
+ page = parse_page(path.read_text(encoding="utf-8"))
400
+ memory_type = IMPORT_MAP.get(page.page_type) if page else None
401
+ if page is None or memory_type is None or page.status == "deprecated":
402
+ report.skipped.append(rel)
403
+ continue
404
+ content = f"{page.title}\n\n{page.body}".strip()
405
+ tags = [WIKI_TAG, f"page:{page.slug}", f"page_type:{page.page_type}"]
406
+ previous = existing.get(f"page:{page.slug}")
407
+ supersedes = previous.ref_id if previous is not None and previous.content != content else None
408
+ self._service.retain(
409
+ content=content,
410
+ type=memory_type,
411
+ session_id=session_id,
412
+ source=rel,
413
+ importance=0.9 if page.status == "stable" else 0.7,
414
+ tags=tags,
415
+ supersedes=supersedes,
416
+ )
417
+ report.imported.append(rel)
418
+ return report
419
+
420
+ @staticmethod
421
+ def _page_tag(memory: Memory) -> str:
422
+ for tag in memory.tags:
423
+ if tag.startswith("page:"):
424
+ return tag
425
+ return ""
@@ -1,228 +0,0 @@
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
40
- # repo. DOCUMENT is not exported either: documents are sources, not wiki pages.
41
- EXPORT_MAP: dict[MemoryType, tuple[str, str]] = {
42
- MemoryType.DECISION: ("decision", "decisions"),
43
- MemoryType.CONVENTION: ("concept", "concepts"),
44
- MemoryType.FACT: ("concept", "concepts"),
45
- MemoryType.PROCEDURE: ("workflow", "workflows"),
46
- }
47
-
48
- # page_type -> MemoryType for import. Pages without a mapping are skipped.
49
- IMPORT_MAP: dict[str, MemoryType] = {
50
- "decision": MemoryType.DECISION,
51
- "concept": MemoryType.FACT,
52
- "overview": MemoryType.FACT,
53
- "glossary": MemoryType.FACT,
54
- "incident": MemoryType.FACT,
55
- "open-question": MemoryType.FACT,
56
- "workflow": MemoryType.PROCEDURE,
57
- "playbook": MemoryType.PROCEDURE,
58
- }
59
-
60
- # Required H2 sections per page type (first one receives the memory content).
61
- PAGE_SECTIONS: dict[str, list[str]] = {
62
- "decision": ["Decision", "Context", "Alternatives considered", "Consequences"],
63
- "concept": ["Summary", "Key ideas", "Where this shows up", "Related"],
64
- "workflow": ["When to use this", "Steps", "Verification", "Related"],
65
- }
66
-
67
- PLACEHOLDER = "_Not recorded in memory — curate before marking this page stable._"
68
-
69
-
70
- def _today() -> str:
71
- return datetime.now(UTC).date().isoformat()
72
-
73
-
74
- @dataclass
75
- class ExportReport:
76
- """Outcome of an export run."""
77
-
78
- written: list[str] = field(default_factory=list)
79
- skipped: int = 0
80
-
81
-
82
- @dataclass
83
- class ImportReport:
84
- """Outcome of an import run."""
85
-
86
- imported: list[str] = field(default_factory=list)
87
- skipped: list[str] = field(default_factory=list)
88
-
89
-
90
- class WikiExporter:
91
- """Compile long-term memories into draft wiki pages under ``wiki_dir``."""
92
-
93
- def __init__(self, service: MemoryService, wiki_dir: Path) -> None:
94
- self._service = service
95
- self._wiki_dir = Path(wiki_dir)
96
-
97
- def export(self, memories: list[Memory] | None = None, update_index: bool = True) -> ExportReport:
98
- """Write one page per exportable memory that is not already a wiki page.
99
-
100
- Memories that came from the wiki (tagged ``wiki``), chunk records, or
101
- memories a newer memory supersedes (``superseded_by`` set) are skipped. Existing pages with
102
- the same slug are left untouched: the wiki, once written, is curated
103
- by humans/agents, not overwritten by the store.
104
- """
105
- report = ExportReport()
106
- candidates = memories if memories is not None else self._service.list_memories(tier=MemoryTier.LONG_TERM)
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.superseded_by 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 ""
File without changes
File without changes