aru-code 0.1.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.
aru/config.py ADDED
@@ -0,0 +1,237 @@
1
+ """Configuration loader for AGENTS.md and .agents/ directory.
2
+
3
+ Supports:
4
+ - AGENTS.md: Project-level agent instructions (appended to system prompt)
5
+ - .agents/commands/*.md: Custom slash commands (filename = command name)
6
+ - .agents/skills/*.md: Custom skills/personas (loaded as additional instructions)
7
+
8
+ Follows the Gemini .agents convention for cross-platform compatibility.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ @dataclass
19
+ class CustomCommand:
20
+ """A custom command defined in .agents/commands/."""
21
+ name: str
22
+ description: str
23
+ template: str
24
+ source_path: str
25
+
26
+
27
+ @dataclass
28
+ class Skill:
29
+ """A skill defined in .agents/skills/."""
30
+ name: str
31
+ description: str
32
+ content: str
33
+ source_path: str
34
+
35
+
36
+ MAX_README_CHARS = 2000 # Reduced from 8000 to save ~1.7K tokens per request
37
+
38
+
39
+ @dataclass
40
+ class AgentConfig:
41
+ """Loaded configuration from AGENTS.md, README.md, and .agents/ directory."""
42
+ readme_md: str = ""
43
+ agents_md: str = ""
44
+ commands: dict[str, CustomCommand] = field(default_factory=dict)
45
+ skills: dict[str, Skill] = field(default_factory=dict)
46
+ permissions: dict[str, Any] = field(default_factory=dict)
47
+ model_defaults: dict[str, str] = field(default_factory=dict)
48
+ plan_reviewer: bool = True
49
+
50
+ @property
51
+ def has_instructions(self) -> bool:
52
+ return bool(self.agents_md) or bool(self.skills)
53
+
54
+ def get_extra_instructions(self, active_skills: list[str] | None = None, lightweight: bool = False) -> str:
55
+ """Build extra instructions from README.md, AGENTS.md, and active skills.
56
+
57
+ Args:
58
+ active_skills: List of skill names to include.
59
+ lightweight: If True, skip README.md to save tokens (for executor steps).
60
+ """
61
+ parts = []
62
+ if self.readme_md and not lightweight:
63
+ parts.append(f"## Project Overview (README.md)\n\n{self.readme_md}")
64
+ if self.agents_md:
65
+ parts.append(f"## Project Instructions (AGENTS.md)\n\n{self.agents_md}")
66
+ if active_skills:
67
+ for name in active_skills:
68
+ if name in self.skills:
69
+ skill = self.skills[name]
70
+ parts.append(f"## Skill: {skill.name}\n\n{skill.content}")
71
+ return "\n\n".join(parts)
72
+
73
+
74
+ def _parse_frontmatter(content: str) -> tuple[dict[str, str], str]:
75
+ """Parse YAML-like frontmatter from a markdown file.
76
+
77
+ Returns (metadata_dict, body_content).
78
+ """
79
+ metadata: dict[str, str] = {}
80
+ body = content
81
+
82
+ if content.startswith("---"):
83
+ lines = content.split("\n")
84
+ end_idx = -1
85
+ for i in range(1, len(lines)):
86
+ if lines[i].strip() == "---":
87
+ end_idx = i
88
+ break
89
+ if end_idx > 0:
90
+ for line in lines[1:end_idx]:
91
+ if ":" in line:
92
+ key, _, value = line.partition(":")
93
+ metadata[key.strip()] = value.strip()
94
+ body = "\n".join(lines[end_idx + 1:]).strip()
95
+
96
+ return metadata, body
97
+
98
+
99
+ def _load_commands(agents_dir: Path) -> dict[str, CustomCommand]:
100
+ """Load custom commands from .agents/commands/."""
101
+ commands_dir = agents_dir / "commands"
102
+ commands: dict[str, CustomCommand] = {}
103
+
104
+ if not commands_dir.is_dir():
105
+ return commands
106
+
107
+ for filepath in sorted(commands_dir.iterdir()):
108
+ if filepath.suffix != ".md":
109
+ continue
110
+
111
+ name = filepath.stem
112
+ try:
113
+ content = filepath.read_text(encoding="utf-8")
114
+ except (OSError, UnicodeDecodeError):
115
+ continue
116
+
117
+ metadata, body = _parse_frontmatter(content)
118
+ description = metadata.get("description", f"Custom command: {name}")
119
+
120
+ commands[name] = CustomCommand(
121
+ name=name,
122
+ description=description,
123
+ template=body,
124
+ source_path=str(filepath),
125
+ )
126
+
127
+ return commands
128
+
129
+
130
+ def _load_skills(agents_dir: Path) -> dict[str, Skill]:
131
+ """Load skills from .agents/skills/."""
132
+ skills_dir = agents_dir / "skills"
133
+ skills: dict[str, Skill] = {}
134
+
135
+ if not skills_dir.is_dir():
136
+ return skills
137
+
138
+ for filepath in sorted(skills_dir.iterdir()):
139
+ if filepath.suffix != ".md":
140
+ continue
141
+
142
+ name = filepath.stem
143
+ try:
144
+ content = filepath.read_text(encoding="utf-8")
145
+ except (OSError, UnicodeDecodeError):
146
+ continue
147
+
148
+ metadata, body = _parse_frontmatter(content)
149
+ description = metadata.get("description", f"Skill: {name}")
150
+
151
+ skills[name] = Skill(
152
+ name=name,
153
+ description=description,
154
+ content=body,
155
+ source_path=str(filepath),
156
+ )
157
+
158
+ return skills
159
+
160
+
161
+ def load_config(cwd: str | None = None) -> AgentConfig:
162
+ """Load agent configuration from AGENTS.md and .agents/ directory.
163
+
164
+ Searches the current working directory for:
165
+ - AGENTS.md: Project-level instructions
166
+ - .agents/commands/*.md: Custom slash commands
167
+ - .agents/skills/*.md: Custom skills/personas
168
+
169
+ Args:
170
+ cwd: Working directory to search in. Defaults to os.getcwd().
171
+
172
+ Returns:
173
+ AgentConfig with all loaded configuration.
174
+ """
175
+ root = Path(cwd or os.getcwd())
176
+ config = AgentConfig()
177
+
178
+ # Load README.md first — gives the agent project context upfront
179
+ for readme_name in ("README.md", "readme.md", "Readme.md"):
180
+ readme_path = root / readme_name
181
+ if readme_path.is_file():
182
+ try:
183
+ content = readme_path.read_text(encoding="utf-8").strip()
184
+ config.readme_md = content[:MAX_README_CHARS]
185
+ except (OSError, UnicodeDecodeError):
186
+ pass
187
+ break
188
+
189
+ # Load AGENTS.md
190
+ agents_md_path = root / "AGENTS.md"
191
+ if agents_md_path.is_file():
192
+ try:
193
+ config.agents_md = agents_md_path.read_text(encoding="utf-8").strip()
194
+ except (OSError, UnicodeDecodeError):
195
+ pass
196
+
197
+ # Load .agents/ directory
198
+ agents_dir = root / ".agents"
199
+ if agents_dir.is_dir():
200
+ config.commands = _load_commands(agents_dir)
201
+ config.skills = _load_skills(agents_dir)
202
+
203
+ # Load opencode-style config (aru.json or .aru/config.json)
204
+ config_paths = [root / "aru.json", root / ".aru" / "config.json"]
205
+ for config_path in config_paths:
206
+ if config_path.is_file():
207
+ try:
208
+ content = config_path.read_text(encoding="utf-8")
209
+ data = json.loads(content)
210
+ if isinstance(data, dict):
211
+ if "permission" in data:
212
+ config.permissions = data["permission"]
213
+ # Load provider configuration
214
+ if "providers" in data or "models" in data:
215
+ from aru.providers import load_providers_from_config
216
+ load_providers_from_config(data)
217
+ # Store model defaults for CLI
218
+ if "models" in data and isinstance(data["models"], dict):
219
+ config.model_defaults = data["models"]
220
+ if "plan_reviewer" in data:
221
+ config.plan_reviewer = bool(data["plan_reviewer"])
222
+ break
223
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
224
+ pass
225
+
226
+ return config
227
+
228
+
229
+ def render_command_template(template: str, user_input: str) -> str:
230
+ """Render a command template with user input.
231
+
232
+ Replaces $INPUT with the user's arguments.
233
+ Also supports $SELECTION (empty if not provided) for future use.
234
+ """
235
+ result = template.replace("$INPUT", user_input)
236
+ result = result.replace("$SELECTION", "")
237
+ return result
aru/context.py ADDED
@@ -0,0 +1,287 @@
1
+ """Context management for token optimization.
2
+
3
+ Implements three layers of token reduction:
4
+ 1. Pruning — evict old tool/assistant outputs from history
5
+ 2. Truncation — universal cap on tool output size
6
+ 3. Compaction — summarize entire conversation when approaching context limits
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # ── Constants ──────────────────────────────────────────────────────
12
+
13
+ # Pruning: protect the most recent N chars of assistant content from eviction
14
+ PRUNE_PROTECT_CHARS = 50_000 # ~14K tokens
15
+ # Pruning: minimum chars that must be freeable to justify a prune pass
16
+ PRUNE_MINIMUM_CHARS = 20_000 # ~5.7K tokens
17
+ # Placeholder that replaces evicted content
18
+ PRUNED_PLACEHOLDER = "[previous output cleared to save context]"
19
+
20
+ # Truncation: universal limits for any tool output
21
+ TRUNCATE_MAX_LINES = 500
22
+ TRUNCATE_MAX_BYTES = 20 * 1024 # 20 KB
23
+ TRUNCATE_KEEP_START = 350 # lines to keep from the start
24
+ TRUNCATE_KEEP_END = 100 # lines to keep from the end
25
+
26
+ # Compaction: trigger when cumulative input tokens exceed this fraction of model limit
27
+ COMPACTION_THRESHOLD_RATIO = 0.50
28
+ # Default model context limits (input tokens)
29
+ MODEL_CONTEXT_LIMITS: dict[str, int] = {
30
+ "claude-sonnet-4-5-20250929": 200_000,
31
+ "claude-sonnet-4-20250514": 200_000,
32
+ "claude-haiku-4-5-20251001": 200_000,
33
+ "claude-opus-4-20250514": 200_000,
34
+ "claude-opus-4-6": 1_000_000,
35
+ "claude-sonnet-4-6": 1_000_000,
36
+ "gpt-4o": 128_000,
37
+ "gpt-4o-mini": 128_000,
38
+ "default": 200_000,
39
+ }
40
+
41
+ COMPACTION_TEMPLATE = """\
42
+ Summarize this conversation concisely. Preserve:
43
+ 1. **Goal**: What the user wants to accomplish
44
+ 2. **Key decisions**: Important choices made during the conversation
45
+ 3. **Discoveries**: What was learned about the codebase or problem
46
+ 4. **Accomplished**: What has been done so far (be specific about files changed)
47
+ 5. **Relevant files**: File paths that are important for continuing the work
48
+ 6. **Next steps**: What remains to be done
49
+
50
+ Be concise but complete. This summary replaces the full conversation history."""
51
+
52
+
53
+ # ── Layer 1: Pruning ──────────────────────────────────────────────
54
+
55
+ def prune_history(history: list[dict[str, str]]) -> list[dict[str, str]]:
56
+ """Replace old assistant messages with a short placeholder to reduce tokens.
57
+
58
+ Walks backward through history, protecting the most recent assistant
59
+ content (up to PRUNE_PROTECT_CHARS). Older assistant messages beyond
60
+ that budget are replaced with a compact placeholder.
61
+
62
+ Returns a new list (does not mutate the input).
63
+ """
64
+ if len(history) <= 2:
65
+ return list(history)
66
+
67
+ # Calculate total assistant chars
68
+ total_assistant_chars = sum(
69
+ len(msg["content"]) for msg in history if msg["role"] == "assistant"
70
+ )
71
+
72
+ # Not enough to prune
73
+ if total_assistant_chars < PRUNE_PROTECT_CHARS + PRUNE_MINIMUM_CHARS:
74
+ return list(history)
75
+
76
+ # Walk backward, protecting recent content
77
+ result = list(history)
78
+ protected = 0
79
+
80
+ for i in range(len(result) - 1, -1, -1):
81
+ msg = result[i]
82
+ if msg["role"] != "assistant":
83
+ continue
84
+
85
+ msg_len = len(msg["content"])
86
+ if protected + msg_len <= PRUNE_PROTECT_CHARS:
87
+ # Still within protection window
88
+ protected += msg_len
89
+ else:
90
+ # Beyond protection window — prune this message
91
+ if msg["content"] != PRUNED_PLACEHOLDER:
92
+ result[i] = {"role": "assistant", "content": PRUNED_PLACEHOLDER}
93
+
94
+ return result
95
+
96
+
97
+ # ── Layer 2: Truncation ───────────────────────────────────────────
98
+
99
+ def truncate_output(text: str) -> str:
100
+ """Universal truncation for tool outputs.
101
+
102
+ Caps output at TRUNCATE_MAX_BYTES / TRUNCATE_MAX_LINES, keeping the
103
+ start and end with a middle marker showing what was cut.
104
+ """
105
+ if not text:
106
+ return text
107
+
108
+ # Check byte size
109
+ byte_len = len(text.encode("utf-8", errors="replace"))
110
+ lines = text.splitlines(keepends=True)
111
+ line_count = len(lines)
112
+
113
+ if byte_len <= TRUNCATE_MAX_BYTES and line_count <= TRUNCATE_MAX_LINES:
114
+ return text
115
+
116
+ # Truncate by lines
117
+ if line_count > TRUNCATE_MAX_LINES:
118
+ head = lines[:TRUNCATE_KEEP_START]
119
+ tail = lines[-TRUNCATE_KEEP_END:]
120
+ omitted = line_count - TRUNCATE_KEEP_START - TRUNCATE_KEEP_END
121
+ return (
122
+ "".join(head)
123
+ + f"\n\n[... {omitted:,} lines omitted ({line_count:,} total) — "
124
+ f"use offset/limit or a more specific query ...]\n\n"
125
+ + "".join(tail)
126
+ )
127
+
128
+ # Truncate by bytes (lines fit but total bytes too large)
129
+ kept_lines: list[str] = []
130
+ total = 0
131
+ for line in lines:
132
+ line_bytes = len(line.encode("utf-8", errors="replace"))
133
+ if total + line_bytes > TRUNCATE_MAX_BYTES:
134
+ break
135
+ kept_lines.append(line)
136
+ total += line_bytes
137
+
138
+ remaining = line_count - len(kept_lines)
139
+ return (
140
+ "".join(kept_lines)
141
+ + f"\n\n[... truncated at ~{TRUNCATE_MAX_BYTES // 1024}KB — "
142
+ f"{remaining:,} more lines — use offset/limit to read further ...]\n"
143
+ )
144
+
145
+
146
+ # ── Layer 3: Compaction ───────────────────────────────────────────
147
+
148
+ def should_compact(total_input_tokens: int, model_id: str = "default") -> bool:
149
+ """Check if the conversation should be compacted based on token usage."""
150
+ limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
151
+ threshold = int(limit * COMPACTION_THRESHOLD_RATIO)
152
+ return total_input_tokens >= threshold
153
+
154
+
155
+ def build_compaction_prompt(history: list[dict[str, str]], plan_task: str | None = None) -> str:
156
+ """Build the prompt sent to the compaction agent to summarize the conversation."""
157
+ parts = [COMPACTION_TEMPLATE, "\n\n---\n\n## Conversation to summarize:\n"]
158
+
159
+ if plan_task:
160
+ parts.append(f"**Active task:** {plan_task}\n\n")
161
+
162
+ for msg in history:
163
+ role = msg["role"].upper()
164
+ content = msg["content"]
165
+ # Cap individual messages in the compaction input to avoid blowing up
166
+ if len(content) > 2000:
167
+ content = content[:2000] + f"... [{len(content) - 2000} chars truncated]"
168
+ parts.append(f"**{role}:** {content}\n\n")
169
+
170
+ return "".join(parts)
171
+
172
+
173
+ def apply_compaction(history: list[dict[str, str]], summary: str) -> list[dict[str, str]]:
174
+ """Replace history with a compaction summary + the most recent exchange."""
175
+ compacted = [
176
+ {"role": "user", "content": f"[Conversation compacted]\n\n{summary}"}
177
+ ]
178
+ # Keep the last user message and last assistant message for continuity
179
+ last_user = None
180
+ last_assistant = None
181
+ for msg in reversed(history):
182
+ if msg["role"] == "user" and last_user is None:
183
+ last_user = msg
184
+ elif msg["role"] == "assistant" and last_assistant is None:
185
+ last_assistant = msg
186
+ if last_user and last_assistant:
187
+ break
188
+
189
+ if last_assistant:
190
+ compacted.append(last_assistant)
191
+ if last_user and last_user != compacted[0]:
192
+ compacted.append(last_user)
193
+
194
+ return compacted
195
+
196
+
197
+ async def compact_conversation(
198
+ history: list[dict[str, str]],
199
+ model_ref: str,
200
+ plan_task: str | None = None,
201
+ ) -> list[dict[str, str]]:
202
+ """Run the compaction agent to summarize and replace history.
203
+
204
+ Uses a small/fast model for the summarization to minimize cost.
205
+ Falls back to simple truncation if the agent call fails.
206
+ """
207
+ from aru.tools.codebase import _get_small_model_ref
208
+ from aru.providers import create_model
209
+
210
+ prompt = build_compaction_prompt(history, plan_task)
211
+
212
+ try:
213
+ from agno.agent import Agent
214
+
215
+ small_ref = _get_small_model_ref()
216
+ compactor = Agent(
217
+ name="Compactor",
218
+ model=create_model(small_ref, max_tokens=2048),
219
+ instructions="You summarize conversations concisely. Output ONLY the summary, no preamble.",
220
+ markdown=True,
221
+ )
222
+
223
+ result = await compactor.arun(prompt, stream=False)
224
+ summary = result.content if result and result.content else ""
225
+
226
+ if not summary:
227
+ # Fallback: simple mechanical summary
228
+ summary = _fallback_summary(history, plan_task)
229
+
230
+ return apply_compaction(history, summary)
231
+
232
+ except Exception:
233
+ # Fallback if agent fails
234
+ summary = _fallback_summary(history, plan_task)
235
+ return apply_compaction(history, summary)
236
+
237
+
238
+ def _fallback_summary(history: list[dict[str, str]], plan_task: str | None = None) -> str:
239
+ """Mechanical summary when the compaction agent is unavailable."""
240
+ parts = []
241
+ if plan_task:
242
+ parts.append(f"**Task:** {plan_task}")
243
+
244
+ msg_count = len(history)
245
+ user_msgs = sum(1 for m in history if m["role"] == "user")
246
+ parts.append(f"**Conversation:** {msg_count} messages ({user_msgs} from user)")
247
+
248
+ # Extract file paths mentioned
249
+ import re
250
+ all_text = " ".join(m["content"] for m in history)
251
+ files = set(re.findall(r'[\w./\\-]+\.(?:py|js|ts|tsx|jsx|go|rs|java|md|json|yaml|yml|toml)', all_text))
252
+ if files:
253
+ parts.append(f"**Files referenced:** {', '.join(sorted(files)[:20])}")
254
+
255
+ # Keep last 3 messages as brief excerpts
256
+ parts.append("\n**Recent context:**")
257
+ for msg in history[-3:]:
258
+ role = msg["role"]
259
+ text = msg["content"][:300]
260
+ if len(msg["content"]) > 300:
261
+ text += "..."
262
+ parts.append(f"- [{role}]: {text}")
263
+
264
+ return "\n".join(parts)
265
+
266
+
267
+ def format_context_block(content: str, label: str = "Context", include_timestamp: bool = True) -> str:
268
+ """Format a context block with separator and optional timestamp.
269
+
270
+ Args:
271
+ content: The content to include in the block.
272
+ label: Label for the context block.
273
+ include_timestamp: Whether to include timestamp in the separator.
274
+
275
+ Returns:
276
+ Formatted context block with separators and timestamp.
277
+ """
278
+ from datetime import datetime
279
+
280
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
281
+
282
+ if include_timestamp:
283
+ separator = f"-- {label} ({timestamp}) --"
284
+ else:
285
+ separator = f"-- {label} --"
286
+
287
+ return f"{separator}\n{content}\n{separator}"