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/query.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .index import connect_index
7
+
8
+
9
+ def query_messages(
10
+ root: str | Path,
11
+ *,
12
+ session_id: str | None = None,
13
+ event_type: str | None = None,
14
+ actor: str | None = None,
15
+ task_id: str | None = None,
16
+ tool: str | None = None,
17
+ git_head: str | None = None,
18
+ workspace: str | None = None,
19
+ text: str | None = None,
20
+ since: str | None = None,
21
+ until: str | None = None,
22
+ limit: int = 100,
23
+ ) -> list[dict[str, Any]]:
24
+ clauses: list[str] = []
25
+ params: list[Any] = []
26
+ if session_id:
27
+ clauses.append("session_id = ?")
28
+ params.append(session_id)
29
+ if event_type:
30
+ clauses.append("event_type = ?")
31
+ params.append(event_type)
32
+ if actor:
33
+ clauses.append("(from_actor like ? or to_actor like ?)")
34
+ params.extend([f"{actor}%", f"{actor}%"])
35
+ if task_id:
36
+ clauses.append("task_id = ?")
37
+ params.append(task_id)
38
+ if tool:
39
+ clauses.append("tool = ?")
40
+ params.append(tool)
41
+ if git_head:
42
+ clauses.append("git_head = ?")
43
+ params.append(git_head)
44
+ if workspace:
45
+ clauses.append("workspace = ?")
46
+ params.append(workspace)
47
+ if since:
48
+ clauses.append("coalesce(date_utc, indexed_at) >= ?")
49
+ params.append(since)
50
+ if until:
51
+ clauses.append("coalesce(date_utc, indexed_at) <= ?")
52
+ params.append(until)
53
+ if text:
54
+ clauses.append("(subject like ? or body_text like ? or message_id like ?)")
55
+ like = f"%{text}%"
56
+ params.extend([like, like, like])
57
+
58
+ sql = "select * from messages"
59
+ if clauses:
60
+ sql += " where " + " and ".join(clauses)
61
+ sql += " order by coalesce(date_utc, indexed_at), coalesce(created_ns, 0), file_path, id limit ?"
62
+ params.append(limit)
63
+
64
+ with connect_index(root) as conn:
65
+ return [dict(row) for row in conn.execute(sql, params).fetchall()]
agentdir/redaction.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+
7
+ SECRET_PATTERNS = [
8
+ ("github-token", re.compile(r"gh[opsu]_[A-Za-z0-9_]{20,}")),
9
+ ("aws-access-key", re.compile(r"AKIA[0-9A-Z]{16}")),
10
+ (
11
+ "key-value-secret",
12
+ re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*['\"]?[A-Za-z0-9_./+=-]{12,}"),
13
+ ),
14
+ ("private-key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
15
+ ]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class RedactionResult:
20
+ text: str
21
+ replacements: int
22
+ labels: tuple[str, ...] = ()
23
+
24
+
25
+ def redact_text(text: str) -> RedactionResult:
26
+ replacements = 0
27
+ redacted = text
28
+ labels: list[str] = []
29
+ for label, pattern in SECRET_PATTERNS:
30
+ redacted, count = pattern.subn(f"<redacted:{label}>", redacted)
31
+ replacements += count
32
+ if count:
33
+ labels.append(label)
34
+ return RedactionResult(text=redacted, replacements=replacements, labels=tuple(labels))
35
+
36
+
37
+ def looks_secret_bearing(text: str) -> bool:
38
+ return any(pattern.search(text) for _label, pattern in SECRET_PATTERNS)
39
+
40
+
41
+ def secret_labels(text: str) -> tuple[str, ...]:
42
+ return tuple(label for label, pattern in SECRET_PATTERNS if pattern.search(text))
agentdir/rendering.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def rich_status(status: dict[str, Any]) -> str | None:
7
+ rich = _rich()
8
+ if rich is None:
9
+ return None
10
+ Console, Table = rich
11
+ table = Table(title="AgentDir Status", show_header=True, header_style="bold")
12
+ table.add_column("Area")
13
+ table.add_column("Key")
14
+ table.add_column("Value")
15
+ root = status["root"]
16
+ session = status["session"]
17
+ context = status["context"]
18
+ memory = status["memory"]
19
+ federation = status["federation"]
20
+ health = status["health"]
21
+ table.add_row("root", "path", root["path"])
22
+ table.add_row("root", "scope", root["scope"])
23
+ table.add_row("root", "version", str(root.get("version") or ""))
24
+ table.add_row("session", "active", _bool(session["active"]))
25
+ table.add_row("session", "current", session["current"]["session_id"] if session.get("current") else "")
26
+ table.add_row("context", "latest_pack", context["latest_pack"]["pack_id"] if context.get("latest_pack") else "")
27
+ table.add_row("evidence", "count", str(status["evidence"]["count"]))
28
+ table.add_row("memory", "documents", str(memory.get("memory_documents", 0)))
29
+ table.add_row("memory", "passages", str(memory.get("passages", 0)))
30
+ table.add_row("federation", "registered_roots", str(federation["registered_roots"]))
31
+ table.add_row("federation", "stale_roots", str(federation["stale_roots"]))
32
+ table.add_row("health", "doctor_ok", _bool(health["ok"]))
33
+ return _export(table, Console)
34
+
35
+
36
+ def rich_root_diagnostics(rows: list[dict[str, Any]]) -> str | None:
37
+ rich = _rich()
38
+ if rich is None:
39
+ return None
40
+ Console, Table = rich
41
+ table = Table(title="AgentDir Root Diagnostics", show_header=True, header_style="bold")
42
+ for column in ("Root", "Name", "Status", "Freshness", "Path", "Details"):
43
+ table.add_column(column)
44
+ for row in rows:
45
+ details = "; ".join([*row.get("errors", []), *row.get("warnings", [])])
46
+ table.add_row(
47
+ row["root_id"],
48
+ row["name"],
49
+ "ok" if row["ok"] else "error",
50
+ "stale" if row.get("stale") else "fresh",
51
+ row["root_path"],
52
+ details,
53
+ )
54
+ return _export(table, Console)
55
+
56
+
57
+ def rich_doctor(report: dict[str, Any]) -> str | None:
58
+ rich = _rich()
59
+ if rich is None:
60
+ return None
61
+ Console, Table = rich
62
+ table = Table(title="AgentDir Doctor", show_header=True, header_style="bold")
63
+ table.add_column("Level")
64
+ table.add_column("Message")
65
+ table.add_row("ok", _bool(report["ok"]))
66
+ for warning in report.get("warnings", []):
67
+ table.add_row("warning", warning)
68
+ for error in report.get("errors", []):
69
+ table.add_row("error", error)
70
+ return _export(table, Console)
71
+
72
+
73
+ def _rich() -> tuple[Any, Any] | None:
74
+ try:
75
+ from rich.console import Console
76
+ from rich.table import Table
77
+ except ImportError:
78
+ return None
79
+ return Console, Table
80
+
81
+
82
+ def _export(renderable: Any, Console: Any) -> str:
83
+ console = Console(record=True, force_terminal=False, color_system=None, width=120)
84
+ console.print(renderable)
85
+ return console.export_text(clear=True)
86
+
87
+
88
+ def _bool(value: bool) -> str:
89
+ return "true" if value else "false"
agentdir/replay.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+
6
+ from .index import rebuild_index
7
+ from .query import query_messages
8
+
9
+
10
+ def replay_session(root: str | Path, session_id: str) -> list[str]:
11
+ try:
12
+ rows = query_messages(root, session_id=session_id, limit=10_000)
13
+ except sqlite3.DatabaseError as exc:
14
+ if not _looks_like_missing_index(exc):
15
+ raise
16
+ rebuild_index(root)
17
+ rows = query_messages(root, session_id=session_id, limit=10_000)
18
+ lines: list[str] = []
19
+ for row in rows:
20
+ date = row.get("date_header") or row.get("indexed_at") or "unknown-date"
21
+ event_type = row.get("event_type") or "unknown"
22
+ subject = row.get("subject") or ""
23
+ body = (row.get("body_text") or "").strip().replace("\n", "\\n")
24
+ file_path = row.get("file_path") or ""
25
+ lines.append(f"{date} {event_type} {subject} {body} [{file_path}]")
26
+ return lines
27
+
28
+
29
+ def _looks_like_missing_index(exc: sqlite3.DatabaseError) -> bool:
30
+ message = str(exc).lower()
31
+ return "no such table" in message or "file is not a database" in message or "database disk image is malformed" in message
agentdir/retention.py ADDED
@@ -0,0 +1,319 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import time
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+
8
+ from .index import rebuild_index
9
+ from .mailbox import fsync_directory
10
+ from .sessions import read_current_session
11
+ from .store import AgentDirError, init_root, validate_id
12
+
13
+ SECONDS_PER_DAY = 24 * 60 * 60
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class RetentionCandidate:
18
+ session_id: str
19
+ store: str
20
+ path: str
21
+ event_count: int
22
+ latest_event_at: float | None
23
+ active: bool = False
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class RetentionResult:
28
+ action: str
29
+ dry_run: bool
30
+ selected: list[RetentionCandidate]
31
+ protected: list[RetentionCandidate]
32
+ changed: list[str]
33
+ rebuilt_index: bool = False
34
+
35
+ def as_dict(self) -> dict[str, object]:
36
+ return {
37
+ "action": self.action,
38
+ "dry_run": self.dry_run,
39
+ "selected": [asdict(candidate) for candidate in self.selected],
40
+ "protected": [asdict(candidate) for candidate in self.protected],
41
+ "changed": self.changed,
42
+ "rebuilt_index": self.rebuilt_index,
43
+ }
44
+
45
+
46
+ def archive_sessions(
47
+ root: str | Path,
48
+ *,
49
+ sessions: list[str] | None = None,
50
+ older_than_days: int | None = None,
51
+ keep_recent: int | None = None,
52
+ apply: bool = False,
53
+ ) -> RetentionResult:
54
+ paths = init_root(root)
55
+ _validate_selectors(
56
+ sessions=sessions,
57
+ older_than_days=older_than_days,
58
+ keep_recent=keep_recent,
59
+ command="archive",
60
+ )
61
+ active_id = _active_session_id(paths.root)
62
+ candidates = _list_sessions(paths.sessions, store="sessions", active_session_id=active_id)
63
+ selected, protected = _select_candidates(
64
+ candidates,
65
+ sessions=sessions,
66
+ older_than_days=older_than_days,
67
+ keep_recent=keep_recent,
68
+ )
69
+ _raise_on_exact_active("archive", sessions, protected)
70
+
71
+ changed: list[str] = []
72
+ if apply:
73
+ target_root = paths.archives / "sessions"
74
+ target_root.mkdir(parents=True, exist_ok=True)
75
+ targets: list[tuple[RetentionCandidate, Path]] = []
76
+ for candidate in selected:
77
+ target = target_root / candidate.session_id
78
+ if target.exists():
79
+ raise AgentDirError(f"archive target already exists: {target}")
80
+ targets.append((candidate, target))
81
+ for candidate, target in targets:
82
+ source = Path(candidate.path)
83
+ source.rename(target)
84
+ fsync_directory(paths.sessions)
85
+ fsync_directory(target_root)
86
+ changed.append(f"{candidate.session_id}:sessions->archives")
87
+ rebuilt = bool(changed)
88
+ if rebuilt:
89
+ rebuild_index(paths.root)
90
+ else:
91
+ rebuilt = False
92
+
93
+ return RetentionResult(
94
+ action="archive",
95
+ dry_run=not apply,
96
+ selected=selected,
97
+ protected=protected,
98
+ changed=changed,
99
+ rebuilt_index=rebuilt,
100
+ )
101
+
102
+
103
+ def prune_sessions(
104
+ root: str | Path,
105
+ *,
106
+ sessions: list[str] | None = None,
107
+ older_than_days: int | None = None,
108
+ keep_recent: int | None = None,
109
+ include_live_sessions: bool = False,
110
+ apply: bool = False,
111
+ ) -> RetentionResult:
112
+ paths = init_root(root)
113
+ _validate_selectors(
114
+ sessions=sessions,
115
+ older_than_days=older_than_days,
116
+ keep_recent=keep_recent,
117
+ command="prune",
118
+ )
119
+ active_id = _active_session_id(paths.root)
120
+ candidates = _list_sessions(
121
+ paths.archives / "sessions",
122
+ store="archives",
123
+ active_session_id=active_id,
124
+ )
125
+ if include_live_sessions:
126
+ candidates.extend(
127
+ _list_sessions(paths.sessions, store="sessions", active_session_id=active_id)
128
+ )
129
+ selected, protected = _select_candidates(
130
+ candidates,
131
+ sessions=sessions,
132
+ older_than_days=older_than_days,
133
+ keep_recent=keep_recent,
134
+ )
135
+ _raise_on_exact_active("prune", sessions, protected)
136
+
137
+ changed: list[str] = []
138
+ live_changed = False
139
+ if apply:
140
+ for candidate in selected:
141
+ source = Path(candidate.path)
142
+ shutil.rmtree(source)
143
+ fsync_directory(source.parent)
144
+ changed.append(f"{candidate.session_id}:{candidate.store}->deleted")
145
+ live_changed = live_changed or candidate.store == "sessions"
146
+ if live_changed:
147
+ rebuild_index(paths.root)
148
+
149
+ return RetentionResult(
150
+ action="prune",
151
+ dry_run=not apply,
152
+ selected=selected,
153
+ protected=protected,
154
+ changed=changed,
155
+ rebuilt_index=apply and live_changed,
156
+ )
157
+
158
+
159
+ def format_retention_result(result: RetentionResult) -> str:
160
+ mode = "dry-run" if result.dry_run else "applied"
161
+ lines = [
162
+ f"action={result.action}",
163
+ f"mode={mode}",
164
+ f"selected={len(result.selected)}",
165
+ f"protected={len(result.protected)}",
166
+ f"changed={len(result.changed)}",
167
+ ]
168
+ for candidate in result.selected:
169
+ lines.append(
170
+ f"selected: {candidate.session_id} store={candidate.store} events={candidate.event_count}"
171
+ )
172
+ for candidate in result.protected:
173
+ lines.append(f"protected: {candidate.session_id} store={candidate.store} active=true")
174
+ for changed in result.changed:
175
+ lines.append(f"changed: {changed}")
176
+ if result.rebuilt_index:
177
+ lines.append("rebuilt_index=true")
178
+ if result.dry_run and result.selected:
179
+ lines.append("rerun with --apply to perform this change")
180
+ return "\n".join(lines)
181
+
182
+
183
+ def _validate_selectors(
184
+ *,
185
+ sessions: list[str] | None,
186
+ older_than_days: int | None,
187
+ keep_recent: int | None,
188
+ command: str,
189
+ ) -> None:
190
+ if not sessions and older_than_days is None and keep_recent is None:
191
+ raise AgentDirError(
192
+ f"{command} requires --session, --older-than-days, or --keep-recent"
193
+ )
194
+ if sessions and (older_than_days is not None or keep_recent is not None):
195
+ raise AgentDirError("Use --session or age/count filters, not both")
196
+ for session_id in sessions or []:
197
+ validate_id(session_id, "session id")
198
+ if older_than_days is not None and older_than_days < 0:
199
+ raise AgentDirError("--older-than-days must be non-negative")
200
+ if keep_recent is not None and keep_recent < 0:
201
+ raise AgentDirError("--keep-recent must be non-negative")
202
+
203
+
204
+ def _active_session_id(root: Path) -> str | None:
205
+ try:
206
+ state = read_current_session(root)
207
+ except Exception:
208
+ return None
209
+ if state and state.status == "active":
210
+ return state.session_id
211
+ return None
212
+
213
+
214
+ def _list_sessions(
215
+ directory: Path,
216
+ *,
217
+ store: str,
218
+ active_session_id: str | None,
219
+ ) -> list[RetentionCandidate]:
220
+ if not directory.is_dir():
221
+ return []
222
+ candidates: list[RetentionCandidate] = []
223
+ for session_dir in sorted(directory.iterdir()):
224
+ if not session_dir.is_dir() or not (session_dir / "Maildir").is_dir():
225
+ continue
226
+ event_paths = [
227
+ path
228
+ for state in ("new", "cur")
229
+ for path in (session_dir / "Maildir" / state).glob("*")
230
+ if path.is_file() and not path.name.startswith(".")
231
+ ]
232
+ latest_event_at = max((path.stat().st_mtime for path in event_paths), default=None)
233
+ if latest_event_at is None:
234
+ latest_event_at = session_dir.stat().st_mtime
235
+ candidates.append(
236
+ RetentionCandidate(
237
+ session_id=session_dir.name,
238
+ store=store,
239
+ path=str(session_dir),
240
+ event_count=len(event_paths),
241
+ latest_event_at=latest_event_at,
242
+ active=session_dir.name == active_session_id and store == "sessions",
243
+ )
244
+ )
245
+ return candidates
246
+
247
+
248
+ def _select_candidates(
249
+ candidates: list[RetentionCandidate],
250
+ *,
251
+ sessions: list[str] | None,
252
+ older_than_days: int | None,
253
+ keep_recent: int | None,
254
+ ) -> tuple[list[RetentionCandidate], list[RetentionCandidate]]:
255
+ selected = _select_unprotected_candidates(
256
+ candidates,
257
+ sessions=sessions,
258
+ older_than_days=older_than_days,
259
+ keep_recent=keep_recent,
260
+ )
261
+ protected = [candidate for candidate in selected if candidate.active]
262
+ return [candidate for candidate in selected if not candidate.active], protected
263
+
264
+
265
+ def _select_unprotected_candidates(
266
+ candidates: list[RetentionCandidate],
267
+ *,
268
+ sessions: list[str] | None,
269
+ older_than_days: int | None,
270
+ keep_recent: int | None,
271
+ ) -> list[RetentionCandidate]:
272
+ if sessions:
273
+ requested = set(sessions)
274
+ selected = [candidate for candidate in candidates if candidate.session_id in requested]
275
+ found = {candidate.session_id for candidate in selected}
276
+ missing = sorted(requested - found)
277
+ if missing:
278
+ raise AgentDirError(f"unknown session id(s): {', '.join(missing)}")
279
+ return selected
280
+
281
+ selected = list(candidates)
282
+ if older_than_days is not None:
283
+ cutoff = time.time() - (older_than_days * SECONDS_PER_DAY)
284
+ selected = [
285
+ candidate
286
+ for candidate in selected
287
+ if (candidate.latest_event_at or 0) <= cutoff
288
+ ]
289
+ if keep_recent is not None:
290
+ ordered = sorted(
291
+ candidates,
292
+ key=lambda candidate: (
293
+ candidate.latest_event_at or 0,
294
+ candidate.store,
295
+ candidate.session_id,
296
+ ),
297
+ reverse=True,
298
+ )
299
+ prune_keys = {
300
+ (candidate.store, candidate.session_id)
301
+ for candidate in ordered[keep_recent:]
302
+ }
303
+ selected = [
304
+ candidate
305
+ for candidate in selected
306
+ if (candidate.store, candidate.session_id) in prune_keys
307
+ ]
308
+ return selected
309
+
310
+
311
+ def _raise_on_exact_active(
312
+ command: str,
313
+ sessions: list[str] | None,
314
+ protected: list[RetentionCandidate],
315
+ ) -> None:
316
+ if not sessions or not protected:
317
+ return
318
+ ids = ", ".join(candidate.session_id for candidate in protected)
319
+ raise AgentDirError(f"refusing to {command} active session(s): {ids}")