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/_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
+ }