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 +35 -0
- memleaf/adapters/__init__.py +15 -0
- memleaf/adapters/antigravity.py +352 -0
- memleaf/adapters/base.py +746 -0
- memleaf/adapters/codex.py +460 -0
- memleaf/adapters/hermes.py +710 -0
- memleaf/budget.py +123 -0
- memleaf/capture.py +311 -0
- memleaf/cli.py +360 -0
- memleaf/compaction.py +878 -0
- memleaf/config.py +164 -0
- memleaf/frontmatter.py +403 -0
- memleaf/host_events.py +1327 -0
- memleaf/inbox.py +292 -0
- memleaf/index.py +275 -0
- memleaf/llm/__init__.py +43 -0
- memleaf/llm/base.py +349 -0
- memleaf/llm/claude_compatible.py +31 -0
- memleaf/llm/gemini.py +35 -0
- memleaf/llm/openai_compatible.py +127 -0
- memleaf/llm/router.py +172 -0
- memleaf/locking.py +134 -0
- memleaf/mcp_server.py +979 -0
- memleaf/memory_writer.py +437 -0
- memleaf/model_discovery.py +746 -0
- memleaf/models.py +285 -0
- memleaf/native_index.py +776 -0
- memleaf/processing.py +2029 -0
- memleaf/prompts.py +306 -0
- memleaf/redaction.py +41 -0
- memleaf/retrieval.py +377 -0
- memleaf/retrieval_gate.py +604 -0
- memleaf/scope_maintenance.py +480 -0
- memleaf/scope_state.py +328 -0
- memleaf/service.py +1420 -0
- memleaf/validation.py +794 -0
- memleaf/vault.py +247 -0
- memleaf-0.1.0.dist-info/METADATA +423 -0
- memleaf-0.1.0.dist-info/RECORD +43 -0
- memleaf-0.1.0.dist-info/WHEEL +5 -0
- memleaf-0.1.0.dist-info/entry_points.txt +3 -0
- memleaf-0.1.0.dist-info/licenses/LICENSE +21 -0
- memleaf-0.1.0.dist-info/top_level.txt +1 -0
memleaf/adapters/base.py
ADDED
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
"""Shared, dependency-free primitives for host adapters.
|
|
2
|
+
|
|
3
|
+
The adapters deliberately expose only paths, statuses, and commands in their
|
|
4
|
+
results. Command output is kept private to the adapter so configuration
|
|
5
|
+
contents and possible secrets cannot accidentally end up in ``agents.json``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as _datetime
|
|
11
|
+
import hashlib
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shlex
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
24
|
+
|
|
25
|
+
from ..locking import VaultLock, atomic_write_json, read_json
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class CommandResult:
|
|
30
|
+
"""Small normalized representation of one argv invocation."""
|
|
31
|
+
|
|
32
|
+
returncode: int
|
|
33
|
+
stdout: str = ""
|
|
34
|
+
stderr: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Detection:
|
|
39
|
+
"""Safe, serializable evidence about one supported host."""
|
|
40
|
+
|
|
41
|
+
agent: str
|
|
42
|
+
detected: bool
|
|
43
|
+
confidence: str = "unknown"
|
|
44
|
+
reason: str = ""
|
|
45
|
+
executable: str | None = None
|
|
46
|
+
config_path: str | None = None
|
|
47
|
+
status: str = "unknown"
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, Any]:
|
|
50
|
+
return {
|
|
51
|
+
"agent": self.agent,
|
|
52
|
+
"detected": bool(self.detected),
|
|
53
|
+
"confidence": self.confidence,
|
|
54
|
+
"reason": self.reason,
|
|
55
|
+
"executable": self.executable,
|
|
56
|
+
"config_path": self.config_path,
|
|
57
|
+
"status": self.status,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
as_dict = to_dict
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class ConfigureResult:
|
|
65
|
+
"""Safe, serializable outcome of one adapter configuration attempt."""
|
|
66
|
+
|
|
67
|
+
agent: str
|
|
68
|
+
detected: bool = False
|
|
69
|
+
confidence: str = "unknown"
|
|
70
|
+
reason: str = ""
|
|
71
|
+
executable: str | None = None
|
|
72
|
+
config_path: str | None = None
|
|
73
|
+
status: str = "skipped"
|
|
74
|
+
changed: bool = False
|
|
75
|
+
backup_path: str | None = None
|
|
76
|
+
command: list[str] | None = None
|
|
77
|
+
dry_run: bool = False
|
|
78
|
+
hook_trust_status: str | None = None
|
|
79
|
+
hook_activation_status: str | None = None
|
|
80
|
+
hook_definition_hash: str | None = None
|
|
81
|
+
user_action_required: bool | None = None
|
|
82
|
+
user_action: str | None = None
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict[str, Any]:
|
|
85
|
+
result = {
|
|
86
|
+
"agent": self.agent,
|
|
87
|
+
"detected": bool(self.detected),
|
|
88
|
+
"confidence": self.confidence,
|
|
89
|
+
"reason": self.reason,
|
|
90
|
+
"executable": self.executable,
|
|
91
|
+
"config_path": self.config_path,
|
|
92
|
+
"status": self.status,
|
|
93
|
+
"changed": bool(self.changed),
|
|
94
|
+
"backup_path": self.backup_path,
|
|
95
|
+
"command": list(self.command) if self.command is not None else None,
|
|
96
|
+
"dry_run": bool(self.dry_run),
|
|
97
|
+
}
|
|
98
|
+
if self.hook_trust_status is not None:
|
|
99
|
+
result["hook_trust_status"] = self.hook_trust_status
|
|
100
|
+
if self.hook_activation_status is not None:
|
|
101
|
+
result["hook_activation_status"] = self.hook_activation_status
|
|
102
|
+
if self.hook_definition_hash is not None:
|
|
103
|
+
result["hook_definition_hash"] = self.hook_definition_hash
|
|
104
|
+
if self.user_action_required is not None:
|
|
105
|
+
result["user_action_required"] = bool(self.user_action_required)
|
|
106
|
+
if self.user_action is not None:
|
|
107
|
+
result["user_action"] = self.user_action
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
as_dict = to_dict
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class HookMergeResult:
|
|
115
|
+
"""Safe result from merging one host hook configuration."""
|
|
116
|
+
|
|
117
|
+
status: str
|
|
118
|
+
reason: str
|
|
119
|
+
changed: bool = False
|
|
120
|
+
backup_path: Path | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
CommandRunner = Callable[..., Any]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def adapter_home(home: Path | str | None = None) -> Path:
|
|
127
|
+
"""Return an absolute home path without changing the process environment."""
|
|
128
|
+
|
|
129
|
+
return (Path(home).expanduser() if home is not None else Path.home()).resolve()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def adapter_environment(env: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
133
|
+
"""Copy an injectable environment for executable discovery and runners."""
|
|
134
|
+
|
|
135
|
+
return dict(os.environ if env is None else env)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def resolve_executable(
|
|
139
|
+
name: str,
|
|
140
|
+
*,
|
|
141
|
+
env: Mapping[str, str],
|
|
142
|
+
known_paths: Sequence[Path | str] = (),
|
|
143
|
+
) -> str | None:
|
|
144
|
+
"""Resolve an executable from the injected PATH, then known locations."""
|
|
145
|
+
|
|
146
|
+
path_value = env.get("PATH")
|
|
147
|
+
from_path = shutil.which(name, path=path_value)
|
|
148
|
+
candidates: list[Path] = []
|
|
149
|
+
if from_path:
|
|
150
|
+
candidates.append(Path(from_path))
|
|
151
|
+
candidates.extend(Path(candidate).expanduser() for candidate in known_paths)
|
|
152
|
+
for candidate in candidates:
|
|
153
|
+
try:
|
|
154
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
155
|
+
return str(candidate.resolve())
|
|
156
|
+
except OSError:
|
|
157
|
+
continue
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def absolute_vault(vault: Path | str) -> str:
|
|
162
|
+
"""Build the one supported MCP command argument without creating a vault."""
|
|
163
|
+
|
|
164
|
+
return str(Path(vault).expanduser().resolve())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def mcp_command(vault: Path | str) -> list[str]:
|
|
168
|
+
return ["memleaf-mcp", "--vault", absolute_vault(vault)]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def hook_definition_fingerprint(definition: Mapping[str, Any]) -> str:
|
|
172
|
+
"""Return a stable, non-reversible identity for one host hook definition."""
|
|
173
|
+
|
|
174
|
+
payload = json.dumps(
|
|
175
|
+
definition,
|
|
176
|
+
ensure_ascii=False,
|
|
177
|
+
sort_keys=True,
|
|
178
|
+
separators=(",", ":"),
|
|
179
|
+
).encode("utf-8")
|
|
180
|
+
return hashlib.sha256(payload).hexdigest()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def agent_index_path(vault: Path | str) -> Path:
|
|
184
|
+
"""Return the agents index path without creating or changing the vault."""
|
|
185
|
+
|
|
186
|
+
root = vault if isinstance(vault, (str, os.PathLike)) else getattr(vault, "root", vault)
|
|
187
|
+
return Path(root).expanduser().resolve() / "_index" / "agents.json"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _read_agents_index(path: Path) -> dict[str, Any] | None:
|
|
191
|
+
if path.is_symlink() or (path.exists() and not path.is_file()):
|
|
192
|
+
return None
|
|
193
|
+
if not path.exists():
|
|
194
|
+
return {"version": 1, "agents": {}}
|
|
195
|
+
try:
|
|
196
|
+
value = read_json(path)
|
|
197
|
+
except (OSError, UnicodeError, TypeError, ValueError):
|
|
198
|
+
return None
|
|
199
|
+
if not isinstance(value, dict):
|
|
200
|
+
return None
|
|
201
|
+
agents = value.get("agents")
|
|
202
|
+
if agents is None:
|
|
203
|
+
agents = {}
|
|
204
|
+
if not isinstance(agents, dict):
|
|
205
|
+
return None
|
|
206
|
+
result = dict(value)
|
|
207
|
+
result["agents"] = dict(agents)
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def hook_activation_status(
|
|
212
|
+
vault: Path | str,
|
|
213
|
+
agent: str,
|
|
214
|
+
definition_hash: str,
|
|
215
|
+
pending_status: str,
|
|
216
|
+
) -> str:
|
|
217
|
+
"""Keep ``active`` only when it belongs to the current hook definition."""
|
|
218
|
+
|
|
219
|
+
index = _read_agents_index(agent_index_path(vault))
|
|
220
|
+
if index is None:
|
|
221
|
+
return pending_status
|
|
222
|
+
entry = index["agents"].get(agent)
|
|
223
|
+
if not isinstance(entry, Mapping):
|
|
224
|
+
return pending_status
|
|
225
|
+
if (
|
|
226
|
+
entry.get("hook_activation_status") == "active"
|
|
227
|
+
and entry.get("hook_definition_hash") == definition_hash
|
|
228
|
+
):
|
|
229
|
+
return "active"
|
|
230
|
+
return pending_status
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def update_agents_index(
|
|
234
|
+
path: Path | str,
|
|
235
|
+
updates: Mapping[str, Mapping[str, Any]],
|
|
236
|
+
) -> bool:
|
|
237
|
+
"""Atomically merge agent entries while retaining other index data."""
|
|
238
|
+
|
|
239
|
+
target = Path(path)
|
|
240
|
+
if target.is_symlink() or target.parent.is_symlink():
|
|
241
|
+
return False
|
|
242
|
+
lock_path = target.parent / "vault.lock"
|
|
243
|
+
try:
|
|
244
|
+
with VaultLock(lock_path):
|
|
245
|
+
current = _read_agents_index(target)
|
|
246
|
+
if current is None:
|
|
247
|
+
return False
|
|
248
|
+
agents = dict(current["agents"])
|
|
249
|
+
changed = False
|
|
250
|
+
for agent, update in updates.items():
|
|
251
|
+
if not isinstance(agent, str) or not agent or not isinstance(update, Mapping):
|
|
252
|
+
return False
|
|
253
|
+
previous = agents.get(agent)
|
|
254
|
+
merged = dict(previous) if isinstance(previous, Mapping) else {}
|
|
255
|
+
merged.update(dict(update))
|
|
256
|
+
if merged.get("hook_activation_status") == "active":
|
|
257
|
+
merged["user_action_required"] = False
|
|
258
|
+
merged.pop("user_action", None)
|
|
259
|
+
if merged != previous:
|
|
260
|
+
changed = True
|
|
261
|
+
agents[agent] = merged
|
|
262
|
+
if not changed:
|
|
263
|
+
return True
|
|
264
|
+
current["agents"] = agents
|
|
265
|
+
mode = target.stat().st_mode & 0o7777 if target.exists() else 0o600
|
|
266
|
+
atomic_write_json(target, current, mode=mode or 0o600)
|
|
267
|
+
return True
|
|
268
|
+
except Exception:
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def mark_hook_active(vault: Path | str, agent: str) -> bool:
|
|
273
|
+
"""Record a successful real hook invocation without touching hook trust."""
|
|
274
|
+
|
|
275
|
+
target = agent_index_path(vault)
|
|
276
|
+
if target.is_symlink() or target.parent.is_symlink():
|
|
277
|
+
return False
|
|
278
|
+
lock_path = target.parent / "vault.lock"
|
|
279
|
+
try:
|
|
280
|
+
with VaultLock(lock_path):
|
|
281
|
+
current = _read_agents_index(target)
|
|
282
|
+
if current is None:
|
|
283
|
+
return False
|
|
284
|
+
agents = dict(current["agents"])
|
|
285
|
+
existing = agents.get(agent)
|
|
286
|
+
if not isinstance(existing, Mapping):
|
|
287
|
+
return False
|
|
288
|
+
updated = dict(existing)
|
|
289
|
+
updated["hook_activation_status"] = "active"
|
|
290
|
+
updated["user_action_required"] = False
|
|
291
|
+
updated.pop("user_action", None)
|
|
292
|
+
if updated == existing:
|
|
293
|
+
return True
|
|
294
|
+
agents[agent] = updated
|
|
295
|
+
current["agents"] = agents
|
|
296
|
+
mode = target.stat().st_mode & 0o7777 if target.exists() else 0o600
|
|
297
|
+
atomic_write_json(target, current, mode=mode or 0o600)
|
|
298
|
+
return True
|
|
299
|
+
except Exception:
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def host_event_command(
|
|
304
|
+
host: str,
|
|
305
|
+
event: str,
|
|
306
|
+
vault: Path | str,
|
|
307
|
+
*,
|
|
308
|
+
interpreter: str | Path | None = None,
|
|
309
|
+
) -> str:
|
|
310
|
+
"""Return a shell-safe hook command using the installed interpreter.
|
|
311
|
+
|
|
312
|
+
GUI-launched hosts do not necessarily inherit the user's interactive PATH,
|
|
313
|
+
so the hook must not depend on the ``memleaf`` console script being found.
|
|
314
|
+
``sys.executable`` is kept as-is (including a venv symlink) and every
|
|
315
|
+
argument is quoted for the host's command runner.
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
value = str(interpreter if interpreter is not None else sys.executable)
|
|
319
|
+
executable = Path(value).expanduser()
|
|
320
|
+
if not executable.is_absolute():
|
|
321
|
+
executable = Path.cwd() / executable
|
|
322
|
+
args = (
|
|
323
|
+
str(executable),
|
|
324
|
+
"-m",
|
|
325
|
+
"memleaf.cli",
|
|
326
|
+
"host-event",
|
|
327
|
+
host,
|
|
328
|
+
event,
|
|
329
|
+
"--vault",
|
|
330
|
+
absolute_vault(vault),
|
|
331
|
+
)
|
|
332
|
+
return " ".join(shlex.quote(argument) for argument in args)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def run_argv(
|
|
336
|
+
runner: CommandRunner | None,
|
|
337
|
+
argv: Sequence[str],
|
|
338
|
+
*,
|
|
339
|
+
env: Mapping[str, str],
|
|
340
|
+
input_text: str | None = None,
|
|
341
|
+
) -> CommandResult:
|
|
342
|
+
"""Call an injected runner or subprocess with an argv list only.
|
|
343
|
+
|
|
344
|
+
A few small runner signatures are accepted to keep adapters convenient to
|
|
345
|
+
test: ``runner(argv, env=env)``, ``runner(argv, env)``, and ``runner(argv)``.
|
|
346
|
+
Runners that explicitly accept ``input_text`` can inspect the optional
|
|
347
|
+
stdin payload as well. No fallback invokes a shell or retries a callable.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
command = list(argv)
|
|
351
|
+
|
|
352
|
+
# Keep the injected-runner contract backwards compatible: only a runner
|
|
353
|
+
# that explicitly exposes input_text receives it. The real subprocess
|
|
354
|
+
# path is the only default that needs to create a pipe for stdin.
|
|
355
|
+
if runner is None:
|
|
356
|
+
return normalize_command_result(
|
|
357
|
+
_subprocess_runner(command, env=env, input_text=input_text)
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
actual_runner = runner
|
|
361
|
+
call_args: tuple[Any, ...] = (command,)
|
|
362
|
+
call_kwargs: dict[str, Any] = {"env": env}
|
|
363
|
+
try:
|
|
364
|
+
signature = inspect.signature(actual_runner)
|
|
365
|
+
except (TypeError, ValueError):
|
|
366
|
+
# If a callable does not expose a signature, use one convention only;
|
|
367
|
+
# a TypeError raised by its body must never cause a second invocation.
|
|
368
|
+
value = actual_runner(command, env=env)
|
|
369
|
+
else:
|
|
370
|
+
candidates = []
|
|
371
|
+
if input_text is not None:
|
|
372
|
+
candidates.append(((command,), {"env": env, "input_text": input_text}))
|
|
373
|
+
candidates.extend(
|
|
374
|
+
(
|
|
375
|
+
((command,), {"env": env}),
|
|
376
|
+
((command, env), {}),
|
|
377
|
+
((command,), {}),
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
for candidate_args, candidate_kwargs in candidates:
|
|
381
|
+
try:
|
|
382
|
+
signature.bind(*candidate_args, **candidate_kwargs)
|
|
383
|
+
except TypeError:
|
|
384
|
+
continue
|
|
385
|
+
call_args = candidate_args
|
|
386
|
+
call_kwargs = candidate_kwargs
|
|
387
|
+
break
|
|
388
|
+
value = actual_runner(*call_args, **call_kwargs)
|
|
389
|
+
return normalize_command_result(value)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _subprocess_runner(
|
|
393
|
+
argv: Sequence[str],
|
|
394
|
+
*,
|
|
395
|
+
env: Mapping[str, str],
|
|
396
|
+
input_text: str | None = None,
|
|
397
|
+
) -> Any:
|
|
398
|
+
# Host commands are always argv lists; no shell is involved.
|
|
399
|
+
options: dict[str, Any] = {
|
|
400
|
+
"check": False,
|
|
401
|
+
"capture_output": True,
|
|
402
|
+
"text": True,
|
|
403
|
+
"env": dict(env),
|
|
404
|
+
}
|
|
405
|
+
if input_text is not None:
|
|
406
|
+
options["input"] = input_text
|
|
407
|
+
return subprocess.run(list(argv), **options)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def normalize_command_result(value: Any) -> CommandResult:
|
|
411
|
+
"""Normalize common fake-runner and ``subprocess`` return shapes."""
|
|
412
|
+
|
|
413
|
+
if isinstance(value, CommandResult):
|
|
414
|
+
return value
|
|
415
|
+
if hasattr(value, "returncode"):
|
|
416
|
+
return CommandResult(
|
|
417
|
+
_as_returncode(getattr(value, "returncode", 1)),
|
|
418
|
+
_as_text(getattr(value, "stdout", "")),
|
|
419
|
+
_as_text(getattr(value, "stderr", "")),
|
|
420
|
+
)
|
|
421
|
+
if isinstance(value, Mapping):
|
|
422
|
+
return CommandResult(
|
|
423
|
+
_as_returncode(value.get("returncode", value.get("code", 1))),
|
|
424
|
+
_as_text(value.get("stdout", "")),
|
|
425
|
+
_as_text(value.get("stderr", "")),
|
|
426
|
+
)
|
|
427
|
+
if isinstance(value, (tuple, list)):
|
|
428
|
+
values = list(value)
|
|
429
|
+
return CommandResult(
|
|
430
|
+
_as_returncode(values[0] if values else 1),
|
|
431
|
+
_as_text(values[1] if len(values) > 1 else ""),
|
|
432
|
+
_as_text(values[2] if len(values) > 2 else ""),
|
|
433
|
+
)
|
|
434
|
+
if isinstance(value, int):
|
|
435
|
+
return CommandResult(value)
|
|
436
|
+
return CommandResult(1)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _as_returncode(value: Any) -> int:
|
|
440
|
+
try:
|
|
441
|
+
return int(value)
|
|
442
|
+
except (TypeError, ValueError):
|
|
443
|
+
return 1
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _as_text(value: Any) -> str:
|
|
447
|
+
if value is None:
|
|
448
|
+
return ""
|
|
449
|
+
if isinstance(value, bytes):
|
|
450
|
+
return value.decode("utf-8", errors="replace")
|
|
451
|
+
return str(value)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def command_is_missing(result: CommandResult) -> bool:
|
|
455
|
+
"""Recognize explicit not-found responses without exposing their text."""
|
|
456
|
+
|
|
457
|
+
if result.returncode == 0:
|
|
458
|
+
return False
|
|
459
|
+
text = f"{result.stdout} {result.stderr}".lower()
|
|
460
|
+
markers = (
|
|
461
|
+
"not found",
|
|
462
|
+
"not configured",
|
|
463
|
+
"does not exist",
|
|
464
|
+
"no such",
|
|
465
|
+
"unknown mcp",
|
|
466
|
+
"unknown server",
|
|
467
|
+
"server not found",
|
|
468
|
+
"no mcp server",
|
|
469
|
+
)
|
|
470
|
+
return any(marker in text for marker in markers)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def make_backup(path: Path | str) -> Path | None:
|
|
474
|
+
"""Create a same-directory timestamped backup, refusing symlinks."""
|
|
475
|
+
|
|
476
|
+
source = Path(path)
|
|
477
|
+
if not source.exists():
|
|
478
|
+
return None
|
|
479
|
+
if source.is_symlink() or not source.is_file():
|
|
480
|
+
raise OSError("unsafe configuration path")
|
|
481
|
+
mode = source.stat().st_mode & 0o7777
|
|
482
|
+
timestamp = _datetime.datetime.now(_datetime.timezone.utc).strftime(
|
|
483
|
+
"%Y%m%dT%H%M%S%fZ"
|
|
484
|
+
)
|
|
485
|
+
for _ in range(8):
|
|
486
|
+
backup = source.with_name(
|
|
487
|
+
f"{source.name}.memleaf.bak.{timestamp}.{uuid.uuid4().hex[:10]}"
|
|
488
|
+
)
|
|
489
|
+
if backup.exists() or backup.is_symlink():
|
|
490
|
+
continue
|
|
491
|
+
shutil.copy2(source, backup)
|
|
492
|
+
try:
|
|
493
|
+
os.chmod(backup, mode)
|
|
494
|
+
except OSError:
|
|
495
|
+
pass
|
|
496
|
+
_fsync_file(backup)
|
|
497
|
+
_fsync_directory(backup.parent)
|
|
498
|
+
return backup
|
|
499
|
+
raise OSError("could not create configuration backup")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def atomic_replace_bytes(path: Path | str, data: bytes, *, mode: int = 0o600) -> None:
|
|
503
|
+
"""Atomically replace one regular file with fsync and a same-dir temp."""
|
|
504
|
+
|
|
505
|
+
target = Path(path)
|
|
506
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
507
|
+
descriptor: int | None = None
|
|
508
|
+
temporary: Path | None = None
|
|
509
|
+
try:
|
|
510
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
511
|
+
prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
|
|
512
|
+
)
|
|
513
|
+
temporary = Path(temporary_name)
|
|
514
|
+
os.fchmod(descriptor, mode)
|
|
515
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
516
|
+
descriptor = None
|
|
517
|
+
stream.write(data)
|
|
518
|
+
stream.flush()
|
|
519
|
+
os.fsync(stream.fileno())
|
|
520
|
+
os.replace(temporary, target)
|
|
521
|
+
temporary = None
|
|
522
|
+
_fsync_directory(target.parent)
|
|
523
|
+
finally:
|
|
524
|
+
if descriptor is not None:
|
|
525
|
+
os.close(descriptor)
|
|
526
|
+
if temporary is not None:
|
|
527
|
+
try:
|
|
528
|
+
temporary.unlink()
|
|
529
|
+
except FileNotFoundError:
|
|
530
|
+
pass
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def merge_hook_config(
|
|
534
|
+
path: Path | str,
|
|
535
|
+
additions: Mapping[str, Sequence[Mapping[str, Any]]],
|
|
536
|
+
*,
|
|
537
|
+
container_key: str | None = None,
|
|
538
|
+
dry_run: bool = False,
|
|
539
|
+
) -> HookMergeResult:
|
|
540
|
+
"""Merge command handlers into a host hook JSON file.
|
|
541
|
+
|
|
542
|
+
``container_key`` is used by Antigravity's named-hook format. Codex uses
|
|
543
|
+
a top-level ``hooks`` object and passes ``container_key="hooks"``.
|
|
544
|
+
Existing handlers are left untouched; a matching memleaf command is
|
|
545
|
+
idempotent and a different memleaf command is treated as a conflict.
|
|
546
|
+
"""
|
|
547
|
+
|
|
548
|
+
target = Path(path)
|
|
549
|
+
if target.is_symlink():
|
|
550
|
+
return HookMergeResult("diagnostic", "hook configuration is a symlink; unchanged")
|
|
551
|
+
# Check the host-specific directory levels without rejecting normal
|
|
552
|
+
# system path aliases such as macOS's /var -> /private/var.
|
|
553
|
+
if any(parent.is_symlink() for parent in (target.parent, target.parent.parent)):
|
|
554
|
+
return HookMergeResult("diagnostic", "hook configuration parent is a symlink; unchanged")
|
|
555
|
+
if target.exists() and not target.is_file():
|
|
556
|
+
return HookMergeResult("diagnostic", "hook configuration is not a regular file; unchanged")
|
|
557
|
+
|
|
558
|
+
if target.exists():
|
|
559
|
+
try:
|
|
560
|
+
value = json.loads(target.read_text(encoding="utf-8"))
|
|
561
|
+
except (OSError, UnicodeError, ValueError):
|
|
562
|
+
return HookMergeResult("diagnostic", "hook configuration is invalid JSON; unchanged")
|
|
563
|
+
if not isinstance(value, dict):
|
|
564
|
+
return HookMergeResult("diagnostic", "hook configuration is not a JSON object; unchanged")
|
|
565
|
+
document: dict[str, Any] = dict(value)
|
|
566
|
+
else:
|
|
567
|
+
document = {}
|
|
568
|
+
|
|
569
|
+
if container_key is None:
|
|
570
|
+
container: dict[str, Any] = document
|
|
571
|
+
else:
|
|
572
|
+
current = document.get(container_key)
|
|
573
|
+
if current is None:
|
|
574
|
+
current = {}
|
|
575
|
+
if not isinstance(current, Mapping):
|
|
576
|
+
return HookMergeResult("diagnostic", "hook event container is not a JSON object; unchanged")
|
|
577
|
+
container = dict(current)
|
|
578
|
+
if container_key != "hooks" and container.get("enabled") is False:
|
|
579
|
+
return HookMergeResult("diagnostic", "existing memleaf hook is disabled; unchanged")
|
|
580
|
+
|
|
581
|
+
updated = False
|
|
582
|
+
for event, handlers in additions.items():
|
|
583
|
+
if not isinstance(event, str) or not event or not isinstance(handlers, Sequence):
|
|
584
|
+
return HookMergeResult("diagnostic", "invalid hook definition; unchanged")
|
|
585
|
+
existing = container.get(event)
|
|
586
|
+
if existing is None:
|
|
587
|
+
existing_items: list[Any] = []
|
|
588
|
+
elif isinstance(existing, list):
|
|
589
|
+
existing_items = list(existing)
|
|
590
|
+
else:
|
|
591
|
+
return HookMergeResult("diagnostic", "hook event is not an array; unchanged")
|
|
592
|
+
|
|
593
|
+
for handler in handlers:
|
|
594
|
+
if not isinstance(handler, Mapping):
|
|
595
|
+
return HookMergeResult("diagnostic", "invalid hook handler; unchanged")
|
|
596
|
+
requested = handler.get("hooks") if container_key == "hooks" else [handler]
|
|
597
|
+
if not isinstance(requested, list) or not requested:
|
|
598
|
+
return HookMergeResult("diagnostic", "invalid hook handler; unchanged")
|
|
599
|
+
group_has_new = False
|
|
600
|
+
for requested_handler in requested:
|
|
601
|
+
if not isinstance(requested_handler, Mapping):
|
|
602
|
+
return HookMergeResult("diagnostic", "invalid hook handler; unchanged")
|
|
603
|
+
command = requested_handler.get("command")
|
|
604
|
+
if not isinstance(command, str) or not command:
|
|
605
|
+
return HookMergeResult("diagnostic", "hook command is invalid; unchanged")
|
|
606
|
+
matching, conflict = _hook_command_state(existing_items, command, event, container_key)
|
|
607
|
+
if conflict:
|
|
608
|
+
return HookMergeResult("diagnostic", "existing memleaf hook conflicts; unchanged")
|
|
609
|
+
if not matching:
|
|
610
|
+
group_has_new = True
|
|
611
|
+
if group_has_new:
|
|
612
|
+
existing_items.append(dict(handler))
|
|
613
|
+
updated = True
|
|
614
|
+
container[event] = existing_items
|
|
615
|
+
|
|
616
|
+
if not updated:
|
|
617
|
+
return HookMergeResult("already_configured", "memleaf hooks are already configured")
|
|
618
|
+
if container_key is not None:
|
|
619
|
+
document[container_key] = container
|
|
620
|
+
if dry_run:
|
|
621
|
+
return HookMergeResult("would_configure", "would merge memleaf lifecycle hooks")
|
|
622
|
+
|
|
623
|
+
backup: Path | None = None
|
|
624
|
+
try:
|
|
625
|
+
backup = make_backup(target)
|
|
626
|
+
payload = (
|
|
627
|
+
json.dumps(document, ensure_ascii=False, indent=2, separators=(",", ": ")) + "\n"
|
|
628
|
+
).encode("utf-8")
|
|
629
|
+
mode = target.stat().st_mode & 0o7777 if target.exists() else 0o600
|
|
630
|
+
atomic_replace_bytes(target, payload, mode=mode or 0o600)
|
|
631
|
+
except Exception:
|
|
632
|
+
return HookMergeResult("failure", "hook configuration update failed; backup retained", backup_path=backup)
|
|
633
|
+
return HookMergeResult(
|
|
634
|
+
"configured",
|
|
635
|
+
"memleaf lifecycle hooks configured",
|
|
636
|
+
changed=True,
|
|
637
|
+
backup_path=backup,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _hook_command_state(
|
|
642
|
+
existing_items: Sequence[Any],
|
|
643
|
+
command: str,
|
|
644
|
+
event: str,
|
|
645
|
+
container_key: str | None,
|
|
646
|
+
) -> tuple[bool, bool]:
|
|
647
|
+
"""Return ``(matching, conflict)`` for one requested command."""
|
|
648
|
+
|
|
649
|
+
requested_identity = _host_event_identity(command)
|
|
650
|
+
for item in existing_items:
|
|
651
|
+
handlers: Any = item.get("hooks") if isinstance(item, Mapping) and container_key == "hooks" else [item]
|
|
652
|
+
if not isinstance(handlers, list):
|
|
653
|
+
continue
|
|
654
|
+
for handler in handlers:
|
|
655
|
+
if not isinstance(handler, Mapping):
|
|
656
|
+
continue
|
|
657
|
+
current = handler.get("command")
|
|
658
|
+
if not isinstance(current, str):
|
|
659
|
+
continue
|
|
660
|
+
if current == command:
|
|
661
|
+
return True, False
|
|
662
|
+
if requested_identity is not None and _host_event_identity(current) == requested_identity:
|
|
663
|
+
return False, True
|
|
664
|
+
return False, False
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _host_event_identity(command: str) -> tuple[str, str] | None:
|
|
668
|
+
"""Recognize old and current memleaf host-event command forms."""
|
|
669
|
+
|
|
670
|
+
try:
|
|
671
|
+
tokens = shlex.split(command)
|
|
672
|
+
except ValueError:
|
|
673
|
+
return None
|
|
674
|
+
for index, token in enumerate(tokens):
|
|
675
|
+
if token != "host-event" or index < 1 or index + 2 >= len(tokens):
|
|
676
|
+
continue
|
|
677
|
+
old_form = tokens[index - 1] == "memleaf"
|
|
678
|
+
module_form = index >= 2 and tokens[index - 1] == "memleaf.cli" and tokens[index - 2] == "-m"
|
|
679
|
+
if old_form or module_form:
|
|
680
|
+
host, hook_event = tokens[index + 1], tokens[index + 2]
|
|
681
|
+
if host in ("codex", "antigravity") and hook_event:
|
|
682
|
+
return host, hook_event
|
|
683
|
+
return None
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _fsync_file(path: Path) -> None:
|
|
687
|
+
try:
|
|
688
|
+
descriptor = os.open(str(path), os.O_RDONLY)
|
|
689
|
+
except OSError:
|
|
690
|
+
return
|
|
691
|
+
try:
|
|
692
|
+
try:
|
|
693
|
+
os.fsync(descriptor)
|
|
694
|
+
except OSError:
|
|
695
|
+
pass
|
|
696
|
+
finally:
|
|
697
|
+
os.close(descriptor)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _fsync_directory(directory: Path) -> None:
|
|
701
|
+
try:
|
|
702
|
+
descriptor = os.open(str(directory), os.O_RDONLY)
|
|
703
|
+
except OSError:
|
|
704
|
+
return
|
|
705
|
+
try:
|
|
706
|
+
try:
|
|
707
|
+
os.fsync(descriptor)
|
|
708
|
+
except OSError:
|
|
709
|
+
pass
|
|
710
|
+
finally:
|
|
711
|
+
os.close(descriptor)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def result_from_detection(
|
|
715
|
+
detection: Detection,
|
|
716
|
+
*,
|
|
717
|
+
status: str,
|
|
718
|
+
reason: str,
|
|
719
|
+
changed: bool = False,
|
|
720
|
+
backup_path: Path | str | None = None,
|
|
721
|
+
command: Sequence[str] | None = None,
|
|
722
|
+
dry_run: bool = False,
|
|
723
|
+
hook_trust_status: str | None = None,
|
|
724
|
+
hook_activation_status: str | None = None,
|
|
725
|
+
hook_definition_hash: str | None = None,
|
|
726
|
+
user_action_required: bool | None = None,
|
|
727
|
+
user_action: str | None = None,
|
|
728
|
+
) -> ConfigureResult:
|
|
729
|
+
return ConfigureResult(
|
|
730
|
+
agent=detection.agent,
|
|
731
|
+
detected=detection.detected,
|
|
732
|
+
confidence=detection.confidence,
|
|
733
|
+
reason=reason,
|
|
734
|
+
executable=detection.executable,
|
|
735
|
+
config_path=detection.config_path,
|
|
736
|
+
status=status,
|
|
737
|
+
changed=changed,
|
|
738
|
+
backup_path=str(backup_path) if backup_path is not None else None,
|
|
739
|
+
command=list(command) if command is not None else None,
|
|
740
|
+
dry_run=dry_run,
|
|
741
|
+
hook_trust_status=hook_trust_status,
|
|
742
|
+
hook_activation_status=hook_activation_status,
|
|
743
|
+
hook_definition_hash=hook_definition_hash,
|
|
744
|
+
user_action_required=user_action_required,
|
|
745
|
+
user_action=user_action,
|
|
746
|
+
)
|