memleaf 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
memleaf/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Local-first Markdown memory core for AI agents."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .config import DEFAULT_CONFIG, default_config, load_config, save_config
6
+ from .frontmatter import FrontmatterError, dump_frontmatter, dump_yaml, load_yaml, parse_frontmatter
7
+ from .models import CaptureResult, ForgetAboutResult, Memory
8
+ from .redaction import redact_secrets, redact_text
9
+ from .retrieval import RetrievalError
10
+ from .service import Core, Memleaf, MemoryService
11
+ from .vault import Vault, safe_component
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "CaptureResult",
16
+ "Core",
17
+ "DEFAULT_CONFIG",
18
+ "ForgetAboutResult",
19
+ "FrontmatterError",
20
+ "Memleaf",
21
+ "Memory",
22
+ "MemoryService",
23
+ "RetrievalError",
24
+ "Vault",
25
+ "default_config",
26
+ "dump_frontmatter",
27
+ "dump_yaml",
28
+ "load_config",
29
+ "load_yaml",
30
+ "parse_frontmatter",
31
+ "redact_secrets",
32
+ "redact_text",
33
+ "safe_component",
34
+ "save_config",
35
+ ]
@@ -0,0 +1,15 @@
1
+ """Host detection and MCP configuration adapters."""
2
+
3
+ from .antigravity import AntigravityAdapter
4
+ from .base import CommandResult, ConfigureResult, Detection
5
+ from .codex import CodexAdapter
6
+ from .hermes import HermesAdapter
7
+
8
+ __all__ = [
9
+ "AntigravityAdapter",
10
+ "CodexAdapter",
11
+ "CommandResult",
12
+ "ConfigureResult",
13
+ "Detection",
14
+ "HermesAdapter",
15
+ ]
@@ -0,0 +1,352 @@
1
+ """Antigravity central MCP JSON detection and atomic merge adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any, Mapping, Sequence
9
+
10
+ from .base import (
11
+ CommandRunner,
12
+ ConfigureResult,
13
+ Detection,
14
+ HookMergeResult,
15
+ absolute_vault,
16
+ adapter_environment,
17
+ adapter_home,
18
+ atomic_replace_bytes,
19
+ host_event_command,
20
+ hook_activation_status as persisted_hook_activation_status,
21
+ hook_definition_fingerprint,
22
+ make_backup,
23
+ merge_hook_config,
24
+ result_from_detection,
25
+ )
26
+
27
+
28
+ _ANTIGRAVITY_HOOK_USER_ACTION = (
29
+ "Fully quit and reopen Antigravity, then complete one test turn to activate the memleaf hooks."
30
+ )
31
+
32
+
33
+ class AntigravityAdapter:
34
+ """Handle only the existing, valid central Antigravity JSON config."""
35
+
36
+ agent = "antigravity"
37
+
38
+ def __init__(
39
+ self,
40
+ home: Path | str | None = None,
41
+ env: Mapping[str, str] | None = None,
42
+ runner: CommandRunner | None = None,
43
+ *,
44
+ command_runner: CommandRunner | None = None,
45
+ path: str | Sequence[str] | None = None,
46
+ ) -> None:
47
+ if runner is not None and command_runner is not None:
48
+ raise ValueError("provide runner or command_runner, not both")
49
+ self.env = adapter_environment(env)
50
+ effective_home = home if home is not None else self.env.get("HOME")
51
+ self.home = adapter_home(effective_home)
52
+ if path is not None:
53
+ self.env["PATH"] = (
54
+ path if isinstance(path, str) else os.pathsep.join(path)
55
+ )
56
+ # Kept for a uniform injectable adapter surface; Antigravity has no
57
+ # supported CLI in this stage, so it is intentionally unused.
58
+ self.runner = runner or command_runner
59
+
60
+ @property
61
+ def config_path(self) -> Path:
62
+ return self.home / ".gemini" / "config" / "mcp_config.json"
63
+
64
+ @property
65
+ def hooks_path(self) -> Path:
66
+ return self.home / ".gemini" / "config" / "hooks.json"
67
+
68
+ def detect(self) -> Detection:
69
+ config = self.config_path
70
+ config_value = str(config)
71
+ if config.is_symlink():
72
+ return Detection(
73
+ agent=self.agent,
74
+ detected=False,
75
+ confidence="low",
76
+ reason="central configuration is a symlink; refusing access",
77
+ config_path=config_value,
78
+ status="diagnostic",
79
+ )
80
+ if not config.exists():
81
+ return Detection(
82
+ agent=self.agent,
83
+ detected=False,
84
+ confidence="none",
85
+ reason="central configuration was not found; no path guessed",
86
+ config_path=config_value,
87
+ status="not_detected",
88
+ )
89
+ if not config.is_file():
90
+ return Detection(
91
+ agent=self.agent,
92
+ detected=False,
93
+ confidence="low",
94
+ reason="central configuration is not a regular file",
95
+ config_path=config_value,
96
+ status="diagnostic",
97
+ )
98
+ state, _ = _load_config(config)
99
+ if state != "valid":
100
+ return Detection(
101
+ agent=self.agent,
102
+ detected=False,
103
+ confidence="low",
104
+ reason="central configuration is not a valid MCP JSON object",
105
+ config_path=config_value,
106
+ status="diagnostic",
107
+ )
108
+ return Detection(
109
+ agent=self.agent,
110
+ detected=True,
111
+ confidence="high",
112
+ reason="valid central MCP JSON configuration found",
113
+ config_path=config_value,
114
+ status="detected",
115
+ )
116
+
117
+ def configure(
118
+ self,
119
+ detection: Detection | Path | str | None = None,
120
+ vault: Path | str | None = None,
121
+ *,
122
+ dry_run: bool = False,
123
+ attempt: bool = False,
124
+ ) -> ConfigureResult:
125
+ if detection is not None and not isinstance(detection, Detection) and vault is None:
126
+ vault = detection
127
+ detection = None
128
+ detection = self._coerce_detection(detection, vault)
129
+ if vault is None:
130
+ raise ValueError("vault is required")
131
+ if not detection.detected or detection.confidence != "high":
132
+ return result_from_detection(
133
+ detection,
134
+ status="diagnostic" if attempt else "not_detected",
135
+ reason=(
136
+ "host was not reliably detected; no configuration path guessed"
137
+ if attempt
138
+ else detection.reason
139
+ ),
140
+ dry_run=dry_run,
141
+ )
142
+
143
+ config = Path(detection.config_path) if detection.config_path else self.config_path
144
+ if config.is_symlink():
145
+ return result_from_detection(
146
+ detection,
147
+ status="diagnostic",
148
+ reason="central configuration is a symlink; unchanged",
149
+ dry_run=dry_run,
150
+ )
151
+ if not config.is_file():
152
+ return result_from_detection(
153
+ detection,
154
+ status="diagnostic",
155
+ reason="central configuration is not a regular file; unchanged",
156
+ dry_run=dry_run,
157
+ )
158
+ state, document = _load_config(config)
159
+ if state != "valid" or document is None:
160
+ return result_from_detection(
161
+ detection,
162
+ status="diagnostic",
163
+ reason="central configuration is invalid; unchanged",
164
+ dry_run=dry_run,
165
+ )
166
+
167
+ hook_definition = _antigravity_hook_definition(vault)
168
+ hook_hash = hook_definition_fingerprint(hook_definition)
169
+ activation_status = persisted_hook_activation_status(
170
+ vault,
171
+ self.agent,
172
+ hook_hash,
173
+ "pending_restart",
174
+ )
175
+ if not dry_run:
176
+ hook_preflight = _configure_antigravity_hooks(self.hooks_path, vault, dry_run=True)
177
+ if hook_preflight.status == "diagnostic":
178
+ return result_from_detection(
179
+ detection,
180
+ status="diagnostic",
181
+ reason=hook_preflight.reason,
182
+ hook_activation_status="pending_restart",
183
+ hook_definition_hash=hook_hash,
184
+ user_action_required=True,
185
+ user_action=_ANTIGRAVITY_HOOK_USER_ACTION,
186
+ )
187
+
188
+ expected = {
189
+ "command": "memleaf-mcp",
190
+ "args": ["--vault", absolute_vault(vault)],
191
+ }
192
+ servers = document["mcpServers"]
193
+ existing = servers.get("memleaf")
194
+ if isinstance(existing, Mapping):
195
+ if _entry_matches(existing, expected):
196
+ mcp_changed = False
197
+ backup = None
198
+ mcp_reason = "existing memleaf entry is correct"
199
+ else:
200
+ return result_from_detection(
201
+ detection,
202
+ status="diagnostic",
203
+ reason="existing memleaf entry is conflicting; unchanged",
204
+ dry_run=dry_run,
205
+ )
206
+ elif existing is not None:
207
+ return result_from_detection(
208
+ detection,
209
+ status="diagnostic",
210
+ reason="existing memleaf entry is conflicting; unchanged",
211
+ dry_run=dry_run,
212
+ )
213
+ else:
214
+ if dry_run:
215
+ mcp_changed = False
216
+ backup = None
217
+ mcp_reason = "would atomically merge the central MCP JSON configuration"
218
+ else:
219
+ backup = None
220
+ try:
221
+ backup = make_backup(config)
222
+ except Exception:
223
+ return result_from_detection(
224
+ detection,
225
+ status="failure",
226
+ reason="could not create configuration backup; unchanged",
227
+ )
228
+
229
+ merged = dict(document)
230
+ merged_servers = dict(servers)
231
+ merged_servers["memleaf"] = expected
232
+ merged["mcpServers"] = merged_servers
233
+ payload = (
234
+ json.dumps(merged, ensure_ascii=False, indent=2, separators=(",", ": "))
235
+ + "\n"
236
+ ).encode("utf-8")
237
+ try:
238
+ mode = config.stat().st_mode & 0o7777
239
+ atomic_replace_bytes(config, payload, mode=mode or 0o600)
240
+ except Exception:
241
+ return result_from_detection(
242
+ detection,
243
+ status="failure",
244
+ reason="atomic configuration update failed; backup retained",
245
+ backup_path=backup,
246
+ )
247
+ mcp_changed = True
248
+ mcp_reason = "central MCP JSON configuration updated"
249
+
250
+ hook_result = _configure_antigravity_hooks(self.hooks_path, vault, dry_run=dry_run)
251
+ if hook_result.status in ("diagnostic", "failure"):
252
+ return result_from_detection(
253
+ detection,
254
+ status=hook_result.status,
255
+ reason=hook_result.reason,
256
+ changed=mcp_changed,
257
+ backup_path=hook_result.backup_path or backup,
258
+ dry_run=dry_run,
259
+ hook_activation_status="pending_restart",
260
+ hook_definition_hash=hook_hash,
261
+ user_action_required=True,
262
+ user_action=_ANTIGRAVITY_HOOK_USER_ACTION,
263
+ )
264
+ return result_from_detection(
265
+ detection,
266
+ status=(
267
+ "would_configure"
268
+ if dry_run
269
+ else "configured" if mcp_changed or hook_result.changed else "already_configured"
270
+ ),
271
+ reason=(
272
+ "would configure MCP entry and lifecycle hooks"
273
+ if dry_run
274
+ else f"{mcp_reason}; lifecycle hooks configured"
275
+ if mcp_changed and hook_result.changed
276
+ else "lifecycle hooks configured"
277
+ if hook_result.changed
278
+ else mcp_reason
279
+ ),
280
+ changed=(mcp_changed or hook_result.changed) if not dry_run else False,
281
+ backup_path=backup or hook_result.backup_path,
282
+ dry_run=dry_run,
283
+ hook_activation_status=activation_status,
284
+ hook_definition_hash=hook_hash,
285
+ user_action_required=activation_status != "active",
286
+ user_action=(
287
+ _ANTIGRAVITY_HOOK_USER_ACTION
288
+ if activation_status != "active"
289
+ else None
290
+ ),
291
+ )
292
+
293
+ def _coerce_detection(
294
+ self,
295
+ detection: Detection | Path | str | None,
296
+ vault: Path | str | None,
297
+ ) -> Detection:
298
+ if isinstance(detection, Detection) or detection is None:
299
+ return detection or self.detect()
300
+ if vault is None:
301
+ return self.detect()
302
+ return self.detect()
303
+
304
+
305
+ Antigravity = AntigravityAdapter
306
+
307
+
308
+ def _configure_antigravity_hooks(
309
+ path: Path,
310
+ vault: Path | str,
311
+ *,
312
+ dry_run: bool = False,
313
+ interpreter: str | Path | None = None,
314
+ ) -> HookMergeResult:
315
+ definition = _antigravity_hook_definition(vault, interpreter=interpreter)
316
+ return merge_hook_config(
317
+ path,
318
+ definition,
319
+ container_key="memleaf",
320
+ dry_run=dry_run,
321
+ )
322
+
323
+
324
+ def _antigravity_hook_definition(
325
+ vault: Path | str,
326
+ *,
327
+ interpreter: str | Path | None = None,
328
+ ) -> dict[str, list[dict[str, Any]]]:
329
+ command = host_event_command("antigravity", "PreInvocation", vault, interpreter=interpreter)
330
+ stop_command = host_event_command("antigravity", "Stop", vault, interpreter=interpreter)
331
+ return {
332
+ "PreInvocation": [{"type": "command", "command": command, "timeout": 600}],
333
+ "Stop": [{"type": "command", "command": stop_command, "timeout": 600}],
334
+ }
335
+
336
+
337
+ def _load_config(path: Path) -> tuple[str, dict[str, Any] | None]:
338
+ try:
339
+ with path.open("r", encoding="utf-8") as stream:
340
+ value = json.load(stream)
341
+ except (OSError, UnicodeError, ValueError):
342
+ return "invalid", None
343
+ if not isinstance(value, dict) or not isinstance(value.get("mcpServers"), dict):
344
+ return "invalid", None
345
+ return "valid", value
346
+
347
+
348
+ def _entry_matches(entry: Mapping[str, Any], expected: Mapping[str, Any]) -> bool:
349
+ return (
350
+ entry.get("command") == expected.get("command")
351
+ and entry.get("args") == expected.get("args")
352
+ )