universal-memory 0.1.0__tar.gz → 0.1.2__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 (85) hide show
  1. {universal_memory-0.1.0 → universal_memory-0.1.2}/PKG-INFO +26 -7
  2. {universal_memory-0.1.0 → universal_memory-0.1.2}/README.md +16 -6
  3. {universal_memory-0.1.0 → universal_memory-0.1.2}/pyproject.toml +36 -1
  4. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/__init__.py +1 -1
  5. universal_memory-0.1.2/src/universal_memory/application/diagnostics/__init__.py +15 -0
  6. universal_memory-0.1.2/src/universal_memory/application/diagnostics/doctor_use_case.py +365 -0
  7. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/host/setup_host_use_case.py +6 -1
  8. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/memory/get_memory_status_use_case.py +2 -0
  9. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/bootstrap/cli.py +3 -0
  10. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/bootstrap/mcp.py +3 -0
  11. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/interfaces/cli/init_command.py +104 -0
  12. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/interfaces/mcp/server.py +27 -0
  13. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/__main__.py +0 -0
  14. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/__init__.py +0 -0
  15. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/host/__init__.py +0 -0
  16. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/host/drift_detector.py +0 -0
  17. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/host/sync_instructions_use_case.py +0 -0
  18. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/memory/__init__.py +0 -0
  19. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/memory/assemble_context_summary_use_case.py +0 -0
  20. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/memory/context_hygiene_use_case.py +0 -0
  21. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/memory/list_facts_use_case.py +0 -0
  22. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/memory/purge_fact_use_case.py +0 -0
  23. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/memory/remember_fact_use_case.py +0 -0
  24. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/memory/search_facts_use_case.py +0 -0
  25. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/onboarding/__init__.py +0 -0
  26. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/onboarding/setup_project.py +0 -0
  27. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/security/__init__.py +0 -0
  28. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/security/list_audit_log_use_case.py +0 -0
  29. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/security/list_snapshots_use_case.py +0 -0
  30. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/security/rollback_use_case.py +0 -0
  31. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/security/safe_write_use_case.py +0 -0
  32. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/skills/__init__.py +0 -0
  33. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/skills/generate_skill.py +0 -0
  34. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/skills/list_skills.py +0 -0
  35. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/skills/native_skill_sync.py +0 -0
  36. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/skills/propose_skill.py +0 -0
  37. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/skills/track_latent_skill.py +0 -0
  38. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/skills/update_skill.py +0 -0
  39. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/update/__init__.py +0 -0
  40. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/application/update/update_use_cases.py +0 -0
  41. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/bootstrap/__init__.py +0 -0
  42. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/__init__.py +0 -0
  43. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/entities/__init__.py +0 -0
  44. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/entities/audit_event.py +0 -0
  45. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/entities/base.py +0 -0
  46. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/entities/context_summary.py +0 -0
  47. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/entities/fact.py +0 -0
  48. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/entities/host.py +0 -0
  49. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/entities/instruction_target.py +0 -0
  50. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/entities/latent_skill.py +0 -0
  51. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/entities/rule.py +0 -0
  52. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/entities/runtime.py +0 -0
  53. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/entities/safe_write_result.py +0 -0
  54. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/entities/snapshot.py +0 -0
  55. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/exceptions.py +0 -0
  56. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/ports/__init__.py +0 -0
  57. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/ports/audit_log_repository.py +0 -0
  58. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/ports/config_validation_port.py +0 -0
  59. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/ports/context_summary_repository.py +0 -0
  60. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/ports/fact_repository.py +0 -0
  61. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/ports/latent_skill_repository.py +0 -0
  62. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/ports/project_layout_port.py +0 -0
  63. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/ports/rule_repository.py +0 -0
  64. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/ports/secret_scanner_port.py +0 -0
  65. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/ports/snapshot_repository.py +0 -0
  66. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/domain/project_layout.py +0 -0
  67. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/__init__.py +0 -0
  68. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/config/__init__.py +0 -0
  69. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/config/adapters.py +0 -0
  70. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/config/project_layout.py +0 -0
  71. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/config/toml_loader.py +0 -0
  72. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/security/__init__.py +0 -0
  73. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/security/entropy_secret_scanner.py +0 -0
  74. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/security/local_audit_log_repository.py +0 -0
  75. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/security/local_snapshot_repository.py +0 -0
  76. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/storage/__init__.py +0 -0
  77. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/storage/local_context_summary_repository.py +0 -0
  78. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/storage/local_fact_repository.py +0 -0
  79. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/storage/local_latent_skill_repository.py +0 -0
  80. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/infrastructure/storage/local_rule_repository.py +0 -0
  81. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/interfaces/__init__.py +0 -0
  82. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/interfaces/cli/__init__.py +0 -0
  83. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/interfaces/cli/message_catalog.py +0 -0
  84. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/interfaces/errors.py +0 -0
  85. {universal_memory-0.1.0 → universal_memory-0.1.2}/src/universal_memory/interfaces/mcp/__init__.py +0 -0
@@ -1,10 +1,19 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: universal-memory
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Vendor-agnostic cognitive persistence layer for AI agents.
5
+ Keywords: agent-memory,ai,ai-agents,agent-skills,claude-code,codex,context-engineering,developer-tools,llm,mcp,memory
5
6
  Author: Yan L. Amorelli
6
7
  Author-email: Yan L. Amorelli <dev@amorelliaoyan.com>
7
8
  License: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Topic :: Utilities
8
17
  Requires-Dist: fastmcp>=0.1.0
9
18
  Requires-Dist: pydantic>=2.0
10
19
  Requires-Dist: rich>=15.0.0
@@ -15,11 +24,15 @@ Project-URL: Homepage, https://github.com/YanAmorelli/universal-memory
15
24
  Project-URL: Repository, https://github.com/YanAmorelli/universal-memory
