dotagents-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dotagents/AGENTS.md +125 -0
- dotagents/__init__.py +3 -0
- dotagents/__main__.py +6 -0
- dotagents/_agents.py +627 -0
- dotagents/_context.py +311 -0
- dotagents/_env.py +600 -0
- dotagents/_hooks.py +172 -0
- dotagents/_merge.py +122 -0
- dotagents/_overlay/AGENTS.md +31 -0
- dotagents/_overlay/CLAUDE.md +3 -0
- dotagents/_overlay/README.md +41 -0
- dotagents/_overlay/dotagents/DECISIONS.md +20 -0
- dotagents/_overlay/dotagents/cmds/README.md +46 -0
- dotagents/_overlays.py +412 -0
- dotagents/_resolve.py +81 -0
- dotagents/_scope.py +265 -0
- dotagents/_skills.py +236 -0
- dotagents/_sync.py +72 -0
- dotagents/_wrappers.py +112 -0
- dotagents/cli/__init__.py +354 -0
- dotagents/cli/_common.py +331 -0
- dotagents/cli/build_pyz.py +107 -0
- dotagents/cli/context.py +129 -0
- dotagents/cli/env.py +168 -0
- dotagents/cli/init.py +117 -0
- dotagents/cli/overlays.py +366 -0
- dotagents_cli-0.3.0.dist-info/METADATA +291 -0
- dotagents_cli-0.3.0.dist-info/RECORD +31 -0
- dotagents_cli-0.3.0.dist-info/WHEEL +4 -0
- dotagents_cli-0.3.0.dist-info/entry_points.txt +2 -0
- dotagents_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
dotagents/_context.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Assemble effective context (Plan 04)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from dotagents import _resolve
|
|
10
|
+
from dotagents import _agents
|
|
11
|
+
from dotagents import _overlays
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_overlay_priority(overlay_dir: Path) -> int:
|
|
15
|
+
"""Overlay merge priority (plan 02), read from the manifest.
|
|
16
|
+
|
|
17
|
+
The manifest reader owns priority parsing (`_overlays.read_manifest`), so
|
|
18
|
+
this just consumes its value -- no duplicate ad-hoc regex. Lower sorts
|
|
19
|
+
earlier; the unprioritized default is `_overlays.DEFAULT_PRIORITY` (500)."""
|
|
20
|
+
manifest = _overlays.read_manifest(overlay_dir)
|
|
21
|
+
value = manifest.get("priority", _overlays.DEFAULT_PRIORITY)
|
|
22
|
+
try:
|
|
23
|
+
return int(value) # type: ignore[arg-type]
|
|
24
|
+
except (TypeError, ValueError):
|
|
25
|
+
return _overlays.DEFAULT_PRIORITY
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _expand_placeholders(text: str, project_root: Path, overlay_roots: list[Path]) -> str:
|
|
29
|
+
"""Expand <PROJECT_ROOT> and <OVERLAY_NAME_OVERLAY_ROOT> placeholders."""
|
|
30
|
+
text = text.replace("<PROJECT_ROOT>", str(project_root))
|
|
31
|
+
for ov in overlay_roots:
|
|
32
|
+
env_name = ov.name.upper().replace("-", "_")
|
|
33
|
+
text = text.replace(f"<{env_name}_OVERLAY_ROOT>", str(ov))
|
|
34
|
+
return text
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# A relative path token ending in .md: one or more path segments, no spaces,
|
|
38
|
+
# no leading slash or `~` (absolute paths are already-known files, not on-demand
|
|
39
|
+
# pointers), at least the trailing `.md`. Used for the BARE reference pass.
|
|
40
|
+
_BARE_MD_REF = re.compile(r'(?<![\w`./~-])((?:[\w.-]+/)*[\w.-]+\.md)\b')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _find_md_refs(text: str) -> "list[str]":
|
|
44
|
+
"""Collect on-demand markdown references, both backticked and bare.
|
|
45
|
+
|
|
46
|
+
An `AGENTS.md` says "read kb/X.md before Y" as often bare as backticked, so
|
|
47
|
+
matching only backticks (the original bug) missed the very files the
|
|
48
|
+
generator exists to inline. This catches both. Excluded:
|
|
49
|
+
- the `<!-- Source: ... -->` provenance comments this module emits (they are
|
|
50
|
+
absolute source paths, not on-demand pointers),
|
|
51
|
+
- absolute / home paths (already-loaded, not on-demand),
|
|
52
|
+
- the bare filename `AGENTS.md` with no directory (a harness-walked file, not
|
|
53
|
+
an on-demand pointer -- and it would match every mention of the word).
|
|
54
|
+
Order-preserving, de-duplicated.
|
|
55
|
+
"""
|
|
56
|
+
seen: "dict[str, None]" = {}
|
|
57
|
+
|
|
58
|
+
def _add(ref: str) -> None:
|
|
59
|
+
ref = ref.strip()
|
|
60
|
+
if not ref or ref in seen:
|
|
61
|
+
return
|
|
62
|
+
# Skip bare top-level filenames with no directory component that name a
|
|
63
|
+
# harness-walked context file rather than an on-demand target.
|
|
64
|
+
if "/" not in ref and ref in ("AGENTS.md", "AGENTS.local.md", "CONTEXT.md"):
|
|
65
|
+
return
|
|
66
|
+
seen[ref] = None
|
|
67
|
+
|
|
68
|
+
# Drop the provenance comments before scanning so their absolute paths don't
|
|
69
|
+
# get re-read as references.
|
|
70
|
+
scannable = re.sub(r'(?m)^<!-- Source:.*?-->\s*$', '', text)
|
|
71
|
+
|
|
72
|
+
# Backticked refs first (highest confidence).
|
|
73
|
+
for m in re.findall(r'`([^`]+?\.md)`', scannable):
|
|
74
|
+
if not m.startswith(("/", "~")):
|
|
75
|
+
_add(m)
|
|
76
|
+
# Bare refs.
|
|
77
|
+
for m in _BARE_MD_REF.findall(scannable):
|
|
78
|
+
_add(m)
|
|
79
|
+
return list(seen)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _inline_referenced_files(text: str, search_roots: list[Path]) -> str:
|
|
83
|
+
"""Find markdown file references (backticked or bare) and inline them.
|
|
84
|
+
|
|
85
|
+
This defeats unreliable on-demand loading: an `AGENTS.md` that merely points
|
|
86
|
+
at `kb/X.md` gets that file's content appended inline so the agent never has
|
|
87
|
+
to fetch it. Only references that resolve to a real file under a search root
|
|
88
|
+
are inlined; unresolved references are left as-is."""
|
|
89
|
+
refs = _find_md_refs(text)
|
|
90
|
+
|
|
91
|
+
inlined: "dict[str, str]" = {}
|
|
92
|
+
for ref in refs:
|
|
93
|
+
for root in search_roots:
|
|
94
|
+
cand = root / ref
|
|
95
|
+
try:
|
|
96
|
+
if cand.is_file():
|
|
97
|
+
inlined[ref] = cand.read_text(encoding="utf-8")
|
|
98
|
+
break
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
if inlined:
|
|
103
|
+
appends = ["\n\n## On-Demand Files (Inlined)\n"]
|
|
104
|
+
for ref, content in inlined.items():
|
|
105
|
+
appends.append(f"### {ref}\n\n{content}\n")
|
|
106
|
+
return text + "\n".join(appends)
|
|
107
|
+
return text
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _collect_skills(agents_dir: Path, project_root: Path, global_scope: bool) -> "list[tuple[str, str]]":
|
|
111
|
+
"""Discover available skills as (name, description) pairs.
|
|
112
|
+
|
|
113
|
+
Skills are OPT-IN: the generator lists them so the user can choose to invoke
|
|
114
|
+
one, but never inlines a skill body (that would defeat the user's
|
|
115
|
+
'skills I decide to use' model). Deterministic order."""
|
|
116
|
+
roots = [agents_dir]
|
|
117
|
+
if not global_scope:
|
|
118
|
+
roots.append(project_root / ".agents")
|
|
119
|
+
|
|
120
|
+
overlay_root = agents_dir / "overlays"
|
|
121
|
+
if overlay_root.is_dir():
|
|
122
|
+
roots.extend([d for d in sorted(overlay_root.iterdir()) if d.is_dir()])
|
|
123
|
+
|
|
124
|
+
skills: "list[tuple[str, str]]" = []
|
|
125
|
+
seen: "set[str]" = set()
|
|
126
|
+
for root in roots:
|
|
127
|
+
skills_dir = root / "skills"
|
|
128
|
+
if not skills_dir.is_dir():
|
|
129
|
+
continue
|
|
130
|
+
for skill_dir in sorted(skills_dir.iterdir()):
|
|
131
|
+
if not skill_dir.is_dir():
|
|
132
|
+
continue
|
|
133
|
+
skill_md = skill_dir / "SKILL.md"
|
|
134
|
+
if not skill_md.is_file():
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
138
|
+
name_match = re.search(r'(?m)^name:\s*(.+)$', content)
|
|
139
|
+
desc_match = re.search(r'(?m)^description:\s*(.+)$', content)
|
|
140
|
+
if name_match and desc_match:
|
|
141
|
+
name = name_match.group(1).strip()
|
|
142
|
+
if name in seen:
|
|
143
|
+
continue
|
|
144
|
+
seen.add(name)
|
|
145
|
+
skills.append((name, desc_match.group(1).strip()))
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
return skills
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _get_skills_listing(agents_dir: Path, project_root: Path, global_scope: bool) -> str:
|
|
152
|
+
"""Formatted opt-in skills listing (markdown), or '' if none."""
|
|
153
|
+
skills = _collect_skills(agents_dir, project_root, global_scope)
|
|
154
|
+
if not skills:
|
|
155
|
+
return ""
|
|
156
|
+
lines = ["- **%s**: %s" % (n, d) for n, d in skills]
|
|
157
|
+
return "\n\n## Available Skills (Opt-in)\n" + "\n".join(lines) + "\n"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _resolve_and_filter_sources(
|
|
161
|
+
agent: _agents.Agent,
|
|
162
|
+
agents_dir: Path,
|
|
163
|
+
project_root: Path,
|
|
164
|
+
global_scope: bool,
|
|
165
|
+
) -> "list[tuple[str, Path, Path | None]]":
|
|
166
|
+
"""Resolve context sources (contract A), priority-order them, and subtract
|
|
167
|
+
the active agent's ``harness_loads`` so nothing already in the harness's
|
|
168
|
+
context is re-emitted (no double-send)."""
|
|
169
|
+
sources = _resolve.get_file_paths(
|
|
170
|
+
{"overlay": "CONTEXT.md", "default": "AGENTS.md"},
|
|
171
|
+
{"project": "AGENTS.local.md", "project-root": "AGENTS.local.md"},
|
|
172
|
+
agents_dir=agents_dir,
|
|
173
|
+
project_root=project_root,
|
|
174
|
+
global_scope=global_scope,
|
|
175
|
+
include_missing=False,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Apply overlay priority (plan 02): overlays sort among themselves by their
|
|
179
|
+
# declared priority (lower first); non-overlay levels (system/user/project)
|
|
180
|
+
# keep the resolver's precedence order, placed after all overlays via a high
|
|
181
|
+
# sentinel. Stable sort preserves resolver order within equal keys.
|
|
182
|
+
_NON_OVERLAY_SENTINEL = 10_000
|
|
183
|
+
|
|
184
|
+
def _sort_key(item):
|
|
185
|
+
level, path, root = item
|
|
186
|
+
if level == "overlay" and root:
|
|
187
|
+
return (_get_overlay_priority(root), path.name)
|
|
188
|
+
return (_NON_OVERLAY_SENTINEL, "")
|
|
189
|
+
|
|
190
|
+
sources.sort(key=_sort_key)
|
|
191
|
+
|
|
192
|
+
# Subtract harness loads (no double-send).
|
|
193
|
+
harness_loads_resolved = []
|
|
194
|
+
for hl in agent.harness_loads:
|
|
195
|
+
if hl.startswith("~/") or hl.startswith("/"):
|
|
196
|
+
harness_loads_resolved.append(Path(hl).expanduser().resolve())
|
|
197
|
+
|
|
198
|
+
filtered = []
|
|
199
|
+
for item in sources:
|
|
200
|
+
level, path, root = item
|
|
201
|
+
skip = False
|
|
202
|
+
try:
|
|
203
|
+
if path.resolve() in harness_loads_resolved:
|
|
204
|
+
skip = True
|
|
205
|
+
except OSError:
|
|
206
|
+
pass
|
|
207
|
+
if not skip:
|
|
208
|
+
for hl in agent.harness_loads:
|
|
209
|
+
if not (hl.startswith("~/") or hl.startswith("/")) and path.name == hl:
|
|
210
|
+
skip = True
|
|
211
|
+
break
|
|
212
|
+
if not skip:
|
|
213
|
+
filtered.append(item)
|
|
214
|
+
return filtered
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def assemble_context(
|
|
218
|
+
agent: _agents.Agent,
|
|
219
|
+
agents_dir: Path,
|
|
220
|
+
project_root: Path,
|
|
221
|
+
global_scope: bool = False,
|
|
222
|
+
) -> str:
|
|
223
|
+
"""Assemble the effective context text (markdown) for the given agent.
|
|
224
|
+
|
|
225
|
+
Returns '' if, after subtracting what the agent's harness already loads,
|
|
226
|
+
there is nothing new to emit (no empty double of already-loaded content)."""
|
|
227
|
+
filtered_sources = _resolve_and_filter_sources(
|
|
228
|
+
agent, agents_dir, project_root, global_scope
|
|
229
|
+
)
|
|
230
|
+
if not filtered_sources:
|
|
231
|
+
return ""
|
|
232
|
+
|
|
233
|
+
assembled_parts = []
|
|
234
|
+
search_roots = [project_root, agents_dir]
|
|
235
|
+
overlay_roots = []
|
|
236
|
+
|
|
237
|
+
for level, path, root in filtered_sources:
|
|
238
|
+
try:
|
|
239
|
+
content = path.read_text(encoding="utf-8")
|
|
240
|
+
if root:
|
|
241
|
+
search_roots.append(root)
|
|
242
|
+
if level == "overlay":
|
|
243
|
+
overlay_roots.append(root)
|
|
244
|
+
assembled_parts.append(f"<!-- Source: {path} -->\n{content.strip()}\n")
|
|
245
|
+
except OSError:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
text = "\n\n".join(assembled_parts)
|
|
249
|
+
text = _expand_placeholders(text, project_root, overlay_roots)
|
|
250
|
+
text = _inline_referenced_files(text, search_roots)
|
|
251
|
+
text += _get_skills_listing(agents_dir, project_root, global_scope)
|
|
252
|
+
|
|
253
|
+
return text
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def assemble_context_data(
|
|
257
|
+
agent: _agents.Agent,
|
|
258
|
+
agents_dir: Path,
|
|
259
|
+
project_root: Path,
|
|
260
|
+
global_scope: bool = False,
|
|
261
|
+
) -> "dict[str, object]":
|
|
262
|
+
"""Structured form of the assembled context, for ``--format json``.
|
|
263
|
+
|
|
264
|
+
Shape:
|
|
265
|
+
{
|
|
266
|
+
"agent": <registry name>,
|
|
267
|
+
"harness": <harness_id>,
|
|
268
|
+
"sources": [<absolute source path>, ...], # after harness subtraction
|
|
269
|
+
"context": <assembled markdown text, minus the skills listing>,
|
|
270
|
+
"skills": [{"name": ..., "description": ...}, ...], # opt-in listing
|
|
271
|
+
}
|
|
272
|
+
``context`` is the same assembled+inlined text the markdown format emits, but
|
|
273
|
+
WITHOUT the skills listing appended -- skills are their own structured field
|
|
274
|
+
so a consumer can render them separately and keep the opt-in distinction."""
|
|
275
|
+
filtered_sources = _resolve_and_filter_sources(
|
|
276
|
+
agent, agents_dir, project_root, global_scope
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
assembled_parts = []
|
|
280
|
+
search_roots = [project_root, agents_dir]
|
|
281
|
+
overlay_roots = []
|
|
282
|
+
source_paths: "list[str]" = []
|
|
283
|
+
|
|
284
|
+
for level, path, root in filtered_sources:
|
|
285
|
+
try:
|
|
286
|
+
content = path.read_text(encoding="utf-8")
|
|
287
|
+
except OSError:
|
|
288
|
+
continue
|
|
289
|
+
source_paths.append(str(path))
|
|
290
|
+
if root:
|
|
291
|
+
search_roots.append(root)
|
|
292
|
+
if level == "overlay":
|
|
293
|
+
overlay_roots.append(root)
|
|
294
|
+
assembled_parts.append(f"<!-- Source: {path} -->\n{content.strip()}\n")
|
|
295
|
+
|
|
296
|
+
text = "\n\n".join(assembled_parts)
|
|
297
|
+
text = _expand_placeholders(text, project_root, overlay_roots)
|
|
298
|
+
text = _inline_referenced_files(text, search_roots)
|
|
299
|
+
|
|
300
|
+
skills = [
|
|
301
|
+
{"name": n, "description": d}
|
|
302
|
+
for n, d in _collect_skills(agents_dir, project_root, global_scope)
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
"agent": agent.name,
|
|
307
|
+
"harness": agent.harness_id or agent.name,
|
|
308
|
+
"sources": source_paths,
|
|
309
|
+
"context": text,
|
|
310
|
+
"skills": skills,
|
|
311
|
+
}
|