agentdir-cli 0.7.5__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.
agentdir/daemon.py ADDED
@@ -0,0 +1,253 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from threading import Event
12
+ from typing import Any
13
+
14
+ from .federation import rebuild_registered_roots
15
+ from .index import rebuild_index
16
+ from .store import AgentDirError, init_root, paths_for, require_root
17
+
18
+ DAEMON_STATE_FILE = "memory-daemon.json"
19
+ DAEMON_STOP_FILE = "memory-daemon.stop"
20
+ DAEMON_LOG_FILE = "memory-daemon.log"
21
+
22
+
23
+ def start_memory_daemon(
24
+ root: str | Path,
25
+ *,
26
+ interval: float = 2.0,
27
+ group: str | None = None,
28
+ force: bool = False,
29
+ ) -> dict[str, Any]:
30
+ paths = init_root(root)
31
+ current = memory_daemon_status(paths.root)
32
+ if current["running"] and not force:
33
+ return {**current, "started": False}
34
+ _stop_path(paths.root).unlink(missing_ok=True)
35
+ log_path = paths.state / DAEMON_LOG_FILE
36
+ command = [
37
+ sys.executable,
38
+ "-m",
39
+ "agentdir",
40
+ "memory",
41
+ "daemon",
42
+ "run",
43
+ "--root",
44
+ str(paths.root),
45
+ "--interval",
46
+ str(interval),
47
+ ]
48
+ if group:
49
+ command.extend(["--group", group])
50
+ log = log_path.open("ab")
51
+ process = subprocess.Popen(
52
+ command,
53
+ stdout=log,
54
+ stderr=subprocess.STDOUT,
55
+ cwd=Path.cwd(),
56
+ env=os.environ.copy(),
57
+ start_new_session=True,
58
+ )
59
+ state = _daemon_state(paths.root, pid=process.pid, interval=interval, group=group)
60
+ state.update({"started": True, "log_path": str(log_path)})
61
+ _write_state(paths.root, state)
62
+ return memory_daemon_status(paths.root) | {"started": True}
63
+
64
+
65
+ def run_memory_daemon(
66
+ root: str | Path,
67
+ *,
68
+ interval: float = 2.0,
69
+ group: str | None = None,
70
+ once: bool = False,
71
+ ) -> dict[str, Any]:
72
+ paths = init_root(root)
73
+ state = _daemon_state(paths.root, pid=os.getpid(), interval=interval, group=group)
74
+ _write_state(paths.root, state)
75
+ _refresh(paths.root, group=group)
76
+ if once:
77
+ return memory_daemon_status(paths.root)
78
+ if _watchfiles_available():
79
+ _watch_loop(paths.root, group=group, interval=interval)
80
+ else:
81
+ _poll_loop(paths.root, group=group, interval=interval)
82
+ return memory_daemon_status(paths.root)
83
+
84
+
85
+ def stop_memory_daemon(root: str | Path, *, timeout: float = 5.0) -> dict[str, Any]:
86
+ paths = require_root(root)
87
+ _stop_path(paths.root).write_text(now_iso() + "\n", encoding="utf-8")
88
+ status = memory_daemon_status(paths.root)
89
+ pid = status.get("pid")
90
+ if pid and status["running"]:
91
+ try:
92
+ os.kill(int(pid), signal.SIGTERM)
93
+ except OSError:
94
+ pass
95
+ deadline = time.monotonic() + timeout
96
+ while time.monotonic() < deadline:
97
+ if not _pid_running(int(pid)):
98
+ break
99
+ time.sleep(0.05)
100
+ return memory_daemon_status(paths.root)
101
+
102
+
103
+ def memory_daemon_status(root: str | Path) -> dict[str, Any]:
104
+ paths = require_root(root)
105
+ state = _read_state(paths.root)
106
+ pid = state.get("pid")
107
+ running = _pid_running(int(pid)) if pid else False
108
+ return {
109
+ **state,
110
+ "running": running,
111
+ "stop_requested": _stop_path(paths.root).is_file(),
112
+ "watch_backend": "watchfiles" if _watchfiles_available() else "poll",
113
+ "state_path": str(_state_path(paths.root)),
114
+ "log_path": str(paths.state / DAEMON_LOG_FILE),
115
+ }
116
+
117
+
118
+ def format_memory_daemon_status(status: dict[str, Any]) -> str:
119
+ lines = [
120
+ f"running={str(status['running']).lower()}",
121
+ f"pid={status.get('pid') or ''}",
122
+ f"backend={status['watch_backend']}",
123
+ f"interval={status.get('interval') or ''}",
124
+ f"group={status.get('group') or ''}",
125
+ f"last_refresh_at={status.get('last_refresh_at') or ''}",
126
+ f"last_refresh_ok={str(status.get('last_refresh_ok', False)).lower()}",
127
+ ]
128
+ if status.get("last_error"):
129
+ lines.append(f"last_error={status['last_error']}")
130
+ return "\n".join(lines) + "\n"
131
+
132
+
133
+ def now_iso() -> str:
134
+ return datetime.now(UTC).isoformat()
135
+
136
+
137
+ def _poll_loop(root: Path, *, group: str | None, interval: float) -> None:
138
+ while not _stop_path(root).is_file():
139
+ time.sleep(max(interval, 0.1))
140
+ if _stop_path(root).is_file():
141
+ break
142
+ _refresh(root, group=group)
143
+
144
+
145
+ def _watch_loop(root: Path, *, group: str | None, interval: float) -> None:
146
+ try:
147
+ from watchfiles import watch
148
+ except ImportError:
149
+ _poll_loop(root, group=group, interval=interval)
150
+ return
151
+ stop = Event()
152
+ watch_paths = _watch_paths(root)
153
+ timeout_ms = max(int(interval * 1000), 100)
154
+ for changes in watch(
155
+ *watch_paths,
156
+ stop_event=stop,
157
+ yield_on_timeout=True,
158
+ rust_timeout=timeout_ms,
159
+ recursive=True,
160
+ ):
161
+ if _stop_path(root).is_file():
162
+ stop.set()
163
+ break
164
+ if changes:
165
+ _refresh(root, group=group)
166
+
167
+
168
+ def _refresh(root: Path, *, group: str | None) -> None:
169
+ state = _read_state(root)
170
+ try:
171
+ result = rebuild_index(root)
172
+ roots = rebuild_registered_roots(root, group=group)
173
+ except Exception as exc:
174
+ state.update(
175
+ {
176
+ "last_refresh_at": now_iso(),
177
+ "last_refresh_ok": False,
178
+ "last_error": str(exc),
179
+ }
180
+ )
181
+ else:
182
+ state.update(
183
+ {
184
+ "last_refresh_at": now_iso(),
185
+ "last_refresh_ok": True,
186
+ "last_error": None,
187
+ "indexed": result.indexed,
188
+ "malformed": result.malformed,
189
+ "registered_roots": len(roots),
190
+ }
191
+ )
192
+ _write_state(root, state)
193
+
194
+
195
+ def _watch_paths(root: Path) -> list[str]:
196
+ paths = paths_for(root)
197
+ candidates = [paths.sessions, paths.actors, paths.queues, paths.state]
198
+ return [str(path) for path in candidates if path.exists()]
199
+
200
+
201
+ def _daemon_state(root: Path, *, pid: int, interval: float, group: str | None) -> dict[str, Any]:
202
+ return {
203
+ "pid": pid,
204
+ "root": str(root),
205
+ "interval": interval,
206
+ "group": group,
207
+ "started_at": now_iso(),
208
+ "last_refresh_at": None,
209
+ "last_refresh_ok": False,
210
+ "last_error": None,
211
+ }
212
+
213
+
214
+ def _read_state(root: Path) -> dict[str, Any]:
215
+ path = _state_path(root)
216
+ if not path.is_file():
217
+ return {}
218
+ try:
219
+ return json.loads(path.read_text(encoding="utf-8"))
220
+ except json.JSONDecodeError as exc:
221
+ raise AgentDirError(f"Invalid daemon state file: {exc}") from exc
222
+
223
+
224
+ def _write_state(root: Path, state: dict[str, Any]) -> None:
225
+ path = _state_path(root)
226
+ path.parent.mkdir(parents=True, exist_ok=True)
227
+ path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
228
+
229
+
230
+ def _state_path(root: Path) -> Path:
231
+ return paths_for(root).state / DAEMON_STATE_FILE
232
+
233
+
234
+ def _stop_path(root: Path) -> Path:
235
+ return paths_for(root).state / DAEMON_STOP_FILE
236
+
237
+
238
+ def _pid_running(pid: int) -> bool:
239
+ if pid <= 0:
240
+ return False
241
+ try:
242
+ os.kill(pid, 0)
243
+ except OSError:
244
+ return False
245
+ return True
246
+
247
+
248
+ def _watchfiles_available() -> bool:
249
+ try:
250
+ import watchfiles # noqa: F401
251
+ except ImportError:
252
+ return False
253
+ return True
agentdir/doctor.py ADDED
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from .artifacts import artifact_path
10
+ from .envelope import parse_envelope, validate_required
11
+ from .mailbox import iter_records
12
+ from .redaction import looks_secret_bearing
13
+ from .secrets import scan_secret_records
14
+ from .store import discover_mailboxes, require_root
15
+
16
+
17
+ @dataclass
18
+ class DoctorReport:
19
+ ok: bool = True
20
+ errors: list[str] = field(default_factory=list)
21
+ warnings: list[str] = field(default_factory=list)
22
+
23
+ def add_error(self, message: str) -> None:
24
+ self.ok = False
25
+ self.errors.append(message)
26
+
27
+ def add_warning(self, message: str) -> None:
28
+ self.warnings.append(message)
29
+
30
+ def as_dict(self) -> dict[str, object]:
31
+ return {"ok": self.ok, "errors": self.errors, "warnings": self.warnings}
32
+
33
+
34
+ def run_doctor(root: str | Path) -> DoctorReport:
35
+ report = DoctorReport()
36
+ try:
37
+ paths = require_root(root)
38
+ except Exception as exc:
39
+ report.add_error(str(exc))
40
+ return report
41
+
42
+ for required in (paths.meta, paths.sessions, paths.actors, paths.artifacts, paths.indexes):
43
+ if not required.exists():
44
+ report.add_error(f"missing required path: {required}")
45
+
46
+ seen: dict[str, list[tuple[str, str]]] = defaultdict(list)
47
+ for _kind, mailbox in discover_mailboxes(root):
48
+ for record in iter_records(mailbox):
49
+ try:
50
+ parsed = parse_envelope(record.path)
51
+ missing = validate_required(parsed)
52
+ if missing:
53
+ report.add_error(f"{record.path}: missing headers {', '.join(missing)}")
54
+ relative_path = str(record.path.relative_to(paths.root))
55
+ if parsed.message_id:
56
+ seen[parsed.message_id].append((relative_path, parsed.body_sha256))
57
+ for sha in parsed.message.get_all("X-AgentDir-Blob-SHA256", []):
58
+ if not artifact_path(root, sha).exists():
59
+ report.add_error(f"{record.path}: missing artifact blob {sha}")
60
+ except Exception as exc:
61
+ report.add_error(f"{record.path}: parse error: {exc}")
62
+
63
+ for message_id, file_hashes in seen.items():
64
+ if len(file_hashes) > 1:
65
+ files = [path for path, _hash in file_hashes]
66
+ body_hashes = {_hash for _path, _hash in file_hashes}
67
+ if len(body_hashes) == 1:
68
+ report.add_warning(f"replicated Message-ID {message_id}: {json.dumps(files)}")
69
+ else:
70
+ report.add_error(f"conflicting duplicate Message-ID {message_id}: {json.dumps(files)}")
71
+
72
+ for tmp in paths.root.glob("**/Maildir/tmp/*"):
73
+ if tmp.is_file():
74
+ report.add_warning(f"incomplete tmp record ignored: {tmp.relative_to(paths.root)}")
75
+ secret_findings = scan_secret_records(paths.root)
76
+ for finding in secret_findings:
77
+ labels = ",".join(finding.labels)
78
+ report.add_error(
79
+ f"{paths.root / finding.path}: body contains secret-like text "
80
+ f"labels={labels}; run 'agentdir secrets redact --apply'"
81
+ )
82
+ if not secret_findings:
83
+ for finding in _index_secret_findings(paths.index_path):
84
+ report.add_error(
85
+ f"{paths.index_path}: {finding} contains secret-like indexed text; "
86
+ "run 'agentdir index rebuild'"
87
+ )
88
+ return report
89
+
90
+
91
+ def _index_secret_findings(index_path: Path) -> list[str]:
92
+ if not index_path.is_file():
93
+ return []
94
+ findings: list[str] = []
95
+ tables = (
96
+ ("messages", "id", "body_text"),
97
+ ("memory_documents", "id", "body_text"),
98
+ )
99
+ try:
100
+ with sqlite3.connect(index_path) as conn:
101
+ for table, id_column, body_column in tables:
102
+ if not _table_exists(conn, table):
103
+ continue
104
+ for row_id, body_text in conn.execute(f"select {id_column}, {body_column} from {table}"):
105
+ if looks_secret_bearing(body_text or ""):
106
+ findings.append(f"{table}.{id_column}={row_id}")
107
+ break
108
+ except sqlite3.DatabaseError as exc:
109
+ findings.append(f"index scan failed: {exc}")
110
+ return findings
111
+
112
+
113
+ def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
114
+ row = conn.execute("select 1 from sqlite_master where type = 'table' and name = ?", (table,)).fetchone()
115
+ return row is not None
agentdir/envelope.py ADDED
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import time
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from email import policy
8
+ from email.message import EmailMessage, Message
9
+ from email.parser import BytesParser
10
+ from email.utils import format_datetime, make_msgid
11
+ from pathlib import Path
12
+ from typing import Iterable
13
+
14
+ from .store import AgentDirError
15
+
16
+ AGENTDIR_VERSION = "0.1"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ParsedEnvelope:
21
+ message: Message
22
+ raw: bytes
23
+ path: Path
24
+
25
+ @property
26
+ def message_id(self) -> str | None:
27
+ return self.message.get("Message-ID")
28
+
29
+ @property
30
+ def body_text(self) -> str:
31
+ if self.message.is_multipart():
32
+ parts: list[str] = []
33
+ for part in self.message.walk():
34
+ if part.get_content_maintype() == "text":
35
+ try:
36
+ parts.append(part.get_content())
37
+ except Exception:
38
+ continue
39
+ return "\n".join(parts)
40
+ try:
41
+ content = self.message.get_content()
42
+ except Exception:
43
+ payload = self.message.get_payload(decode=True)
44
+ if payload is None:
45
+ return ""
46
+ return payload.decode("utf-8", errors="replace")
47
+ return content if isinstance(content, str) else str(content)
48
+
49
+ @property
50
+ def body_sha256(self) -> str:
51
+ return hashlib.sha256(self.body_text.encode("utf-8", errors="replace")).hexdigest()
52
+
53
+
54
+ def actor_address(actor: str | None) -> str:
55
+ local = (actor or "agentdir").strip() or "agentdir"
56
+ if "@" in local:
57
+ return local
58
+ safe = "".join(ch if ch.isalnum() or ch in "._+-" else "-" for ch in local)
59
+ return f"{safe}@agentdir.local"
60
+
61
+
62
+ def build_envelope(
63
+ *,
64
+ event_type: str,
65
+ body: str,
66
+ subject: str | None = None,
67
+ from_actor: str | None = None,
68
+ to_actor: str | None = None,
69
+ session_id: str | None = None,
70
+ task_id: str | None = None,
71
+ workspace: str | None = None,
72
+ git_head: str | None = None,
73
+ tool: str | None = None,
74
+ tool_exit_code: int | None = None,
75
+ parent_message_id: str | None = None,
76
+ references: Iterable[str] | None = None,
77
+ artifact_headers: dict[str, str | Iterable[str]] | None = None,
78
+ extra_headers: dict[str, str | Iterable[str]] | None = None,
79
+ message_id: str | None = None,
80
+ ) -> EmailMessage:
81
+ if not event_type:
82
+ raise AgentDirError("event_type is required")
83
+
84
+ msg = EmailMessage(policy=policy.default)
85
+ msg["Message-ID"] = message_id or make_msgid(domain="agentdir.local")
86
+ msg["Date"] = format_datetime(datetime.now(UTC))
87
+ msg["From"] = actor_address(from_actor)
88
+ msg["To"] = actor_address(to_actor or session_id or "agentdir")
89
+ msg["Subject"] = subject or event_type
90
+ msg["X-AgentDir-Version"] = AGENTDIR_VERSION
91
+ msg["X-AgentDir-Event-Type"] = event_type
92
+ msg["X-AgentDir-Created-Ns"] = str(time.time_ns())
93
+
94
+ optional = {
95
+ "X-AgentDir-Session": session_id,
96
+ "X-AgentDir-Task": task_id,
97
+ "X-AgentDir-Workspace": workspace,
98
+ "X-AgentDir-Git-Head": git_head,
99
+ "X-AgentDir-Tool": tool,
100
+ "X-AgentDir-Tool-Exit-Code": str(tool_exit_code) if tool_exit_code is not None else None,
101
+ }
102
+ for name, value in optional.items():
103
+ if value:
104
+ msg[name] = value
105
+
106
+ if parent_message_id:
107
+ msg["In-Reply-To"] = parent_message_id
108
+ refs = list(references or [])
109
+ if parent_message_id and parent_message_id not in refs:
110
+ refs.append(parent_message_id)
111
+ if refs:
112
+ msg["References"] = " ".join(refs)
113
+
114
+ for source in (artifact_headers or {}, extra_headers or {}):
115
+ for name, value in source.items():
116
+ if value is None:
117
+ continue
118
+ if isinstance(value, str):
119
+ msg[name] = value
120
+ continue
121
+ for item in value:
122
+ if item is not None:
123
+ msg[name] = str(item)
124
+
125
+ msg.set_content(body, subtype="plain", charset="utf-8")
126
+ return msg
127
+
128
+
129
+ def envelope_bytes(message: EmailMessage) -> bytes:
130
+ return message.as_bytes(policy=policy.default.clone(linesep="\n"))
131
+
132
+
133
+ def parse_envelope(path: Path) -> ParsedEnvelope:
134
+ raw = path.read_bytes()
135
+ return ParsedEnvelope(
136
+ message=BytesParser(policy=policy.default).parsebytes(raw),
137
+ raw=raw,
138
+ path=path,
139
+ )
140
+
141
+
142
+ def validate_required(parsed: ParsedEnvelope) -> list[str]:
143
+ return [
144
+ name
145
+ for name in ("Message-ID", "Date", "From", "To", "X-AgentDir-Version", "X-AgentDir-Event-Type")
146
+ if not parsed.message.get(name)
147
+ ]
agentdir/events.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Iterable
6
+
7
+ from .artifacts import add_artifact, artifact_headers
8
+ from .envelope import build_envelope, envelope_bytes
9
+ from .mailbox import atomic_deliver
10
+ from .redaction import redact_text
11
+ from .store import init_root, session_mailbox
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class EmittedEvent:
16
+ path: Path
17
+ session_id: str
18
+ event_type: str
19
+
20
+
21
+ def emit_event(
22
+ root: str | Path,
23
+ *,
24
+ session_id: str,
25
+ event_type: str,
26
+ body: str,
27
+ subject: str | None = None,
28
+ from_actor: str | None = None,
29
+ to_actor: str | None = None,
30
+ task_id: str | None = None,
31
+ workspace: str | None = None,
32
+ git_head: str | None = None,
33
+ tool: str | None = None,
34
+ tool_exit_code: int | None = None,
35
+ parent_message_id: str | None = None,
36
+ artifact: str | Path | None = None,
37
+ extra_headers: dict[str, str | Iterable[str]] | None = None,
38
+ message_id: str | None = None,
39
+ ) -> EmittedEvent:
40
+ init_root(root)
41
+ stored_artifact = add_artifact(root, artifact) if artifact else None
42
+ redacted = redact_text(body)
43
+ headers = dict(extra_headers or {})
44
+ if redacted.replacements:
45
+ headers["X-AgentDir-Redactions"] = str(_redaction_count(headers) + redacted.replacements)
46
+ headers["X-AgentDir-Redaction-Labels"] = ",".join(redacted.labels)
47
+ message = build_envelope(
48
+ event_type=event_type,
49
+ body=redacted.text,
50
+ subject=subject,
51
+ from_actor=from_actor,
52
+ to_actor=to_actor,
53
+ session_id=session_id,
54
+ task_id=task_id,
55
+ workspace=workspace,
56
+ git_head=git_head,
57
+ tool=tool,
58
+ tool_exit_code=tool_exit_code,
59
+ parent_message_id=parent_message_id,
60
+ artifact_headers=artifact_headers(stored_artifact) if stored_artifact else {},
61
+ extra_headers=headers,
62
+ message_id=message_id,
63
+ )
64
+ delivered = atomic_deliver(session_mailbox(root, session_id), envelope_bytes(message))
65
+ return EmittedEvent(path=delivered, session_id=session_id, event_type=event_type)
66
+
67
+
68
+ def _redaction_count(headers: dict[str, str | Iterable[str]]) -> int:
69
+ try:
70
+ return int(str(headers.get("X-AgentDir-Redactions") or "0"))
71
+ except ValueError:
72
+ return 0