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.
- {memkernel-0.4.1 → memkernel-0.5.0}/PKG-INFO +13 -3
- {memkernel-0.4.1 → memkernel-0.5.0}/README.md +10 -2
- {memkernel-0.4.1 → memkernel-0.5.0}/pyproject.toml +7 -2
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/__init__.py +1 -1
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/config.py +3 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/mcp/tools/wiki.py +27 -2
- memkernel-0.5.0/src/memkernel/wiki/studio.py +193 -0
- memkernel-0.5.0/src/memkernel/wiki/sync.py +425 -0
- memkernel-0.4.1/src/memkernel/wiki/sync.py +0 -228
- {memkernel-0.4.1 → memkernel-0.5.0}/LICENSE +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/NOTICE +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/adapters/__init__.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/adapters/rlmstudio.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/api/__init__.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/api/app.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/api/routes/__init__.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/api/routes/admin.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/core/__init__.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/core/models.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/core/ports.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/core/services.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/factory.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/lifecycle.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/mcp/__init__.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/mcp/server.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/mcp/tools/__init__.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/mcp/tools/memory.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/memory/__init__.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/memory/dedup.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/memory/lifecycle.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/memory/types.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/py.typed +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/retrieval/__init__.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/retrieval/chunking.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/retrieval/embeddings.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/retrieval/reasoner.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/retrieval/search.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/retrieval/sparse.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/storage/__init__.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/storage/chroma.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/storage/documents.py +0 -0
- {memkernel-0.4.1 → memkernel-0.5.0}/src/memkernel/wiki/__init__.py +0 -0
- {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.
|
|
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).
|
|
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)
|
|
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).
|
|
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)
|
|
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.
|
|
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"]
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|