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/federation.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .index import rebuild_index
|
|
11
|
+
from .memory import DEFAULT_MIN_SCORE, RETRIEVAL_HYBRID, search_memory
|
|
12
|
+
from .rendering import rich_root_diagnostics
|
|
13
|
+
from .store import AgentDirError, paths_for, require_root, validate_id
|
|
14
|
+
|
|
15
|
+
ROOT_REGISTRY_FILE = "registered-roots.json"
|
|
16
|
+
VISIBILITY_CHOICES = ("private", "team", "machine")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register_root(
|
|
20
|
+
controller_root: str | Path,
|
|
21
|
+
root: str | Path,
|
|
22
|
+
*,
|
|
23
|
+
name: str | None = None,
|
|
24
|
+
visibility: str = "private",
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
if visibility not in VISIBILITY_CHOICES:
|
|
27
|
+
raise AgentDirError(
|
|
28
|
+
f"Invalid visibility {visibility!r}; expected one of {', '.join(VISIBILITY_CHOICES)}"
|
|
29
|
+
)
|
|
30
|
+
controller = require_root(controller_root)
|
|
31
|
+
source_root = resolve_registered_root(root)
|
|
32
|
+
root_id = root_id_for_path(source_root)
|
|
33
|
+
registry = _read_registry(controller.root)
|
|
34
|
+
entry = {
|
|
35
|
+
"root_id": root_id,
|
|
36
|
+
"name": name or default_root_name(source_root),
|
|
37
|
+
"root_path": str(source_root),
|
|
38
|
+
"visibility": visibility,
|
|
39
|
+
"registered_at": now_iso(),
|
|
40
|
+
}
|
|
41
|
+
roots = [item for item in registry["roots"] if item["root_id"] != root_id]
|
|
42
|
+
roots.append(entry)
|
|
43
|
+
registry["roots"] = sorted(roots, key=lambda item: (item["name"], item["root_id"]))
|
|
44
|
+
_write_registry(controller.root, registry)
|
|
45
|
+
return entry
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def list_registered_roots(controller_root: str | Path, *, group: str | None = None) -> list[dict[str, Any]]:
|
|
49
|
+
controller = require_root(controller_root)
|
|
50
|
+
registry = _read_registry(controller.root)
|
|
51
|
+
roots = registry["roots"]
|
|
52
|
+
if group:
|
|
53
|
+
wanted = set(_root_ids_for_group(registry, group))
|
|
54
|
+
roots = [root for root in roots if root["root_id"] in wanted]
|
|
55
|
+
return [{**root, "available": Path(root["root_path"]).joinpath("VERSION").is_file()} for root in roots]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def remove_registered_root(controller_root: str | Path, identifier: str) -> dict[str, Any]:
|
|
59
|
+
controller = require_root(controller_root)
|
|
60
|
+
registry = _read_registry(controller.root)
|
|
61
|
+
remaining: list[dict[str, Any]] = []
|
|
62
|
+
removed: dict[str, Any] | None = None
|
|
63
|
+
for root in registry["roots"]:
|
|
64
|
+
if identifier in {root["root_id"], root["name"], root["root_path"]}:
|
|
65
|
+
removed = root
|
|
66
|
+
else:
|
|
67
|
+
remaining.append(root)
|
|
68
|
+
if removed is None:
|
|
69
|
+
raise AgentDirError(f"Unknown registered root: {identifier}")
|
|
70
|
+
registry["roots"] = remaining
|
|
71
|
+
_write_registry(controller.root, registry)
|
|
72
|
+
return removed
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def rebuild_registered_roots(
|
|
76
|
+
controller_root: str | Path,
|
|
77
|
+
*,
|
|
78
|
+
group: str | None = None,
|
|
79
|
+
stale_only: bool = False,
|
|
80
|
+
) -> list[dict[str, Any]]:
|
|
81
|
+
results: list[dict[str, Any]] = []
|
|
82
|
+
stale_ids = {
|
|
83
|
+
item["root_id"]
|
|
84
|
+
for item in doctor_registered_roots(controller_root, group=group)
|
|
85
|
+
if item.get("stale")
|
|
86
|
+
} if stale_only else set()
|
|
87
|
+
for root in list_registered_roots(controller_root, group=group):
|
|
88
|
+
item = {**root}
|
|
89
|
+
if stale_only and root["root_id"] not in stale_ids:
|
|
90
|
+
item["ok"] = True
|
|
91
|
+
item["skipped"] = True
|
|
92
|
+
item["reason"] = "fresh"
|
|
93
|
+
results.append(item)
|
|
94
|
+
continue
|
|
95
|
+
if not root["available"]:
|
|
96
|
+
item["ok"] = False
|
|
97
|
+
item["error"] = "missing root"
|
|
98
|
+
results.append(item)
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
result = rebuild_index(root["root_path"])
|
|
102
|
+
except AgentDirError as exc:
|
|
103
|
+
item["ok"] = False
|
|
104
|
+
item["error"] = str(exc)
|
|
105
|
+
else:
|
|
106
|
+
item["ok"] = True
|
|
107
|
+
item["indexed"] = result.indexed
|
|
108
|
+
item["malformed"] = result.malformed
|
|
109
|
+
item["duplicates"] = result.duplicates
|
|
110
|
+
results.append(item)
|
|
111
|
+
return results
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def search_federated_memory(
|
|
115
|
+
controller_root: str | Path,
|
|
116
|
+
query: str,
|
|
117
|
+
*,
|
|
118
|
+
session_id: str | None = None,
|
|
119
|
+
event_type: str | None = None,
|
|
120
|
+
actor: str | None = None,
|
|
121
|
+
task_id: str | None = None,
|
|
122
|
+
tool: str | None = None,
|
|
123
|
+
git_head: str | None = None,
|
|
124
|
+
workspace: str | None = None,
|
|
125
|
+
since: str | None = None,
|
|
126
|
+
until: str | None = None,
|
|
127
|
+
limit: int = 10,
|
|
128
|
+
min_score: float = DEFAULT_MIN_SCORE,
|
|
129
|
+
retrieval_mode: str = RETRIEVAL_HYBRID,
|
|
130
|
+
rebuild: bool = True,
|
|
131
|
+
group: str | None = None,
|
|
132
|
+
) -> list[dict[str, Any]]:
|
|
133
|
+
roots = list_registered_roots(controller_root, group=group)
|
|
134
|
+
if not roots:
|
|
135
|
+
if group:
|
|
136
|
+
raise AgentDirError(f"No registered roots in group {group!r}")
|
|
137
|
+
raise AgentDirError("No registered roots; run agentdir roots register <root>")
|
|
138
|
+
|
|
139
|
+
per_root_limit = max(limit, 5)
|
|
140
|
+
hits: list[dict[str, Any]] = []
|
|
141
|
+
for root in roots:
|
|
142
|
+
root_path = Path(root["root_path"])
|
|
143
|
+
if not root["available"]:
|
|
144
|
+
continue
|
|
145
|
+
if rebuild:
|
|
146
|
+
rebuild_index(root_path)
|
|
147
|
+
for row in search_memory(
|
|
148
|
+
root_path,
|
|
149
|
+
query,
|
|
150
|
+
session_id=session_id,
|
|
151
|
+
event_type=event_type,
|
|
152
|
+
actor=actor,
|
|
153
|
+
task_id=task_id,
|
|
154
|
+
tool=tool,
|
|
155
|
+
git_head=git_head,
|
|
156
|
+
workspace=workspace,
|
|
157
|
+
since=since,
|
|
158
|
+
until=until,
|
|
159
|
+
limit=per_root_limit,
|
|
160
|
+
min_score=min_score,
|
|
161
|
+
retrieval_mode=retrieval_mode,
|
|
162
|
+
):
|
|
163
|
+
hits.append(_federated_row(root, row))
|
|
164
|
+
|
|
165
|
+
hits.sort(
|
|
166
|
+
key=lambda row: (
|
|
167
|
+
-float(row.get("memory_score") or 0),
|
|
168
|
+
row.get("date_utc") or row.get("indexed_at") or "",
|
|
169
|
+
row.get("source_id") or "",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return hits[:limit]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def suggest_roots(controller_root: str | Path, *, near: str | Path | None = None) -> list[dict[str, Any]]:
|
|
176
|
+
controller = require_root(controller_root)
|
|
177
|
+
registry = _read_registry(controller.root)
|
|
178
|
+
registered_ids = {root["root_id"] for root in registry["roots"]}
|
|
179
|
+
start = Path(near).expanduser().resolve() if near else Path.cwd().resolve()
|
|
180
|
+
candidates: set[Path] = set()
|
|
181
|
+
for candidate in _nearby_root_candidates(start):
|
|
182
|
+
resolved = _candidate_agentdir_root(candidate)
|
|
183
|
+
if resolved:
|
|
184
|
+
candidates.add(resolved)
|
|
185
|
+
suggestions: list[dict[str, Any]] = []
|
|
186
|
+
for root in sorted(candidates, key=lambda path: str(path)):
|
|
187
|
+
root_id = root_id_for_path(root)
|
|
188
|
+
project_path = root.parent if root.name == ".agentdir" else root
|
|
189
|
+
suggestions.append(
|
|
190
|
+
{
|
|
191
|
+
"root_id": root_id,
|
|
192
|
+
"name": default_root_name(root),
|
|
193
|
+
"root_path": str(root),
|
|
194
|
+
"project_path": str(project_path),
|
|
195
|
+
"registered": root_id in registered_ids,
|
|
196
|
+
"available": True,
|
|
197
|
+
"git_remote": _git_remote(project_path),
|
|
198
|
+
"last_indexed_at": _index_mtime(root),
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
return suggestions
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def doctor_registered_roots(controller_root: str | Path, *, group: str | None = None) -> list[dict[str, Any]]:
|
|
205
|
+
diagnostics: list[dict[str, Any]] = []
|
|
206
|
+
for root in list_registered_roots(controller_root, group=group):
|
|
207
|
+
item = {**root, "ok": True, "errors": [], "warnings": []}
|
|
208
|
+
root_path = Path(root["root_path"])
|
|
209
|
+
if not root["available"]:
|
|
210
|
+
item["ok"] = False
|
|
211
|
+
item["errors"].append("missing root")
|
|
212
|
+
item["index_exists"] = False
|
|
213
|
+
item["stale"] = True
|
|
214
|
+
diagnostics.append(item)
|
|
215
|
+
continue
|
|
216
|
+
paths = paths_for(root_path)
|
|
217
|
+
index_path = paths.index_path
|
|
218
|
+
latest_record_mtime = _latest_record_mtime(paths.root)
|
|
219
|
+
index_mtime = index_path.stat().st_mtime if index_path.is_file() else None
|
|
220
|
+
stale = index_mtime is None or (
|
|
221
|
+
latest_record_mtime is not None and latest_record_mtime > index_mtime
|
|
222
|
+
)
|
|
223
|
+
item.update(
|
|
224
|
+
{
|
|
225
|
+
"index_exists": index_path.is_file(),
|
|
226
|
+
"index_path": str(index_path),
|
|
227
|
+
"index_mtime": index_mtime,
|
|
228
|
+
"latest_record_mtime": latest_record_mtime,
|
|
229
|
+
"stale": stale,
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
if not index_path.is_file():
|
|
233
|
+
item["warnings"].append("index missing")
|
|
234
|
+
elif stale:
|
|
235
|
+
item["warnings"].append("index stale")
|
|
236
|
+
diagnostics.append(item)
|
|
237
|
+
return diagnostics
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def create_root_group(controller_root: str | Path, name: str, root_ids: list[str]) -> dict[str, Any]:
|
|
241
|
+
controller = require_root(controller_root)
|
|
242
|
+
validate_id(name, "root group")
|
|
243
|
+
registry = _read_registry(controller.root)
|
|
244
|
+
normalized = _validate_registered_root_ids(registry, root_ids)
|
|
245
|
+
if any(group["name"] == name for group in registry["groups"]):
|
|
246
|
+
raise AgentDirError(f"Root group already exists: {name}")
|
|
247
|
+
now = now_iso()
|
|
248
|
+
group = {"name": name, "root_ids": normalized, "created_at": now, "updated_at": now}
|
|
249
|
+
registry["groups"].append(group)
|
|
250
|
+
registry["groups"] = sorted(registry["groups"], key=lambda item: item["name"])
|
|
251
|
+
_write_registry(controller.root, registry)
|
|
252
|
+
return group
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def list_root_groups(controller_root: str | Path) -> list[dict[str, Any]]:
|
|
256
|
+
controller = require_root(controller_root)
|
|
257
|
+
registry = _read_registry(controller.root)
|
|
258
|
+
roots_by_id = {root["root_id"]: root for root in registry["roots"]}
|
|
259
|
+
groups: list[dict[str, Any]] = []
|
|
260
|
+
for group in registry["groups"]:
|
|
261
|
+
groups.append(
|
|
262
|
+
{
|
|
263
|
+
**group,
|
|
264
|
+
"roots": [
|
|
265
|
+
roots_by_id[root_id]
|
|
266
|
+
for root_id in group["root_ids"]
|
|
267
|
+
if root_id in roots_by_id
|
|
268
|
+
],
|
|
269
|
+
"missing_root_ids": [
|
|
270
|
+
root_id for root_id in group["root_ids"] if root_id not in roots_by_id
|
|
271
|
+
],
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
return groups
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def add_root_to_group(controller_root: str | Path, name: str, root_id: str) -> dict[str, Any]:
|
|
278
|
+
controller = require_root(controller_root)
|
|
279
|
+
registry = _read_registry(controller.root)
|
|
280
|
+
_validate_registered_root_ids(registry, [root_id])
|
|
281
|
+
group = _group_entry(registry, name)
|
|
282
|
+
if root_id not in group["root_ids"]:
|
|
283
|
+
group["root_ids"].append(root_id)
|
|
284
|
+
group["root_ids"].sort()
|
|
285
|
+
group["updated_at"] = now_iso()
|
|
286
|
+
_write_registry(controller.root, registry)
|
|
287
|
+
return group
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def remove_root_from_group(controller_root: str | Path, name: str, root_id: str) -> dict[str, Any]:
|
|
291
|
+
controller = require_root(controller_root)
|
|
292
|
+
registry = _read_registry(controller.root)
|
|
293
|
+
group = _group_entry(registry, name)
|
|
294
|
+
group["root_ids"] = [candidate for candidate in group["root_ids"] if candidate != root_id]
|
|
295
|
+
group["updated_at"] = now_iso()
|
|
296
|
+
_write_registry(controller.root, registry)
|
|
297
|
+
return group
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def format_registered_roots(roots: list[dict[str, Any]]) -> str:
|
|
301
|
+
if not roots:
|
|
302
|
+
return "No registered roots.\n"
|
|
303
|
+
lines: list[str] = []
|
|
304
|
+
for root in roots:
|
|
305
|
+
status = "available" if root["available"] else "missing"
|
|
306
|
+
lines.append(
|
|
307
|
+
f"{root['root_id']} {root['name']} visibility={root['visibility']} "
|
|
308
|
+
f"status={status} {root['root_path']}"
|
|
309
|
+
)
|
|
310
|
+
return "\n".join(lines) + "\n"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def format_root_suggestions(suggestions: list[dict[str, Any]]) -> str:
|
|
314
|
+
if not suggestions:
|
|
315
|
+
return "No AgentDir roots found.\n"
|
|
316
|
+
lines: list[str] = []
|
|
317
|
+
for suggestion in suggestions:
|
|
318
|
+
state = "registered" if suggestion["registered"] else "unregistered"
|
|
319
|
+
remote = f" remote={suggestion['git_remote']}" if suggestion.get("git_remote") else ""
|
|
320
|
+
lines.append(
|
|
321
|
+
f"{suggestion['root_id']} {suggestion['name']} {state} "
|
|
322
|
+
f"{suggestion['root_path']}{remote}"
|
|
323
|
+
)
|
|
324
|
+
return "\n".join(lines) + "\n"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def format_root_diagnostics(rows: list[dict[str, Any]]) -> str:
|
|
328
|
+
rendered = rich_root_diagnostics(rows)
|
|
329
|
+
if rendered is not None:
|
|
330
|
+
return rendered
|
|
331
|
+
if not rows:
|
|
332
|
+
return "No registered roots.\n"
|
|
333
|
+
lines: list[str] = []
|
|
334
|
+
for row in rows:
|
|
335
|
+
status = "ok" if row["ok"] else "error"
|
|
336
|
+
stale = "stale" if row.get("stale") else "fresh"
|
|
337
|
+
details = "; ".join([*row.get("errors", []), *row.get("warnings", [])])
|
|
338
|
+
suffix = f" {details}" if details else ""
|
|
339
|
+
lines.append(f"{row['root_id']} {row['name']} {status} {stale} {row['root_path']}{suffix}")
|
|
340
|
+
return "\n".join(lines) + "\n"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def format_root_groups(groups: list[dict[str, Any]]) -> str:
|
|
344
|
+
if not groups:
|
|
345
|
+
return "No root groups.\n"
|
|
346
|
+
lines: list[str] = []
|
|
347
|
+
for group in groups:
|
|
348
|
+
lines.append(f"{group['name']} roots={len(group['root_ids'])} {','.join(group['root_ids'])}")
|
|
349
|
+
return "\n".join(lines) + "\n"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def format_federated_hits(rows: list[dict[str, Any]]) -> str:
|
|
353
|
+
lines: list[str] = []
|
|
354
|
+
for row in rows:
|
|
355
|
+
body = (row.get("body_text") or "").strip().replace("\n", "\\n")
|
|
356
|
+
if len(body) > 220:
|
|
357
|
+
body = body[:217] + "..."
|
|
358
|
+
lines.append(
|
|
359
|
+
f"{row.get('memory_score'):.3f} "
|
|
360
|
+
f"root={row.get('source_root_name')} "
|
|
361
|
+
f"{row.get('event_type') or 'unknown'} "
|
|
362
|
+
f"{row.get('subject') or ''} "
|
|
363
|
+
f"session={row.get('session_id') or ''} "
|
|
364
|
+
f"{body} "
|
|
365
|
+
f"{row.get('source_file_path') or ''}"
|
|
366
|
+
)
|
|
367
|
+
return "\n".join(lines)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def resolve_registered_root(root: str | Path) -> Path:
|
|
371
|
+
candidate = Path(root).expanduser().resolve()
|
|
372
|
+
if (candidate / "VERSION").is_file():
|
|
373
|
+
require_root(candidate)
|
|
374
|
+
return candidate
|
|
375
|
+
nested = candidate / ".agentdir"
|
|
376
|
+
if (nested / "VERSION").is_file():
|
|
377
|
+
require_root(nested)
|
|
378
|
+
return nested
|
|
379
|
+
raise AgentDirError(f"Not an AgentDir root or project with .agentdir: {candidate}")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def root_id_for_path(root: Path) -> str:
|
|
383
|
+
digest = hashlib.sha256(str(root).encode("utf-8")).hexdigest()
|
|
384
|
+
return f"root-{digest[:12]}"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def default_root_name(root: Path) -> str:
|
|
388
|
+
if root.name == ".agentdir" and root.parent.name:
|
|
389
|
+
return root.parent.name
|
|
390
|
+
return root.name
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def now_iso() -> str:
|
|
394
|
+
return datetime.now(UTC).isoformat()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _registry_path(root: str | Path) -> Path:
|
|
398
|
+
return paths_for(root).state / ROOT_REGISTRY_FILE
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _read_registry(root: str | Path) -> dict[str, Any]:
|
|
402
|
+
path = _registry_path(root)
|
|
403
|
+
if not path.exists():
|
|
404
|
+
return {"version": 1, "roots": [], "groups": []}
|
|
405
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
406
|
+
if data.get("version") != 1:
|
|
407
|
+
raise AgentDirError(f"Unsupported root registry version: {data.get('version')}")
|
|
408
|
+
data.setdefault("roots", [])
|
|
409
|
+
data.setdefault("groups", [])
|
|
410
|
+
return data
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _write_registry(root: str | Path, registry: dict[str, Any]) -> None:
|
|
414
|
+
path = _registry_path(root)
|
|
415
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
416
|
+
path.write_text(json.dumps(registry, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _federated_row(root: dict[str, Any], row: dict[str, Any]) -> dict[str, Any]:
|
|
420
|
+
original_source_id = str(row.get("source_id") or "")
|
|
421
|
+
federated_source_id = f"{root['root_id']}:{original_source_id}"
|
|
422
|
+
body_text = _excerpt(row.get("body_text") or "", 500)
|
|
423
|
+
return {
|
|
424
|
+
**row,
|
|
425
|
+
"body_text": body_text,
|
|
426
|
+
"body_text_truncated": body_text != (row.get("body_text") or ""),
|
|
427
|
+
"source_root_id": root["root_id"],
|
|
428
|
+
"source_root_name": root["name"],
|
|
429
|
+
"source_root_path": root["root_path"],
|
|
430
|
+
"source_root_visibility": root["visibility"],
|
|
431
|
+
"source_id_original": original_source_id,
|
|
432
|
+
"source_file_path": row.get("file_path"),
|
|
433
|
+
"source_id": federated_source_id,
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _excerpt(text: str, limit: int) -> str:
|
|
438
|
+
collapsed = " ".join(text.strip().split())
|
|
439
|
+
if len(collapsed) <= limit:
|
|
440
|
+
return collapsed
|
|
441
|
+
return collapsed[: limit - 3] + "..."
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _root_ids_for_group(registry: dict[str, Any], name: str) -> list[str]:
|
|
445
|
+
return list(_group_entry(registry, name)["root_ids"])
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _group_entry(registry: dict[str, Any], name: str) -> dict[str, Any]:
|
|
449
|
+
for group in registry["groups"]:
|
|
450
|
+
if group["name"] == name:
|
|
451
|
+
return group
|
|
452
|
+
raise AgentDirError(f"Unknown root group: {name}")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _validate_registered_root_ids(registry: dict[str, Any], root_ids: list[str]) -> list[str]:
|
|
456
|
+
registered = {root["root_id"] for root in registry["roots"]}
|
|
457
|
+
normalized: list[str] = []
|
|
458
|
+
for root_id in root_ids:
|
|
459
|
+
validate_id(root_id, "root id")
|
|
460
|
+
if root_id not in registered:
|
|
461
|
+
raise AgentDirError(f"Unknown registered root: {root_id}")
|
|
462
|
+
if root_id not in normalized:
|
|
463
|
+
normalized.append(root_id)
|
|
464
|
+
return normalized
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _candidate_agentdir_root(candidate: Path) -> Path | None:
|
|
468
|
+
if (candidate / "VERSION").is_file():
|
|
469
|
+
return candidate.resolve()
|
|
470
|
+
nested = candidate / ".agentdir"
|
|
471
|
+
if (nested / "VERSION").is_file():
|
|
472
|
+
return nested.resolve()
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _nearby_root_candidates(start: Path) -> list[Path]:
|
|
477
|
+
candidates = [start]
|
|
478
|
+
if start.parent != start:
|
|
479
|
+
candidates.append(start.parent)
|
|
480
|
+
try:
|
|
481
|
+
candidates.extend(path for path in start.parent.iterdir() if path.is_dir())
|
|
482
|
+
except OSError:
|
|
483
|
+
pass
|
|
484
|
+
try:
|
|
485
|
+
candidates.extend(path for path in start.iterdir() if path.is_dir())
|
|
486
|
+
except OSError:
|
|
487
|
+
pass
|
|
488
|
+
seen: set[Path] = set()
|
|
489
|
+
unique: list[Path] = []
|
|
490
|
+
for candidate in candidates:
|
|
491
|
+
resolved = candidate.resolve()
|
|
492
|
+
if resolved in seen:
|
|
493
|
+
continue
|
|
494
|
+
seen.add(resolved)
|
|
495
|
+
unique.append(resolved)
|
|
496
|
+
return unique
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _git_remote(project_path: Path) -> str | None:
|
|
500
|
+
try:
|
|
501
|
+
result = subprocess.run(
|
|
502
|
+
["git", "remote", "get-url", "origin"],
|
|
503
|
+
cwd=project_path,
|
|
504
|
+
text=True,
|
|
505
|
+
capture_output=True,
|
|
506
|
+
check=False,
|
|
507
|
+
)
|
|
508
|
+
except OSError:
|
|
509
|
+
return None
|
|
510
|
+
if result.returncode != 0:
|
|
511
|
+
return None
|
|
512
|
+
return result.stdout.strip() or None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _index_mtime(root: Path) -> float | None:
|
|
516
|
+
path = paths_for(root).index_path
|
|
517
|
+
return path.stat().st_mtime if path.is_file() else None
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _latest_record_mtime(root: Path) -> float | None:
|
|
521
|
+
latest: float | None = None
|
|
522
|
+
for container in ("sessions", "actors", "queues"):
|
|
523
|
+
base = root / container
|
|
524
|
+
if not base.is_dir():
|
|
525
|
+
continue
|
|
526
|
+
for path in base.rglob("*"):
|
|
527
|
+
if path.is_file():
|
|
528
|
+
mtime = path.stat().st_mtime
|
|
529
|
+
latest = mtime if latest is None else max(latest, mtime)
|
|
530
|
+
return latest
|
agentdir/git.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def git_output(args: list[str], cwd: str | Path | None = None) -> str | None:
|
|
8
|
+
try:
|
|
9
|
+
result = subprocess.run(
|
|
10
|
+
["git", *args],
|
|
11
|
+
cwd=Path(cwd).expanduser().resolve() if cwd else None,
|
|
12
|
+
text=True,
|
|
13
|
+
capture_output=True,
|
|
14
|
+
check=False,
|
|
15
|
+
)
|
|
16
|
+
except OSError:
|
|
17
|
+
return None
|
|
18
|
+
if result.returncode != 0:
|
|
19
|
+
return None
|
|
20
|
+
return result.stdout.strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def git_root(cwd: str | Path | None = None) -> Path | None:
|
|
24
|
+
output = git_output(["rev-parse", "--show-toplevel"], cwd)
|
|
25
|
+
return Path(output).resolve() if output else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def git_head(cwd: str | Path | None = None, *, short: bool = False) -> str | None:
|
|
29
|
+
args = ["rev-parse", "--short", "HEAD"] if short else ["rev-parse", "HEAD"]
|
|
30
|
+
return git_output(args, cwd)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def git_branch(cwd: str | Path | None = None) -> str | None:
|
|
34
|
+
return git_output(["branch", "--show-current"], cwd)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def git_status_short(cwd: str | Path | None = None) -> str:
|
|
38
|
+
return git_output(["status", "--short"], cwd) or ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def workspace_name(cwd: str | Path | None = None) -> str:
|
|
42
|
+
root = git_root(cwd)
|
|
43
|
+
if root:
|
|
44
|
+
return root.name
|
|
45
|
+
return Path(cwd or Path.cwd()).resolve().name
|
agentdir/gitignore.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .git import git_output, git_root
|
|
8
|
+
|
|
9
|
+
GITIGNORE_CHOICES = ("ask", "project", "user", "none")
|
|
10
|
+
AGENTDIR_IGNORE_PATTERN = ".agentdir/"
|
|
11
|
+
_AGENTDIR_PATTERN_VARIANTS = {".agentdir", ".agentdir/", "/.agentdir", "/.agentdir/"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def gitignore_plan(
|
|
15
|
+
*,
|
|
16
|
+
target: str,
|
|
17
|
+
cwd: str | Path | None = None,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
if target == "ask":
|
|
20
|
+
return {
|
|
21
|
+
"target": "ask",
|
|
22
|
+
"action": "prompt",
|
|
23
|
+
"path": None,
|
|
24
|
+
"pattern": AGENTDIR_IGNORE_PATTERN,
|
|
25
|
+
"would_write": None,
|
|
26
|
+
}
|
|
27
|
+
if target == "none":
|
|
28
|
+
return _skip("none", "selected_none")
|
|
29
|
+
|
|
30
|
+
path = _ignore_path(target, cwd)
|
|
31
|
+
if path is None:
|
|
32
|
+
return _skip(target, "not_in_git_repository")
|
|
33
|
+
exists = path.is_file()
|
|
34
|
+
return {
|
|
35
|
+
"target": target,
|
|
36
|
+
"action": "exists" if _has_agentdir_pattern(path) else "update" if exists else "create",
|
|
37
|
+
"path": str(path),
|
|
38
|
+
"pattern": AGENTDIR_IGNORE_PATTERN,
|
|
39
|
+
"exists": exists,
|
|
40
|
+
"would_write": not _has_agentdir_pattern(path),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ensure_agentdir_ignored(
|
|
45
|
+
*,
|
|
46
|
+
target: str,
|
|
47
|
+
cwd: str | Path | None = None,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
plan = gitignore_plan(target=target, cwd=cwd)
|
|
50
|
+
if not plan.get("would_write"):
|
|
51
|
+
return {**plan, "changed": False}
|
|
52
|
+
|
|
53
|
+
path = Path(str(plan["path"]))
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
_append_pattern(path)
|
|
56
|
+
return {**plan, "changed": True}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def user_gitignore_path() -> Path:
|
|
60
|
+
configured = git_output(["config", "--global", "--get", "core.excludesFile"])
|
|
61
|
+
if configured:
|
|
62
|
+
return Path(configured).expanduser().resolve()
|
|
63
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
|
64
|
+
base = Path(xdg_config).expanduser() if xdg_config else Path.home() / ".config"
|
|
65
|
+
return (base / "git" / "ignore").resolve()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ignore_path(target: str, cwd: str | Path | None) -> Path | None:
|
|
69
|
+
if target == "project":
|
|
70
|
+
root = git_root(cwd)
|
|
71
|
+
return (root / ".gitignore") if root else None
|
|
72
|
+
if target == "user":
|
|
73
|
+
return user_gitignore_path()
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _has_agentdir_pattern(path: Path) -> bool:
|
|
78
|
+
if not path.is_file():
|
|
79
|
+
return False
|
|
80
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
81
|
+
candidate = line.strip()
|
|
82
|
+
if not candidate or candidate.startswith("#"):
|
|
83
|
+
continue
|
|
84
|
+
if candidate in _AGENTDIR_PATTERN_VARIANTS:
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _append_pattern(path: Path) -> None:
|
|
90
|
+
if path.exists():
|
|
91
|
+
text = path.read_text(encoding="utf-8")
|
|
92
|
+
separator = "" if text.endswith(("\n", "\r")) or not text else "\n"
|
|
93
|
+
path.write_text(f"{text}{separator}{AGENTDIR_IGNORE_PATTERN}\n", encoding="utf-8")
|
|
94
|
+
return
|
|
95
|
+
path.write_text(f"{AGENTDIR_IGNORE_PATTERN}\n", encoding="utf-8")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _skip(target: str, reason: str) -> dict[str, Any]:
|
|
99
|
+
return {
|
|
100
|
+
"target": target,
|
|
101
|
+
"action": "skip",
|
|
102
|
+
"path": None,
|
|
103
|
+
"pattern": AGENTDIR_IGNORE_PATTERN,
|
|
104
|
+
"reason": reason,
|
|
105
|
+
"would_write": False,
|
|
106
|
+
}
|