16
25
  Description-Content-Type: text/markdown
17
26
 
27
+ <p align="center">
28
+ <img src="assets/umem-logo.png" alt="Universal Memory logo" width="720">
29
+ </p>
30
+
18
31
  # Universal Memory (umem)
19
32
 
20
33
  [![PyPI version](https://img.shields.io/pypi/v/universal-memory.svg)](https://pypi.org/project/universal-memory/)
21
- [![Python Version](https://img.shields.io/pypi/pyversions/universal-memory.svg)](https://img.shields.io/project/universal-memory/)
22
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
34
+ [![Python Version](https://img.shields.io/pypi/pyversions/universal-memory.svg)](https://pypi.org/project/universal-memory/)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/YanAmorelli/universal-memory/blob/dev/LICENSE)
23
36
 
24
37
  A vendor-agnostic cognitive persistence layer for AI agents. Eliminate the "repetition tax" by transporting your context, preferences, guidelines, and history seamlessly across sessions, IDEs, and LLM models.
25
38
 
@@ -27,7 +40,7 @@ To see the core idea visually, check out the [Excalidraw design](https://excalid
27
40
 
28
41
  ![Universal Memory MVP Proposal](docs/UNIVERSAL-MEMORY-MVP-PROPOSAL.png)
29
42
 
30
- ### Diagram Breakdown (English Translation)
43
+ ### Diagram Breakdown
31
44
 
32
45
  * **Short-Term Memory (Ephemeral):** Project-specific (folder-level) memories. A simple summary of recent changes, pending tasks, and project or task-level constraints.
33
46
  * **Agents Behaviours:** Comports the user's expected agent behaviors. Instead of requesting the same settings in every session, the agent understands the user by their traits, thoughts, and any context key to enhancing the overall experience. This encompasses:
@@ -72,12 +85,18 @@ Encapsulates complex, repetitive procedural instructions into formal Agent Skill
72
85
 
73
86
  Ensure you have Python 3.12+ installed. You can run or install `umem` using your preferred package manager.
74
87
 
75
- ### Run instantly with `uv` (Recommended)
76
- You don't even need to install it permanently:
88
+ ### Try instantly with `uvx`
89
+ You can run `umem` without installing it permanently:
77
90
  ```bash
78
- uvx umem --help
91
+ uvx --from universal-memory umem --help
79
92
  ```
80
93
 
94
+ > [!WARNING]
95
+ > `uvx` is best for quick trials. For ongoing use, install Universal Memory as a persistent tool so `umem` is always available and can fully manage long-lived global memories and synced agent skills:
96
+ > ```bash
97
+ > uv tool install universal-memory
98
+ > ```
99
+
81
100
  ### Install via PyPI
82
101
  ```bash
83
102
  pip install universal-memory
@@ -1,8 +1,12 @@
1
+ <p align="center">
2
+ <img src="assets/umem-logo.png" alt="Universal Memory logo" width="720">
3
+ </p>
4
+
1
5
  # Universal Memory (umem)
2
6
 
3
7
  [![PyPI version](https://img.shields.io/pypi/v/universal-memory.svg)](https://pypi.org/project/universal-memory/)
4
- [![Python Version](https://img.shields.io/pypi/pyversions/universal-memory.svg)](https://img.shields.io/project/universal-memory/)
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+ [![Python Version](https://img.shields.io/pypi/pyversions/universal-memory.svg)](https://pypi.org/project/universal-memory/)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/YanAmorelli/universal-memory/blob/dev/LICENSE)
6
10
 
7
11
  A vendor-agnostic cognitive persistence layer for AI agents. Eliminate the "repetition tax" by transporting your context, preferences, guidelines, and history seamlessly across sessions, IDEs, and LLM models.
8
12
 
@@ -10,7 +14,7 @@ To see the core idea visually, check out the [Excalidraw design](https://excalid
10
14
 
11
15
  ![Universal Memory MVP Proposal](docs/UNIVERSAL-MEMORY-MVP-PROPOSAL.png)
12
16
 
13
- ### Diagram Breakdown (English Translation)
17
+ ### Diagram Breakdown
14
18
 
15
19
  * **Short-Term Memory (Ephemeral):** Project-specific (folder-level) memories. A simple summary of recent changes, pending tasks, and project or task-level constraints.
16
20
  * **Agents Behaviours:** Comports the user's expected agent behaviors. Instead of requesting the same settings in every session, the agent understands the user by their traits, thoughts, and any context key to enhancing the overall experience. This encompasses:
@@ -55,12 +59,18 @@ Encapsulates complex, repetitive procedural instructions into formal Agent Skill
55
59
 
56
60
  Ensure you have Python 3.12+ installed. You can run or install `umem` using your preferred package manager.
57
61
 
58
- ### Run instantly with `uv` (Recommended)
59
- You don't even need to install it permanently:
62
+ ### Try instantly with `uvx`
63
+ You can run `umem` without installing it permanently:
60
64
  ```bash
61
- uvx umem --help
65
+ uvx --from universal-memory umem --help
62
66
  ```
63
67
 
68
+ > [!WARNING]
69
+ > `uvx` is best for quick trials. For ongoing use, install Universal Memory as a persistent tool so `umem` is always available and can fully manage long-lived global memories and synced agent skills:
70
+ > ```bash
71
+ > uv tool install universal-memory
72
+ > ```
73
+
64
74
  ### Install via PyPI
65
75
  ```bash
66
76
  pip install universal-memory
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "universal-memory"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "Vendor-agnostic cognitive persistence layer for AI agents."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -8,6 +8,29 @@ authors = [
8
8
  { name = "Yan L. Amorelli", email = "dev@amorelliaoyan.com" }
9
9
  ]
10
10
  requires-python = ">=3.12"
11
+ keywords = [
12
+ "agent-memory",
13
+ "ai",
14
+ "ai-agents",
15
+ "agent-skills",
16
+ "claude-code",
17
+ "codex",
18
+ "context-engineering",
19
+ "developer-tools",
20
+ "llm",
21
+ "mcp",
22
+ "memory",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "Intended Audience :: Developers",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3 :: Only",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
31
+ "Topic :: Software Development :: Libraries :: Python Modules",
32
+ "Topic :: Utilities",
33
+ ]
11
34
  dependencies = [
12
35
  "fastmcp>=0.1.0",
13
36
  "pydantic>=2.0",
@@ -44,6 +67,18 @@ line-length = 100
44
67
  select = ["E", "F", "I", "N", "UP", "PL", "RUF", "B", "S"]
45
68
 
46
69
  [tool.ruff.lint.per-file-ignores]
70
+ "scripts/alpha_tester.py" = [
71
+ "E501",
72
+ "F841",
73
+ "PLR0912",
74
+ "PLR0915",
75
+ "PLW1510",
76
+ "RUF005",
77
+ "RUF059",
78
+ "S101",
79
+ "S603",
80
+ "S607",
81
+ ]
47
82
  "tests/**/*.py" = ["S101"]
48
83
  "src/universal_memory/interfaces/cli/init_command.py" = ["E501"]
49
84
 
@@ -5,4 +5,4 @@ from importlib.metadata import PackageNotFoundError, version
5
5
  try:
6
6
  __version__ = version("universal-memory")
7
7
  except PackageNotFoundError:
8
- __version__ = "0.1.0"
8
+ __version__ = "0.1.2"
@@ -0,0 +1,15 @@
1
+ from universal_memory.application.diagnostics.doctor_use_case import (
2
+ DoctorCheck,
3
+ DoctorCommand,
4
+ DoctorResult,
5
+ DoctorSummary,
6
+ DoctorUseCase,
7
+ )
8
+
9
+ __all__ = [
10
+ "DoctorCheck",
11
+ "DoctorCommand",
12
+ "DoctorResult",
13
+ "DoctorSummary",
14
+ "DoctorUseCase",
15
+ ]
@@ -0,0 +1,365 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import sys
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from universal_memory.application.host import ConfigureHostCommand, ConfigureHostResult
11
+ from universal_memory.infrastructure.config.project_layout import (
12
+ DIRECTORY_LAYOUT_PATHS,
13
+ PROJECT_LAYOUT_PATHS,
14
+ )
15
+ from universal_memory.infrastructure.config.toml_loader import load_config
16
+
17
+ CheckStatus = str
18
+ HostCheckCommand = Callable[[ConfigureHostCommand], ConfigureHostResult]
19
+ WhichResolver = Callable[[str], str | None]
20
+ CONFIG_LOAD_ERROR_PREFIX = "__config_load_error__:"
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class DoctorCommand:
25
+ project_root: Path
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class DoctorCheck:
30
+ name: str
31
+ status: CheckStatus
32
+ detail: str | None = None
33
+ error: str | None = None
34
+ recovery_hint: str | None = None
35
+
36
+ def to_payload(self) -> dict[str, str]:
37
+ payload = {"name": self.name, "status": self.status}
38
+ if self.detail is not None:
39
+ payload["detail"] = self.detail
40
+ if self.error is not None:
41
+ payload["error"] = self.error
42
+ if self.recovery_hint is not None:
43
+ payload["recovery_hint"] = self.recovery_hint
44
+ return payload
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class DoctorSummary:
49
+ total_checks: int
50
+ passed: int
51
+ failed: int
52
+
53
+ def to_payload(self) -> dict[str, int]:
54
+ return {
55
+ "total_checks": self.total_checks,
56
+ "passed": self.passed,
57
+ "failed": self.failed,
58
+ }
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class DoctorResult:
63
+ checks: list[DoctorCheck]
64
+
65
+ @property
66
+ def ok(self) -> bool:
67
+ return all(check.status == "success" for check in self.checks)
68
+
69
+ @property
70
+ def summary(self) -> DoctorSummary:
71
+ passed = sum(1 for check in self.checks if check.status == "success")
72
+ failed = len(self.checks) - passed
73
+ return DoctorSummary(total_checks=len(self.checks), passed=passed, failed=failed)
74
+
75
+ def to_payload(self) -> dict[str, object]:
76
+ return {
77
+ "checks": [check.to_payload() for check in self.checks],
78
+ "summary": self.summary.to_payload(),
79
+ }
80
+
81
+
82
+ class DoctorUseCase:
83
+ def __init__( # noqa: PLR0913
84
+ self,
85
+ *,
86
+ host_check_command: HostCheckCommand | None = None,
87
+ which: WhichResolver = shutil.which,
88
+ version_info: tuple[int, int, int] | None = None,
89
+ home: Path | None = None,
90
+ xdg_data_home: Path | None = None,
91
+ xdg_config_home: Path | None = None,
92
+ ) -> None:
93
+ self.host_check_command = host_check_command
94
+ self.which = which
95
+ self.version_info = version_info or (
96
+ sys.version_info.major,
97
+ sys.version_info.minor,
98
+ sys.version_info.micro,
99
+ )
100
+ self.home = home or Path.home()
101
+ self.xdg_data_home = xdg_data_home
102
+ self.xdg_config_home = xdg_config_home
103
+
104
+ def execute(self, command: DoctorCommand) -> DoctorResult:
105
+ project_root = command.project_root.resolve()
106
+ checks = [
107
+ self._python_version_check(),
108
+ self._path_permissions_check(project_root),
109
+ self._project_layout_check(project_root),
110
+ self._path_executables_check(),
111
+ self._hosts_integration_check(project_root),
112
+ ]
113
+ return DoctorResult(checks=checks)
114
+
115
+ def _python_version_check(self) -> DoctorCheck:
116
+ major, minor, micro = self.version_info
117
+ detail = f"Python {major}.{minor}.{micro}"
118
+ if (major, minor) >= (3, 12):
119
+ return DoctorCheck(name="python_version", status="success", detail=detail)
120
+ return DoctorCheck(
121
+ name="python_version",
122
+ status="failed",
123
+ detail=detail,
124
+ error="Python 3.12 or higher is required.",
125
+ recovery_hint="Configure a virtual environment with Python 3.12 or higher.",
126
+ )
127
+
128
+ def _path_permissions_check(self, project_root: Path) -> DoctorCheck:
129
+ paths = [
130
+ (self._global_data_root(), "directory"),
131
+ (self._global_config_path(), "file"),
132
+ ]
133
+ if (project_root / ".umem").exists():
134
+ paths.append((project_root / ".umem", "directory"))
135
+ else:
136
+ paths.append((project_root, "directory"))
137
+
138
+ failures: list[str] = []
139
+ for path, expected_kind in paths:
140
+ failure = self._permission_failure(path, expected_kind, project_root)
141
+ if failure is not None:
142
+ failures.append(failure)
143
+
144
+ if not failures:
145
+ return DoctorCheck(
146
+ name="filesystem_permissions",
147
+ status="success",
148
+ detail="Canonical local and global paths are writable.",
149
+ )
150
+ return DoctorCheck(
151
+ name="filesystem_permissions",
152
+ status="failed",
153
+ error="; ".join(failures),
154
+ recovery_hint="Fix filesystem permissions with chmod -R u+rw <path>.",
155
+ )
156
+
157
+ def _project_layout_check(self, project_root: Path) -> DoctorCheck:
158
+ umem_root = project_root / ".umem"
159
+ if not umem_root.exists():
160
+ return DoctorCheck(
161
+ name="project_layout",
162
+ status="failed",
163
+ error="Missing project layout: .umem",
164
+ recovery_hint="Run umem init --yes to create the project layout.",
165
+ )
166
+ if not umem_root.is_dir():
167
+ return DoctorCheck(
168
+ name="project_layout",
169
+ status="failed",
170
+ error="Project layout root .umem is not a directory.",
171
+ recovery_hint="Move the conflicting .umem file and run umem init --yes.",
172
+ )
173
+
174
+ failures: list[str] = []
175
+ for relative_path in PROJECT_LAYOUT_PATHS:
176
+ target = project_root / relative_path
177
+ expected = "directory" if relative_path in DIRECTORY_LAYOUT_PATHS else "file"
178
+ if not target.exists():
179
+ failures.append(f"Missing path: {relative_path}")
180
+ elif expected == "directory" and not target.is_dir():
181
+ failures.append(f"Wrong path kind: {relative_path} must be a directory")
182
+ elif expected == "file" and not target.is_file():
183
+ failures.append(f"Wrong path kind: {relative_path} must be a file")
184
+
185
+ if not failures:
186
+ return DoctorCheck(
187
+ name="project_layout",
188
+ status="success",
189
+ detail="Project layout is complete.",
190
+ )
191
+ return DoctorCheck(
192
+ name="project_layout",
193
+ status="failed",
194
+ error="; ".join(failures),
195
+ recovery_hint="Run umem init --yes to rebuild missing default paths.",
196
+ )
197
+
198
+ def _path_executables_check(self) -> DoctorCheck:
199
+ required = ["umem", "umem-mcp"]
200
+ missing = [name for name in required if self.which(name) is None]
201
+ if not missing:
202
+ return DoctorCheck(
203
+ name="path_executables",
204
+ status="success",
205
+ detail="umem and umem-mcp are available on PATH.",
206
+ )
207
+ return DoctorCheck(
208
+ name="path_executables",
209
+ status="failed",
210
+ error=f"Missing executables on PATH: {', '.join(missing)}",
211
+ recovery_hint="Activate the virtual environment or install with uv sync.",
212
+ )
213
+
214
+ def _hosts_integration_check(self, project_root: Path) -> DoctorCheck:
215
+ host_ids = self._configured_host_ids(project_root)
216
+ config_errors = [
217
+ host_id.removeprefix(CONFIG_LOAD_ERROR_PREFIX)
218
+ for host_id in host_ids
219
+ if host_id.startswith(CONFIG_LOAD_ERROR_PREFIX)
220
+ ]
221
+ if config_errors:
222
+ return DoctorCheck(
223
+ name="hosts_integration",
224
+ status="failed",
225
+ error="; ".join(config_errors),
226
+ recovery_hint="Fix .umem/config.toml and re-run umem doctor.",
227
+ )
228
+ if self.host_check_command is None:
229
+ return DoctorCheck(
230
+ name="hosts_integration",
231
+ status="success",
232
+ detail="Host integration check is not configured.",
233
+ )
234
+ if not host_ids:
235
+ return DoctorCheck(
236
+ name="hosts_integration",
237
+ status="success",
238
+ detail="No hosts configured.",
239
+ )
240
+
241
+ failures: list[str] = []
242
+ for host_id in host_ids:
243
+ try:
244
+ result = self.host_check_command(
245
+ ConfigureHostCommand(
246
+ host_id=host_id,
247
+ apply=False,
248
+ check=True,
249
+ record_audit=False,
250
+ )
251
+ )
252
+ except Exception as error:
253
+ failures.append(f"{host_id}: {error}")
254
+ continue
255
+ if result.validation_status != "success":
256
+ detail = ", ".join(result.warnings or result.manual_steps)
257
+ suffix = f" ({detail})" if detail else ""
258
+ failures.append(f"{host_id}: {result.validation_status}{suffix}")
259
+
260
+ if not failures:
261
+ return DoctorCheck(
262
+ name="hosts_integration",
263
+ status="success",
264
+ detail=f"Validated hosts: {', '.join(host_ids)}.",
265
+ )
266
+ return DoctorCheck(
267
+ name="hosts_integration",
268
+ status="failed",
269
+ error="; ".join(failures),
270
+ recovery_hint="Run umem host setup <host_id> --yes to repair host instructions.",
271
+ )
272
+
273
+ def _configured_host_ids(self, project_root: Path) -> list[str]:
274
+ try:
275
+ loaded = load_config(project_root)
276
+ except Exception as error:
277
+ return [f"{CONFIG_LOAD_ERROR_PREFIX}{error}"]
278
+
279
+ host_ids: list[str] = []
280
+ for section_name in ("runtimes", "hosts"):
281
+ section = loaded.merged.get(section_name)
282
+ if not isinstance(section, dict):
283
+ continue
284
+ enabled = section.get("enabled")
285
+ if isinstance(enabled, list):
286
+ host_ids.extend(str(item) for item in enabled)
287
+ for key, value in section.items():
288
+ if isinstance(value, dict) and value.get("enabled") is True:
289
+ host_ids.append(str(key))
290
+
291
+ supported = {"codex", "claude_code"}
292
+ deduped: list[str] = []
293
+ for host_id in host_ids:
294
+ if host_id in supported and host_id not in deduped:
295
+ deduped.append(host_id)
296
+ return deduped
297
+
298
+ def _global_data_root(self) -> Path:
299
+ configured = self.xdg_data_home or _env_path("XDG_DATA_HOME")
300
+ return (configured if configured is not None else self.home / ".local" / "share") / "umem"
301
+
302
+ def _global_config_path(self) -> Path:
303
+ configured = self.xdg_config_home or _env_path("XDG_CONFIG_HOME")
304
+ root = configured if configured is not None else self.home / ".config"
305
+ return root / "umem" / "config.toml"
306
+
307
+ @staticmethod
308
+ def _relative_or_user_path(path: Path, project_root: Path) -> str:
309
+ try:
310
+ return path.resolve().relative_to(project_root.resolve()).as_posix()
311
+ except ValueError:
312
+ return path.expanduser().as_posix()
313
+
314
+ def _permission_failure(
315
+ self,
316
+ path: Path,
317
+ expected_kind: str,
318
+ project_root: Path,
319
+ ) -> str | None:
320
+ display_path = self._relative_or_user_path(path, project_root)
321
+ if path.exists():
322
+ return self._existing_path_permission_failure(path, expected_kind, display_path)
323
+
324
+ return self._missing_path_permission_failure(path, display_path)
325
+
326
+ @staticmethod
327
+ def _existing_path_permission_failure(
328
+ path: Path,
329
+ expected_kind: str,
330
+ display_path: str,
331
+ ) -> str | None:
332
+ if expected_kind == "directory":
333
+ if not path.is_dir():
334
+ return f"{display_path}: path must be a directory"
335
+ has_permissions = os.access(path, os.R_OK | os.W_OK | os.X_OK)
336
+ return (
337
+ None
338
+ if has_permissions
339
+ else f"{display_path}: directory is not readable, writable, and searchable"
340
+ )
341
+
342
+ if not path.is_file():
343
+ return f"{display_path}: path must be a file"
344
+
345
+ has_permissions = os.access(path, os.R_OK | os.W_OK)
346
+ return None if has_permissions else f"{display_path}: file is not readable and writable"
347
+
348
+ @staticmethod
349
+ def _missing_path_permission_failure(path: Path, display_path: str) -> str | None:
350
+ parent = path.parent
351
+ parent_has_permissions = (
352
+ parent.exists() and parent.is_dir() and os.access(parent, os.R_OK | os.W_OK | os.X_OK)
353
+ )
354
+ if parent_has_permissions:
355
+ return None
356
+ if not parent.exists():
357
+ return f"{display_path}: parent directory is missing"
358
+ return f"{display_path}: parent directory is not readable, writable, and searchable"
359
+
360
+
361
+ def _env_path(name: str) -> Path | None:
362
+ value = os.environ.get(name)
363
+ if not value:
364
+ return None
365
+ return Path(value)
@@ -114,6 +114,7 @@ class ConfigureHostCommand:
114
114
  host_id: str
115
115
  apply: bool = False
116
116
  check: bool = False
117
+ record_audit: bool = True
117
118
  instruction_blocks: list[InstructionBlock] = field(default_factory=list)
118
119
  shared_manifest_available: bool | None = None
119
120
  max_managed_lines: int = DEFAULT_MAX_MANAGED_LINES
@@ -244,7 +245,11 @@ class ConfigureHostUseCase:
244
245
  max_lines=command.max_managed_lines,
245
246
  max_chars=command.max_managed_chars,
246
247
  )
247
- audit_reference = self._record_host_validation(command, host, validation)
248
+ audit_reference = (
249
+ self._record_host_validation(command, host, validation)
250
+ if command.record_audit
251
+ else "not-recorded"
252
+ )
248
253
  return ConfigureHostResult(
249
254
  host_id=host.name.value,
250
255
  instruction_targets=[target.name.value],
@@ -7,6 +7,7 @@ from dataclasses import dataclass
7
7
  from datetime import UTC, datetime
8
8
  from pathlib import Path
9
9
 
10
+ from universal_memory import __version__
10
11
  from universal_memory.domain.entities import (
11
12
  AuditEventScope,
12
13
  FactScope,
@@ -40,6 +41,7 @@ class GetMemoryStatusResult:
40
41
  last_health_check: str | None
41
42
  host_validation: dict[str, dict[str, str | None]]
42
43
  recommended_action: str | None = None
44
+ installed_version: str = __version__
43
45
 
44
46
 
45
47
  class GetMemoryStatusUseCase:
@@ -2,6 +2,7 @@ from collections.abc import Sequence
2
2
  from datetime import UTC, datetime
3
3
  from pathlib import Path
4
4
 
5
+ from universal_memory.application.diagnostics import DoctorUseCase
5
6
  from universal_memory.application.host import (
6
7
  ConfigureHostUseCase,
7
8
  SyncInstructionsUseCase,
@@ -176,6 +177,7 @@ def main(argv: Sequence[str] | None = None) -> int:
176
177
  safe_write_use_case=safe_write_use_case,
177
178
  fact_repository=fact_repository,
178
179
  )
180
+ doctor_use_case = DoctorUseCase(host_check_command=host_use_case.execute)
179
181
  host_sync_use_case = SyncInstructionsUseCase(
180
182
  project_root=project_root,
181
183
  safe_write_use_case=safe_write_use_case,
@@ -262,6 +264,7 @@ def main(argv: Sequence[str] | None = None) -> int:
262
264
  rollback_command=rollback_use_case.execute,
263
265
  rollback_preview_command=rollback_preview,
264
266
  status_command=status_use_case.execute,
267
+ doctor_command=doctor_use_case.execute,
265
268
  context_command=context_use_case.execute,
266
269
  remember_command=remember_use_case.execute,
267
270
  facts_list_command=facts_list_use_case.execute,
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
 
5
5
  from fastmcp import FastMCP
6
6
 
7
+ from universal_memory.application.diagnostics import DoctorUseCase
7
8
  from universal_memory.application.host import (
8
9
  ConfigureHostUseCase,
9
10
  SyncInstructionsUseCase,
@@ -122,6 +123,7 @@ def build_server(project_root: Path | None = None) -> FastMCP:
122
123
  safe_write_use_case=safe_write_use_case,
123
124
  fact_repository=fact_repository,
124
125
  )
126
+ doctor_use_case = DoctorUseCase(host_check_command=host_use_case.execute)
125
127
  host_sync_use_case = SyncInstructionsUseCase(
126
128
  project_root=root,
127
129
  safe_write_use_case=safe_write_use_case,
@@ -182,6 +184,7 @@ def build_server(project_root: Path | None = None) -> FastMCP:
182
184
  MCPUseCases(
183
185
  initialize_project=initialize_project,
184
186
  status=status_use_case.execute,
187
+ doctor=doctor_use_case.execute,
185
188
  context=context_use_case.execute,
186
189
  remember=remember_use_case.execute,
187
190
  list_facts=facts_list_use_case.execute,
@@ -16,6 +16,11 @@ from rich.panel import Panel
16
16
  from rich.table import Table
17
17
  from rich.text import Text
18
18
 
19
+ from universal_memory import __version__
20
+ from universal_memory.application.diagnostics import (
21
+ DoctorCommand,
22
+ DoctorResult,
23
+ )
19
24
  from universal_memory.application.host import (
20
25
  ConfigureHostCommand,
21
26
  ConfigureHostResult,
@@ -146,6 +151,7 @@ ListSnapshotsCommandHandler = Callable[[ListSnapshotsCommand], ListSnapshotsResu
146
151
  RollbackCommandHandler = Callable[[RollbackCommand], RollbackResult]
147
152
  RollbackPreviewHandler = Callable[[SnapshotScope], Snapshot]
148
153
  StatusCommandHandler = Callable[[GetMemoryStatusCommand], GetMemoryStatusResult]
154
+ DoctorCommandHandler = Callable[[DoctorCommand], DoctorResult]
149
155
  ContextCommandHandler = Callable[[AssembleContextSummaryCommand], AssembleContextSummaryResult]
150
156
  RememberFactCommandHandler = Callable[[RememberFactCommand], RememberFactResult]
151
157
  ListFactsCommandHandler = Callable[[ListFactsCommand], ListFactsResult]
@@ -199,6 +205,7 @@ def main( # noqa: PLR0913
199
205
  rollback_command: RollbackCommandHandler | None = None,
200
206
  rollback_preview_command: RollbackPreviewHandler | None = None,
201
207
  status_command: StatusCommandHandler | None = None,
208
+ doctor_command: DoctorCommandHandler | None = None,
202
209
  context_command: ContextCommandHandler | None = None,
203
210
  remember_command: RememberFactCommandHandler | None = None,
204
211
  facts_list_command: ListFactsCommandHandler | None = None,
@@ -227,6 +234,7 @@ def main( # noqa: PLR0913
227
234
  rollback_command=rollback_command,
228
235
  rollback_preview_command=rollback_preview_command,
229
236
  status_command=status_command,
237
+ doctor_command=doctor_command,
230
238
  context_command=context_command,
231
239
  remember_command=remember_command,
232
240
  facts_list_command=facts_list_command,
@@ -287,6 +295,12 @@ def main( # noqa: PLR0913
287
295
  return 0
288
296
 
289
297
 
298
+ def _version_callback(value: bool) -> None:
299
+ if value:
300
+ typer.echo(f"umem {__version__}")
301
+ raise typer.Exit()
302
+
303
+
290
304
  def create_typer_app( # noqa: PLR0913, PLR0915
291
305
  *,
292
306
  setup_project_command: SetupProjectCommand | None = None,
@@ -295,6 +309,7 @@ def create_typer_app( # noqa: PLR0913, PLR0915
295
309
  rollback_command: RollbackCommandHandler | None = None,
296
310
  rollback_preview_command: RollbackPreviewHandler | None = None,
297
311
  status_command: StatusCommandHandler | None = None,
312
+ doctor_command: DoctorCommandHandler | None = None,
298
313
  context_command: ContextCommandHandler | None = None,
299
314
  remember_command: RememberFactCommandHandler | None = None,
300
315
  facts_list_command: ListFactsCommandHandler | None = None,
@@ -332,6 +347,15 @@ def create_typer_app( # noqa: PLR0913, PLR0915
332
347
  @app.callback()
333
348
  def callback(
334
349
  ctx: typer.Context,
350
+ version: Annotated[
351
+ bool,
352
+ typer.Option(
353
+ "--version",
354
+ help="Show installed version and exit.",
355
+ callback=_version_callback,
356
+ is_eager=True,
357
+ ),
358
+ ] = False,
335
359
  output_format: Annotated[
336
360
  str,
337
361
  typer.Option(
@@ -343,6 +367,7 @@ def create_typer_app( # noqa: PLR0913, PLR0915
343
367
  ),
344
368
  ] = "human",
345
369
  ) -> None:
370
+ _ = version
346
371
  ctx.obj = {"output_format": output_format.lower()}
347
372
 
348
373
  @app.command("init")
@@ -384,6 +409,15 @@ def create_typer_app( # noqa: PLR0913, PLR0915
384
409
  code=_run_status(status_command, output_format=_effective_format(ctx, output_format))
385
410
  )
386
411
 
412
+ @app.command("doctor")
413
+ def doctor(ctx: typer.Context, output_format: OutputFormatOption = None) -> None:
414
+ if doctor_command is None:
415
+ msg = "CLI doctor_command dependency was not configured."
416
+ raise RuntimeError(msg)
417
+ raise typer.Exit(
418
+ code=_run_doctor(doctor_command, output_format=_effective_format(ctx, output_format))
419
+ )
420
+
387
421
  @app.command("update")
388
422
  def update( # noqa: PLR0913
389
423
  ctx: typer.Context,
@@ -950,6 +984,7 @@ def build_main( # noqa: PLR0913
950
984
  rollback_command: RollbackCommandHandler,
951
985
  rollback_preview_command: RollbackPreviewHandler,
952
986
  status_command: StatusCommandHandler,
987
+ doctor_command: DoctorCommandHandler,
953
988
  context_command: ContextCommandHandler,
954
989
  remember_command: RememberFactCommandHandler,
955
990
  facts_list_command: ListFactsCommandHandler,
@@ -985,6 +1020,7 @@ def build_main( # noqa: PLR0913
985
1020
  rollback_command=rollback_command,
986
1021
  rollback_preview_command=rollback_preview_command,
987
1022
  status_command=status_command,
1023
+ doctor_command=doctor_command,
988
1024
  context_command=context_command,
989
1025
  remember_command=remember_command,
990
1026
  facts_list_command=facts_list_command,
@@ -1371,6 +1407,30 @@ def _run_status(command: StatusCommandHandler, *, output_format: str) -> int:
1371
1407
  return 0
1372
1408
 
1373
1409
 
1410
+ def _run_doctor(command: DoctorCommandHandler, *, output_format: str) -> int:
1411
+ try:
1412
+ result = command(DoctorCommand(project_root=Path.cwd()))
1413
+ except OSError as error:
1414
+ _print_expected_error(StorageError(str(error)), output_format=output_format)
1415
+ return 1
1416
+ except DOMAIN_ERROR_TYPES as error:
1417
+ _print_expected_error(error, output_format=output_format)
1418
+ return 1
1419
+ except ValidationError as error:
1420
+ _print_expected_error(ValidationFailedError(str(error)), output_format=output_format)
1421
+ return 1
1422
+ except Exception as error:
1423
+ _print_unexpected_error(error, output_format=output_format)
1424
+ return 1
1425
+
1426
+ if output_format == "json":
1427
+ print(json.dumps(_doctor_success_envelope(result), sort_keys=True))
1428
+ else:
1429
+ _stdout_console().print(_format_human_doctor_output(result))
1430
+
1431
+ return 0 if result.ok else 1
1432
+
1433
+
1374
1434
  def _run_context(
1375
1435
  command: ContextCommandHandler,
1376
1436
  *,
@@ -2629,6 +2689,16 @@ def _status_success_envelope(result: GetMemoryStatusResult) -> dict[str, Any]:
2629
2689
  }
2630
2690
 
2631
2691
 
2692
+ def _doctor_success_envelope(result: DoctorResult) -> dict[str, Any]:
2693
+ return {
2694
+ "ok": result.ok,
2695
+ "operation": "doctor",
2696
+ "scope": "environment",
2697
+ "data": result.to_payload(),
2698
+ "warnings": [],
2699
+ }
2700
+
2701
+
2632
2702
  def _skill_proposal_payload(result: ProposeSkillResult) -> dict[str, Any]:
2633
2703
  return {
2634
2704
  "skill_id": result.latent_skill.id,
@@ -2845,12 +2915,14 @@ def _status_payload(result: GetMemoryStatusResult) -> dict[str, Any]:
2845
2915
  return {
2846
2916
  "initialized": False,
2847
2917
  "project_path": result.project_path,
2918
+ "installed_version": result.installed_version,
2848
2919
  "recommended_action": result.recommended_action,
2849
2920
  }
2850
2921
 
2851
2922
  return {
2852
2923
  "initialized": True,
2853
2924
  "project_path": result.project_path,
2925
+ "installed_version": result.installed_version,
2854
2926
  "fact_counts": result.fact_counts,
2855
2927
  "active_rules_count": result.active_rules_count,
2856
2928
  "registered_skills_count": result.registered_skills_count,
@@ -2936,6 +3008,7 @@ def _format_human_status_output(result: GetMemoryStatusResult) -> str:
2936
3008
  [
2937
3009
  "Local memory is not initialized.",
2938
3010
  f"Project: {result.project_path}",
3011
+ f"Installed version: {result.installed_version}",
2939
3012
  f"Next action: {result.recommended_action}",
2940
3013
  ]
2941
3014
  )
@@ -2943,6 +3016,7 @@ def _format_human_status_output(result: GetMemoryStatusResult) -> str:
2943
3016
  lines = [
2944
3017
  "Local memory initialized.",
2945
3018
  f"Project: {result.project_path}",
3019
+ f"Installed version: {result.installed_version}",
2946
3020
  f"Approximate size: {result.approximate_size_bytes} bytes",
2947
3021
  f"Last health check: {result.last_health_check}",
2948
3022
  f"Active rules: {result.active_rules_count}",
@@ -2967,6 +3041,36 @@ def _format_human_status_output(result: GetMemoryStatusResult) -> str:
2967
3041
  return "\n".join(lines)
2968
3042
 
2969
3043
 
3044
+ def _format_human_doctor_output(result: DoctorResult) -> str:
3045
+ lines = [
3046
+ "universal-memory Doctor - Health Report",
3047
+ "========================================",
3048
+ "",
3049
+ ]
3050
+ for check in result.checks:
3051
+ marker = "[OK]" if check.status == "success" else "[FAIL]"
3052
+ label = " ".join(check.name.split("_")).title()
3053
+ detail = f" - {check.detail}" if check.detail else ""
3054
+ lines.append(f"{marker} {label}{detail}")
3055
+ if check.error:
3056
+ lines.append(f" Error: {check.error}")
3057
+ if check.recovery_hint:
3058
+ lines.append(f" Recovery: {check.recovery_hint}")
3059
+
3060
+ summary = result.summary
3061
+ lines.extend(
3062
+ [
3063
+ "",
3064
+ (
3065
+ "Final status: all checks passed."
3066
+ if result.ok
3067
+ else f"Final status: {summary.failed} failure(s) found."
3068
+ ),
3069
+ ]
3070
+ )
3071
+ return "\n".join(lines)
3072
+
3073
+
2970
3074
  def _format_human_context_output(result: AssembleContextSummaryResult) -> str:
2971
3075
  summary = result.context_summary
2972
3076
  lines = [
@@ -9,6 +9,10 @@ from typing import Any, Literal
9
9
 
10
10
  from fastmcp import FastMCP
11
11
 
12
+ from universal_memory.application.diagnostics import (
13
+ DoctorCommand,
14
+ DoctorResult,
15
+ )
12
16
  from universal_memory.application.host import (
13
17
  ConfigureHostCommand,
14
18
  ConfigureHostResult,
@@ -86,6 +90,7 @@ DEFAULT_CONTEXT_MAX_SIZE_CHARS = 4000
86
90
  TOKEN_ESTIMATE_CHARS = 4
87
91
  SetupProjectCommandHandler = Callable[[Path], SetupProjectResult]
88
92
  StatusCommandHandler = Callable[[GetMemoryStatusCommand], GetMemoryStatusResult]
93
+ DoctorCommandHandler = Callable[[DoctorCommand], DoctorResult]
89
94
  ContextCommandHandler = Callable[[AssembleContextSummaryCommand], AssembleContextSummaryResult]
90
95
  RememberCommandHandler = Callable[[RememberFactCommand], RememberFactResult]
91
96
  ListFactsCommandHandler = Callable[[ListFactsCommand], ListFactsResult]
@@ -115,6 +120,7 @@ def _missing_use_case(_command: Any) -> Any:
115
120
  class MCPUseCases:
116
121
  status: StatusCommandHandler
117
122
  context: ContextCommandHandler
123
+ doctor: DoctorCommandHandler = _missing_use_case
118
124
  initialize_project: SetupProjectCommandHandler = _missing_use_case
119
125
  remember: RememberCommandHandler = _missing_use_case
120
126
  list_facts: ListFactsCommandHandler = _missing_use_case
@@ -184,6 +190,15 @@ def configure_server( # noqa: PLR0915
184
190
  except Exception as error:
185
191
  return _mcp_tool_error(error, operation="status", scope="project")
186
192
 
193
+ @server.tool(name="doctor")
194
+ def doctor() -> ToolResponse:
195
+ """Run read-only environment diagnostics for Universal Memory."""
196
+ try:
197
+ result = use_cases.doctor(DoctorCommand(project_root=root))
198
+ return _doctor_success_envelope(result)
199
+ except Exception as error:
200
+ return _mcp_tool_error(error, operation="doctor", scope="environment")
201
+
187
202
  @server.tool(name="context")
188
203
  def context(
189
204
  scope: Literal["project", "global"] = "project",
@@ -669,12 +684,14 @@ def _status_payload(result: GetMemoryStatusResult) -> dict[str, Any]:
669
684
  return {
670
685
  "initialized": False,
671
686
  "project_path": result.project_path,
687
+ "installed_version": result.installed_version,
672
688
  "recommended_action": result.recommended_action,
673
689
  }
674
690
 
675
691
  return {
676
692
  "initialized": True,
677
693
  "project_path": result.project_path,
694
+ "installed_version": result.installed_version,
678
695
  "fact_counts": result.fact_counts,
679
696
  "active_rules_count": result.active_rules_count,
680
697
  "registered_skills_count": result.registered_skills_count,
@@ -684,6 +701,16 @@ def _status_payload(result: GetMemoryStatusResult) -> dict[str, Any]:
684
701
  }
685
702
 
686
703
 
704
+ def _doctor_success_envelope(result: DoctorResult) -> dict[str, Any]:
705
+ return {
706
+ "ok": result.ok,
707
+ "operation": "doctor",
708
+ "scope": "environment",
709
+ "data": result.to_payload(),
710
+ "warnings": [],
711
+ }
712
+
713
+
687
714
  def _context_payload(
688
715
  result: AssembleContextSummaryResult,
689
716
  *,