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/__init__.py +3 -0
- agentdir/__main__.py +6 -0
- agentdir/actors.py +50 -0
- agentdir/artifacts.py +56 -0
- agentdir/audit.py +339 -0
- agentdir/capture.py +187 -0
- agentdir/cli.py +1900 -0
- agentdir/context.py +654 -0
- agentdir/control.py +729 -0
- agentdir/daemon.py +253 -0
- agentdir/doctor.py +115 -0
- agentdir/envelope.py +147 -0
- agentdir/events.py +72 -0
- agentdir/federation.py +530 -0
- agentdir/git.py +45 -0
- agentdir/gitignore.py +106 -0
- agentdir/hooks.py +215 -0
- agentdir/index.py +362 -0
- agentdir/mailbox.py +84 -0
- agentdir/memory.py +1274 -0
- agentdir/query.py +65 -0
- agentdir/redaction.py +42 -0
- agentdir/rendering.py +89 -0
- agentdir/replay.py +31 -0
- agentdir/retention.py +319 -0
- agentdir/review.py +288 -0
- agentdir/secrets.py +158 -0
- agentdir/sessions.py +171 -0
- agentdir/skills.py +685 -0
- agentdir/store.py +270 -0
- agentdir/upgrade.py +252 -0
- agentdir_cli-0.7.5.dist-info/METADATA +393 -0
- agentdir_cli-0.7.5.dist-info/RECORD +37 -0
- agentdir_cli-0.7.5.dist-info/WHEEL +5 -0
- agentdir_cli-0.7.5.dist-info/entry_points.txt +2 -0
- agentdir_cli-0.7.5.dist-info/licenses/LICENSE +21 -0
- agentdir_cli-0.7.5.dist-info/top_level.txt +1 -0
agentdir/review.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .index import rebuild_index
|
|
8
|
+
from .query import query_messages
|
|
9
|
+
from .sessions import require_current_session
|
|
10
|
+
|
|
11
|
+
EVIDENCE_FAMILIES = ("test", "lint", "typecheck", "build", "doctor", "release", "diagnostic")
|
|
12
|
+
|
|
13
|
+
_FAMILY_KEYWORDS: dict[str, tuple[str, ...]] = {
|
|
14
|
+
"test": (
|
|
15
|
+
"pytest",
|
|
16
|
+
"unittest",
|
|
17
|
+
"test",
|
|
18
|
+
"tests",
|
|
19
|
+
"npm test",
|
|
20
|
+
"pnpm test",
|
|
21
|
+
"yarn test",
|
|
22
|
+
"go test",
|
|
23
|
+
"cargo test",
|
|
24
|
+
"rspec",
|
|
25
|
+
),
|
|
26
|
+
"lint": ("lint", "eslint", "ruff", "flake8", "prettier", "clippy", "golangci-lint"),
|
|
27
|
+
"typecheck": ("typecheck", "type-check", "tsc", "mypy", "pyright", "sorbet", "staticcheck"),
|
|
28
|
+
"build": ("build", "npm run build", "pnpm build", "yarn build", "cargo build", "go build", "make"),
|
|
29
|
+
"doctor": ("doctor", "health", "diagnose", "diagnostic"),
|
|
30
|
+
"release": ("release", "publish", "pack", "version", "tag", "twine", "npm publish"),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_DIAGNOSTIC_EVENTS = {"file.diff"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_review_session(root: str | Path, session_id: str | None) -> str:
|
|
37
|
+
if session_id:
|
|
38
|
+
return session_id
|
|
39
|
+
return require_current_session(root).session_id
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ensure_index(root: str | Path) -> None:
|
|
43
|
+
rebuild_index(root)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def summarize_session(root: str | Path, session_id: str | None = None, *, rebuild: bool = True) -> dict[str, Any]:
|
|
47
|
+
resolved = resolve_review_session(root, session_id)
|
|
48
|
+
if rebuild:
|
|
49
|
+
ensure_index(root)
|
|
50
|
+
rows = query_messages(root, session_id=resolved, limit=10_000)
|
|
51
|
+
counts = Counter(row.get("event_type") or "unknown" for row in rows)
|
|
52
|
+
tool_results = [row for row in rows if row.get("event_type") == "tool.result"]
|
|
53
|
+
failed_tools = [row for row in tool_results if row.get("tool_exit_code") not in (None, 0)]
|
|
54
|
+
return {
|
|
55
|
+
"session_id": resolved,
|
|
56
|
+
"events": len(rows),
|
|
57
|
+
"event_counts": dict(sorted(counts.items())),
|
|
58
|
+
"tool_results": len(tool_results),
|
|
59
|
+
"failed_tools": len(failed_tools),
|
|
60
|
+
"first_event": rows[0]["date_header"] if rows else None,
|
|
61
|
+
"last_event": rows[-1]["date_header"] if rows else None,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def format_summary(summary: dict[str, Any]) -> str:
|
|
66
|
+
lines = [
|
|
67
|
+
f"session={summary['session_id']}",
|
|
68
|
+
f"events={summary['events']}",
|
|
69
|
+
f"tool_results={summary['tool_results']}",
|
|
70
|
+
f"failed_tools={summary['failed_tools']}",
|
|
71
|
+
]
|
|
72
|
+
for event_type, count in summary["event_counts"].items():
|
|
73
|
+
lines.append(f"{event_type}={count}")
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def evidence_rows(root: str | Path, session_id: str | None = None, *, rebuild: bool = True) -> list[dict[str, Any]]:
|
|
78
|
+
resolved = resolve_review_session(root, session_id)
|
|
79
|
+
if rebuild:
|
|
80
|
+
ensure_index(root)
|
|
81
|
+
rows = query_messages(root, session_id=resolved, limit=10_000)
|
|
82
|
+
wanted = {"tool.call", "tool.result", "file.diff"}
|
|
83
|
+
evidence = [
|
|
84
|
+
row
|
|
85
|
+
for row in rows
|
|
86
|
+
if row.get("event_type") in wanted or str(row.get("event_type") or "").startswith("git.hook.")
|
|
87
|
+
]
|
|
88
|
+
for row in evidence:
|
|
89
|
+
row["family"] = classify_evidence(row)
|
|
90
|
+
row["failed"] = evidence_failed(row)
|
|
91
|
+
return evidence
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def classify_evidence(row: dict[str, Any]) -> str:
|
|
95
|
+
event_type = str(row.get("event_type") or "")
|
|
96
|
+
if event_type in _DIAGNOSTIC_EVENTS or event_type.startswith("git.hook."):
|
|
97
|
+
return "diagnostic"
|
|
98
|
+
primary = " ".join(str(row.get(key) or "") for key in ("tool", "subject")).lower()
|
|
99
|
+
for family in EVIDENCE_FAMILIES:
|
|
100
|
+
if family == "diagnostic":
|
|
101
|
+
continue
|
|
102
|
+
if any(keyword in primary for keyword in _FAMILY_KEYWORDS.get(family, ())):
|
|
103
|
+
return family
|
|
104
|
+
haystack = _evidence_search_text(row)
|
|
105
|
+
for family in EVIDENCE_FAMILIES:
|
|
106
|
+
if family == "diagnostic":
|
|
107
|
+
continue
|
|
108
|
+
if any(keyword in haystack for keyword in _FAMILY_KEYWORDS.get(family, ())):
|
|
109
|
+
return family
|
|
110
|
+
return "diagnostic"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def evidence_failed(row: dict[str, Any]) -> bool:
|
|
114
|
+
return row.get("event_type") == "tool.result" and row.get("tool_exit_code") not in (None, 0)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def evidence_ref(row: dict[str, Any]) -> dict[str, Any]:
|
|
118
|
+
return {
|
|
119
|
+
"event_type": row.get("event_type"),
|
|
120
|
+
"family": row.get("family") or classify_evidence(row),
|
|
121
|
+
"tool": row.get("tool"),
|
|
122
|
+
"exit_code": row.get("tool_exit_code"),
|
|
123
|
+
"subject": row.get("subject"),
|
|
124
|
+
"date": row.get("date_header") or row.get("date_utc") or row.get("indexed_at"),
|
|
125
|
+
"path": row.get("file_path"),
|
|
126
|
+
"failed": row.get("failed") if "failed" in row else evidence_failed(row),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def filter_evidence(
|
|
131
|
+
rows: list[dict[str, Any]],
|
|
132
|
+
*,
|
|
133
|
+
family: str | None = None,
|
|
134
|
+
failed: bool = False,
|
|
135
|
+
) -> list[dict[str, Any]]:
|
|
136
|
+
if family and family not in EVIDENCE_FAMILIES:
|
|
137
|
+
raise ValueError(f"unknown evidence family: {family}")
|
|
138
|
+
filtered: list[dict[str, Any]] = []
|
|
139
|
+
for row in rows:
|
|
140
|
+
row_family = row.get("family") or classify_evidence(row)
|
|
141
|
+
row_failed = row.get("failed") if "failed" in row else evidence_failed(row)
|
|
142
|
+
if family and row_family != family:
|
|
143
|
+
continue
|
|
144
|
+
if failed and not row_failed:
|
|
145
|
+
continue
|
|
146
|
+
copied = dict(row)
|
|
147
|
+
copied["family"] = row_family
|
|
148
|
+
copied["failed"] = row_failed
|
|
149
|
+
filtered.append(copied)
|
|
150
|
+
return filtered
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def evidence_brief(rows: list[dict[str, Any]], *, family: str | None = None, failed: bool = False) -> dict[str, Any]:
|
|
154
|
+
filtered = filter_evidence(rows, family=family, failed=failed)
|
|
155
|
+
grouped: dict[str, list[dict[str, Any]]] = {name: [] for name in EVIDENCE_FAMILIES}
|
|
156
|
+
for row in filtered:
|
|
157
|
+
grouped[str(row.get("family") or classify_evidence(row))].append(row)
|
|
158
|
+
failed_rows = [row for row in filtered if row.get("failed") or evidence_failed(row)]
|
|
159
|
+
families: list[dict[str, Any]] = []
|
|
160
|
+
latest_by_family: dict[str, dict[str, Any]] = {}
|
|
161
|
+
for name in EVIDENCE_FAMILIES:
|
|
162
|
+
items = grouped[name]
|
|
163
|
+
if not items:
|
|
164
|
+
continue
|
|
165
|
+
latest = evidence_ref(items[-1])
|
|
166
|
+
latest_by_family[name] = latest
|
|
167
|
+
families.append(
|
|
168
|
+
{
|
|
169
|
+
"family": name,
|
|
170
|
+
"total": len(items),
|
|
171
|
+
"failed": sum(1 for row in items if row.get("failed") or evidence_failed(row)),
|
|
172
|
+
"latest": latest,
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
return {
|
|
176
|
+
"families": families,
|
|
177
|
+
"latest_by_family": latest_by_family,
|
|
178
|
+
"failed_evidence": [evidence_ref(row) for row in failed_rows],
|
|
179
|
+
"counts": {
|
|
180
|
+
"total": len(filtered),
|
|
181
|
+
"failed": len(failed_rows),
|
|
182
|
+
"families": len(families),
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def format_evidence_brief(brief: dict[str, Any]) -> str:
|
|
188
|
+
lines: list[str] = []
|
|
189
|
+
failed = brief.get("failed_evidence") or []
|
|
190
|
+
if failed:
|
|
191
|
+
lines.append("Failed evidence:")
|
|
192
|
+
for item in failed:
|
|
193
|
+
lines.append(_format_evidence_ref(item))
|
|
194
|
+
families = brief.get("families") or []
|
|
195
|
+
if families:
|
|
196
|
+
if lines:
|
|
197
|
+
lines.append("")
|
|
198
|
+
lines.append("Latest evidence by family:")
|
|
199
|
+
for family in families:
|
|
200
|
+
lines.append(_format_evidence_ref(family["latest"], prefix=f"{family['family']}: "))
|
|
201
|
+
if not lines:
|
|
202
|
+
return "No evidence captured."
|
|
203
|
+
return "\n".join(lines)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def timeline_rows(
|
|
207
|
+
root: str | Path,
|
|
208
|
+
session_id: str | None = None,
|
|
209
|
+
*,
|
|
210
|
+
limit: int = 100,
|
|
211
|
+
rebuild: bool = True,
|
|
212
|
+
) -> list[dict[str, Any]]:
|
|
213
|
+
resolved = resolve_review_session(root, session_id)
|
|
214
|
+
if rebuild:
|
|
215
|
+
ensure_index(root)
|
|
216
|
+
rows = query_messages(root, session_id=resolved, limit=limit)
|
|
217
|
+
return [timeline_ref(row) for row in rows]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def timeline_ref(row: dict[str, Any]) -> dict[str, Any]:
|
|
221
|
+
event_type = row.get("event_type")
|
|
222
|
+
item = {
|
|
223
|
+
"date": row.get("date_header") or row.get("date_utc") or row.get("indexed_at"),
|
|
224
|
+
"event_type": event_type,
|
|
225
|
+
"subject": row.get("subject"),
|
|
226
|
+
"tool": row.get("tool"),
|
|
227
|
+
"exit_code": row.get("tool_exit_code"),
|
|
228
|
+
"actor": row.get("from_actor"),
|
|
229
|
+
"path": row.get("file_path"),
|
|
230
|
+
}
|
|
231
|
+
if event_type in {"tool.call", "tool.result", "file.diff"} or str(event_type or "").startswith("git.hook."):
|
|
232
|
+
item["family"] = classify_evidence(row)
|
|
233
|
+
item["failed"] = evidence_failed(row)
|
|
234
|
+
return item
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def format_timeline(rows: list[dict[str, Any]]) -> str:
|
|
238
|
+
lines: list[str] = []
|
|
239
|
+
for row in rows:
|
|
240
|
+
date = row.get("date") or "unknown-date"
|
|
241
|
+
event_type = row.get("event_type") or "unknown"
|
|
242
|
+
subject = row.get("subject") or ""
|
|
243
|
+
tool = row.get("tool") or ""
|
|
244
|
+
exit_code = row.get("exit_code")
|
|
245
|
+
family = row.get("family") or ""
|
|
246
|
+
detail_parts = []
|
|
247
|
+
if tool:
|
|
248
|
+
detail_parts.append(str(tool))
|
|
249
|
+
if family:
|
|
250
|
+
detail_parts.append(f"family={family}")
|
|
251
|
+
if exit_code is not None:
|
|
252
|
+
detail_parts.append(f"exit={exit_code}")
|
|
253
|
+
detail = " ".join(detail_parts)
|
|
254
|
+
lines.append(f"{date} {event_type} {detail} {subject}".rstrip())
|
|
255
|
+
return "\n".join(lines)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def format_evidence(rows: list[dict[str, Any]]) -> str:
|
|
259
|
+
lines: list[str] = []
|
|
260
|
+
for row in rows:
|
|
261
|
+
date = row.get("date_header") or row.get("indexed_at") or "unknown-date"
|
|
262
|
+
event_type = row.get("event_type") or "unknown"
|
|
263
|
+
tool = row.get("tool") or ""
|
|
264
|
+
exit_code = row.get("tool_exit_code")
|
|
265
|
+
subject = row.get("subject") or ""
|
|
266
|
+
file_path = row.get("file_path") or ""
|
|
267
|
+
family = row.get("family") or classify_evidence(row)
|
|
268
|
+
detail = f" exit={exit_code}" if exit_code is not None else ""
|
|
269
|
+
family_detail = f" family={family}" if family else ""
|
|
270
|
+
lines.append(f"{date} {event_type} {tool}{detail}{family_detail} {subject} [{file_path}]")
|
|
271
|
+
return "\n".join(lines)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _evidence_search_text(row: dict[str, Any]) -> str:
|
|
275
|
+
return " ".join(
|
|
276
|
+
str(row.get(key) or "")
|
|
277
|
+
for key in ("tool", "subject", "body_text", "event_type", "message_id")
|
|
278
|
+
).lower()
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _format_evidence_ref(item: dict[str, Any], *, prefix: str = "- ") -> str:
|
|
282
|
+
tool = item.get("tool") or ""
|
|
283
|
+
exit_code = item.get("exit_code")
|
|
284
|
+
subject = item.get("subject") or ""
|
|
285
|
+
date = item.get("date") or "unknown-date"
|
|
286
|
+
exit_text = f" exit={exit_code}" if exit_code is not None else ""
|
|
287
|
+
failed = " failed=true" if item.get("failed") else ""
|
|
288
|
+
return f"{prefix}{date} {item.get('event_type') or 'unknown'} {tool}{exit_text}{failed} {subject}".rstrip()
|
agentdir/secrets.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .envelope import envelope_bytes, parse_envelope
|
|
10
|
+
from .index import rebuild_index
|
|
11
|
+
from .mailbox import fsync_directory, iter_records
|
|
12
|
+
from .redaction import redact_text, secret_labels
|
|
13
|
+
from .store import is_mailbox, require_root
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class SecretFinding:
|
|
18
|
+
path: str
|
|
19
|
+
mailbox: str
|
|
20
|
+
message_id: str | None
|
|
21
|
+
event_type: str | None
|
|
22
|
+
subject: str | None
|
|
23
|
+
labels: tuple[str, ...]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class SecretRedaction:
|
|
28
|
+
path: str
|
|
29
|
+
labels: tuple[str, ...]
|
|
30
|
+
replacements: int
|
|
31
|
+
applied: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def scan_secret_records(root: str | Path) -> list[SecretFinding]:
|
|
35
|
+
paths = require_root(root)
|
|
36
|
+
findings: list[SecretFinding] = []
|
|
37
|
+
for mailbox in _all_mailboxes(paths.root):
|
|
38
|
+
for record in iter_records(mailbox):
|
|
39
|
+
parsed = parse_envelope(record.path)
|
|
40
|
+
labels = secret_labels(parsed.body_text)
|
|
41
|
+
if not labels:
|
|
42
|
+
continue
|
|
43
|
+
findings.append(
|
|
44
|
+
SecretFinding(
|
|
45
|
+
path=str(record.path.relative_to(paths.root)),
|
|
46
|
+
mailbox=str(mailbox.relative_to(paths.root)),
|
|
47
|
+
message_id=parsed.message_id,
|
|
48
|
+
event_type=parsed.message.get("X-AgentDir-Event-Type"),
|
|
49
|
+
subject=parsed.message.get("Subject"),
|
|
50
|
+
labels=labels,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return findings
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def redact_secret_records(root: str | Path, *, apply: bool = False) -> dict[str, Any]:
|
|
57
|
+
paths = require_root(root)
|
|
58
|
+
redactions: list[SecretRedaction] = []
|
|
59
|
+
for mailbox in _all_mailboxes(paths.root):
|
|
60
|
+
for record in iter_records(mailbox):
|
|
61
|
+
parsed = parse_envelope(record.path)
|
|
62
|
+
result = redact_text(parsed.body_text)
|
|
63
|
+
if result.replacements == 0:
|
|
64
|
+
continue
|
|
65
|
+
if apply:
|
|
66
|
+
_rewrite_body(record.path, parsed.message, result.text, result.labels, result.replacements)
|
|
67
|
+
redactions.append(
|
|
68
|
+
SecretRedaction(
|
|
69
|
+
path=str(record.path.relative_to(paths.root)),
|
|
70
|
+
labels=result.labels,
|
|
71
|
+
replacements=result.replacements,
|
|
72
|
+
applied=apply,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
if apply and redactions:
|
|
76
|
+
rebuild_index(paths.root)
|
|
77
|
+
return {
|
|
78
|
+
"applied": apply,
|
|
79
|
+
"count": len(redactions),
|
|
80
|
+
"redactions": [asdict(item) for item in redactions],
|
|
81
|
+
"index_rebuilt": bool(apply and redactions),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def format_secret_findings(findings: list[SecretFinding]) -> str:
|
|
86
|
+
if not findings:
|
|
87
|
+
return "No secret-like envelope bodies found."
|
|
88
|
+
lines = [f"Found {len(findings)} secret-like envelope body/bodies:"]
|
|
89
|
+
for finding in findings:
|
|
90
|
+
labels = ",".join(finding.labels)
|
|
91
|
+
event = finding.event_type or "unknown"
|
|
92
|
+
lines.append(f"- {finding.path} [{event}] labels={labels}")
|
|
93
|
+
lines.append("No body text was printed.")
|
|
94
|
+
return "\n".join(lines)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def format_secret_redaction(result: dict[str, Any]) -> str:
|
|
98
|
+
count = int(result["count"])
|
|
99
|
+
if count == 0:
|
|
100
|
+
return "No secret-like envelope bodies found."
|
|
101
|
+
action = "Redacted" if result["applied"] else "Would redact"
|
|
102
|
+
lines = [f"{action} {count} secret-like envelope body/bodies:"]
|
|
103
|
+
for item in result["redactions"]:
|
|
104
|
+
labels = ",".join(item["labels"])
|
|
105
|
+
lines.append(f"- {item['path']} labels={labels} replacements={item['replacements']}")
|
|
106
|
+
if result["applied"]:
|
|
107
|
+
lines.append("Rebuilt the derived SQLite index after redaction.")
|
|
108
|
+
else:
|
|
109
|
+
lines.append("Dry run only. Re-run with --apply to rewrite bodies and rebuild the index.")
|
|
110
|
+
lines.append("No body text was printed.")
|
|
111
|
+
return "\n".join(lines)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _all_mailboxes(root: Path) -> list[Path]:
|
|
115
|
+
return sorted(path for path in root.glob("**/Maildir") if is_mailbox(path))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _rewrite_body(
|
|
119
|
+
path: Path,
|
|
120
|
+
message: Any,
|
|
121
|
+
body: str,
|
|
122
|
+
labels: tuple[str, ...],
|
|
123
|
+
replacements: int,
|
|
124
|
+
) -> None:
|
|
125
|
+
message.set_content(body, subtype="plain", charset="utf-8")
|
|
126
|
+
_replace_header(message, "X-AgentDir-Secret-Redacted", "true")
|
|
127
|
+
_replace_header(message, "X-AgentDir-Secret-Redacted-At", datetime.now(UTC).isoformat())
|
|
128
|
+
_replace_header(message, "X-AgentDir-Secret-Redaction-Labels", ",".join(labels))
|
|
129
|
+
_replace_header(message, "X-AgentDir-Secret-Redaction-Replacements", str(replacements))
|
|
130
|
+
_atomic_replace(path, envelope_bytes(message))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _replace_header(message: Any, name: str, value: str) -> None:
|
|
134
|
+
while name in message:
|
|
135
|
+
del message[name]
|
|
136
|
+
message[name] = value
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _atomic_replace(path: Path, data: bytes) -> None:
|
|
140
|
+
temp = path.with_name(f".{path.name}.agentdir-redact-tmp")
|
|
141
|
+
if temp.exists():
|
|
142
|
+
temp.unlink()
|
|
143
|
+
mode = path.stat().st_mode
|
|
144
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
145
|
+
try:
|
|
146
|
+
with os.fdopen(fd, "wb") as handle:
|
|
147
|
+
handle.write(data)
|
|
148
|
+
handle.flush()
|
|
149
|
+
os.fsync(handle.fileno())
|
|
150
|
+
os.chmod(temp, mode)
|
|
151
|
+
os.replace(temp, path)
|
|
152
|
+
fsync_directory(path.parent)
|
|
153
|
+
except Exception:
|
|
154
|
+
try:
|
|
155
|
+
temp.unlink()
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
pass
|
|
158
|
+
raise
|
agentdir/sessions.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .events import emit_event
|
|
10
|
+
from .git import git_head, workspace_name
|
|
11
|
+
from .store import AgentDirError, init_root, paths_for, validate_id
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class SessionState:
|
|
16
|
+
session_id: str
|
|
17
|
+
title: str
|
|
18
|
+
actor: str
|
|
19
|
+
workspace: str
|
|
20
|
+
git_head: str | None
|
|
21
|
+
started_at: str
|
|
22
|
+
status: str = "active"
|
|
23
|
+
ended_at: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def now_iso() -> str:
|
|
27
|
+
return datetime.now(UTC).isoformat()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def safe_session_id(value: str) -> str:
|
|
31
|
+
safe = "".join(ch if ch.isalnum() or ch in "._@+-" else "-" for ch in value.strip())
|
|
32
|
+
safe = safe.strip(".-") or "agent-session"
|
|
33
|
+
return validate_id(safe[:128], "session id")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_session_id(cwd: str | Path | None = None) -> str:
|
|
37
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
38
|
+
suffix = f"{time.time_ns() % 1_000_000_000:09d}"
|
|
39
|
+
return safe_session_id(f"repo-{workspace_name(cwd)}-{stamp}-{suffix}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def current_session_path(root: str | Path) -> Path:
|
|
43
|
+
return paths_for(root).state / "current-session.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def last_session_path(root: str | Path) -> Path:
|
|
47
|
+
return paths_for(root).state / "last-session.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_session_state(root: str | Path, state: SessionState) -> None:
|
|
51
|
+
paths = init_root(root)
|
|
52
|
+
paths.state.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
current_session_path(root).write_text(json.dumps(asdict(state), indent=2) + "\n", encoding="utf-8")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def read_current_session(root: str | Path) -> SessionState | None:
|
|
57
|
+
path = current_session_path(root)
|
|
58
|
+
if not path.is_file():
|
|
59
|
+
return None
|
|
60
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
61
|
+
return SessionState(**payload)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def require_current_session(root: str | Path) -> SessionState:
|
|
65
|
+
state = read_current_session(root)
|
|
66
|
+
if state is None or state.status != "active":
|
|
67
|
+
raise AgentDirError("No active AgentDir session; run: agentdir session ensure")
|
|
68
|
+
return state
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def ensure_session(
|
|
72
|
+
root: str | Path,
|
|
73
|
+
session_id: str | None = None,
|
|
74
|
+
*,
|
|
75
|
+
title: str | None = None,
|
|
76
|
+
actor: str = "agent",
|
|
77
|
+
emit_started: bool = True,
|
|
78
|
+
) -> SessionState:
|
|
79
|
+
if session_id:
|
|
80
|
+
current = read_current_session(root)
|
|
81
|
+
if current and current.session_id == session_id and current.status == "active":
|
|
82
|
+
return current
|
|
83
|
+
return start_session(root, session_id=session_id, title=title, actor=actor, emit_started=emit_started)
|
|
84
|
+
current = read_current_session(root)
|
|
85
|
+
if current and current.status == "active":
|
|
86
|
+
return current
|
|
87
|
+
return start_session(root, title=title or "AgentDir auto session", actor=actor, emit_started=emit_started)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def start_session(
|
|
91
|
+
root: str | Path,
|
|
92
|
+
*,
|
|
93
|
+
session_id: str | None = None,
|
|
94
|
+
title: str | None = None,
|
|
95
|
+
actor: str = "agent",
|
|
96
|
+
note: str | None = None,
|
|
97
|
+
cwd: str | Path | None = None,
|
|
98
|
+
emit_started: bool = True,
|
|
99
|
+
) -> SessionState:
|
|
100
|
+
resolved_id = safe_session_id(session_id) if session_id else default_session_id(cwd)
|
|
101
|
+
state = SessionState(
|
|
102
|
+
session_id=resolved_id,
|
|
103
|
+
title=title or resolved_id,
|
|
104
|
+
actor=actor,
|
|
105
|
+
workspace=workspace_name(cwd),
|
|
106
|
+
git_head=git_head(cwd),
|
|
107
|
+
started_at=now_iso(),
|
|
108
|
+
)
|
|
109
|
+
write_session_state(root, state)
|
|
110
|
+
if emit_started:
|
|
111
|
+
body = "\n".join(
|
|
112
|
+
[
|
|
113
|
+
f"session_id={state.session_id}",
|
|
114
|
+
f"title={state.title}",
|
|
115
|
+
f"actor={state.actor}",
|
|
116
|
+
f"workspace={state.workspace}",
|
|
117
|
+
f"git_head={state.git_head or ''}",
|
|
118
|
+
f"started_at={state.started_at}",
|
|
119
|
+
"",
|
|
120
|
+
note or "AgentDir session started.",
|
|
121
|
+
]
|
|
122
|
+
)
|
|
123
|
+
emit_event(
|
|
124
|
+
root,
|
|
125
|
+
session_id=state.session_id,
|
|
126
|
+
event_type="session.started",
|
|
127
|
+
body=body,
|
|
128
|
+
subject=state.title,
|
|
129
|
+
from_actor=actor,
|
|
130
|
+
workspace=state.workspace,
|
|
131
|
+
git_head=state.git_head,
|
|
132
|
+
)
|
|
133
|
+
return state
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def end_session(
|
|
137
|
+
root: str | Path,
|
|
138
|
+
*,
|
|
139
|
+
status: str = "completed",
|
|
140
|
+
summary: str | None = None,
|
|
141
|
+
actor: str = "agent",
|
|
142
|
+
) -> SessionState:
|
|
143
|
+
state = require_current_session(root)
|
|
144
|
+
state.status = status
|
|
145
|
+
state.ended_at = now_iso()
|
|
146
|
+
body = "\n".join(
|
|
147
|
+
[
|
|
148
|
+
f"session_id={state.session_id}",
|
|
149
|
+
f"status={state.status}",
|
|
150
|
+
f"ended_at={state.ended_at}",
|
|
151
|
+
"",
|
|
152
|
+
summary or "AgentDir session ended.",
|
|
153
|
+
]
|
|
154
|
+
)
|
|
155
|
+
emit_event(
|
|
156
|
+
root,
|
|
157
|
+
session_id=state.session_id,
|
|
158
|
+
event_type="session.ended",
|
|
159
|
+
body=body,
|
|
160
|
+
subject=f"session {status}",
|
|
161
|
+
from_actor=actor,
|
|
162
|
+
workspace=state.workspace,
|
|
163
|
+
git_head=git_head(),
|
|
164
|
+
)
|
|
165
|
+
paths = init_root(root)
|
|
166
|
+
last_session_path(root).write_text(json.dumps(asdict(state), indent=2) + "\n", encoding="utf-8")
|
|
167
|
+
current = current_session_path(root)
|
|
168
|
+
if current.exists():
|
|
169
|
+
current.unlink()
|
|
170
|
+
paths.state.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
return state
|