monkeyscode 1.0.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.
monkeyscode/export.py ADDED
@@ -0,0 +1,319 @@
1
+ """
2
+ MonkeysCode SDK — Conversation Export.
3
+
4
+ Export agent results as Markdown, HTML, or JSON transcript.
5
+
6
+ Usage::
7
+
8
+ from monkeyscode.export import to_markdown, to_html, to_transcript
9
+
10
+ result = await agent.run("Fix all errors")
11
+
12
+ md = to_markdown(result)
13
+ html = to_html(result)
14
+ transcript = to_transcript(result)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import html as html_mod
20
+ import re
21
+ from datetime import datetime, timezone
22
+ from typing import Any
23
+
24
+ from monkeyscode.events import AgentResult
25
+
26
+ # ── Markdown Export ──────────────────────────────────────────────────
27
+
28
+
29
+ def to_markdown(
30
+ result: AgentResult,
31
+ *,
32
+ title: str | None = None,
33
+ include_tool_calls: bool = True,
34
+ include_file_diffs: bool = True,
35
+ include_cost: bool = True,
36
+ ) -> str:
37
+ """Convert an agent result to a Markdown report."""
38
+ lines: list[str] = []
39
+
40
+ lines.append(f"# {title or 'MonkeysCode Agent Report'}")
41
+ lines.append("")
42
+
43
+ # Summary
44
+ lines.append("## Summary")
45
+ lines.append("")
46
+ status = "✅ Success" if result.success else "❌ Failed"
47
+ lines.append(f"- **Status**: {status}")
48
+ lines.append(f"- **Model**: {result.model or 'auto'}")
49
+ if result.session_id:
50
+ lines.append(f"- **Session**: `{result.session_id}`")
51
+ if result.duration_ms:
52
+ lines.append(f"- **Duration**: {_format_duration(result.duration_ms)}")
53
+ lines.append("")
54
+
55
+ # Agent output
56
+ if result.summary:
57
+ lines.append("## Agent Output")
58
+ lines.append("")
59
+ lines.append(result.summary)
60
+ lines.append("")
61
+
62
+ # Error
63
+ if result.error:
64
+ lines.append("## Error")
65
+ lines.append("")
66
+ lines.append(f"> {result.error}")
67
+ lines.append("")
68
+
69
+ # Files changed
70
+ if result.files_changed and include_file_diffs:
71
+ lines.append("## Files Changed")
72
+ lines.append("")
73
+ lines.append(f"{len(result.files_changed)} file(s) modified:")
74
+ lines.append("")
75
+ for f in result.files_changed:
76
+ lines.append(f"- `{f.path}` ({f.action})")
77
+ if f.diff:
78
+ lines.append("")
79
+ lines.append("```diff")
80
+ lines.append(f.diff)
81
+ lines.append("```")
82
+ lines.append("")
83
+
84
+ # Tool calls
85
+ if result.tool_calls and include_tool_calls:
86
+ lines.append("## Tool Calls")
87
+ lines.append("")
88
+ lines.append(f"{len(result.tool_calls)} tool call(s):")
89
+ lines.append("")
90
+ lines.append("| # | Tool | Duration |")
91
+ lines.append("|---|------|----------|")
92
+ for i, call in enumerate(result.tool_calls, 1):
93
+ if isinstance(call, dict):
94
+ tool_name = call.get("tool", "?")
95
+ duration = call.get("duration", 0)
96
+ else:
97
+ tool_name = getattr(call, "tool", "?")
98
+ duration = getattr(call, "duration", 0)
99
+ dur = f"{duration}ms" if duration else "—"
100
+ lines.append(f"| {i} | `{tool_name}` | {dur} |")
101
+ lines.append("")
102
+
103
+ # Cost
104
+ if include_cost and result.tokens:
105
+ lines.append("## Cost & Usage")
106
+ lines.append("")
107
+ lines.append("| Metric | Value |")
108
+ lines.append("|--------|-------|")
109
+ lines.append(f"| Cost | ${result.cost:.4f} |")
110
+ lines.append(f"| Input tokens | {result.tokens.input:,} |")
111
+ lines.append(f"| Output tokens | {result.tokens.output:,} |")
112
+ if result.tokens.thinking:
113
+ lines.append(f"| Thinking tokens | {result.tokens.thinking:,} |")
114
+ lines.append(f"| Total tokens | {result.tokens.total:,} |")
115
+ lines.append("")
116
+
117
+ # Footer
118
+ lines.append("---")
119
+ lines.append(f"*Generated by MonkeysCode SDK at {datetime.now(timezone.utc).isoformat()}*")
120
+ lines.append("")
121
+
122
+ return "\n".join(lines)
123
+
124
+
125
+ # ── HTML Export ──────────────────────────────────────────────────────
126
+
127
+
128
+ def to_html(
129
+ result: AgentResult,
130
+ *,
131
+ title: str | None = None,
132
+ **kwargs: Any,
133
+ ) -> str:
134
+ """Convert an agent result to a styled HTML report."""
135
+ md = to_markdown(result, title=title, **kwargs)
136
+ escaped_title = html_mod.escape(title or "MonkeysCode Agent Report")
137
+ body = _markdown_to_html(md)
138
+
139
+ return f"""<!DOCTYPE html>
140
+ <html lang="en">
141
+ <head>
142
+ <meta charset="utf-8">
143
+ <meta name="viewport" content="width=device-width, initial-scale=1">
144
+ <title>{escaped_title}</title>
145
+ <style>
146
+ :root {{
147
+ --bg: #0d1117;
148
+ --fg: #e6edf3;
149
+ --accent: #58a6ff;
150
+ --success: #3fb950;
151
+ --error: #f85149;
152
+ --border: #30363d;
153
+ --code-bg: #161b22;
154
+ }}
155
+ * {{ box-sizing: border-box; }}
156
+ body {{
157
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
158
+ background: var(--bg);
159
+ color: var(--fg);
160
+ max-width: 860px;
161
+ margin: 0 auto;
162
+ padding: 2rem 1.5rem;
163
+ line-height: 1.6;
164
+ }}
165
+ h1 {{ border-bottom: 1px solid var(--border); padding-bottom: 0.5em; color: var(--accent); }}
166
+ h2 {{ margin-top: 2em; }}
167
+ code {{ background: var(--code-bg); padding: 0.2em 0.4em; border-radius: 4px; font-size: 0.9em; }}
168
+ pre {{ background: var(--code-bg); padding: 1em; border-radius: 8px; overflow-x: auto; }}
169
+ pre code {{ background: none; padding: 0; }}
170
+ table {{ width: 100%; border-collapse: collapse; margin: 1em 0; }}
171
+ th, td {{ padding: 0.5em 0.75em; text-align: left; border: 1px solid var(--border); }}
172
+ th {{ background: var(--code-bg); }}
173
+ blockquote {{ border-left: 3px solid var(--error); padding-left: 1em; color: var(--error); }}
174
+ hr {{ border: none; border-top: 1px solid var(--border); margin: 2em 0; }}
175
+ em {{ color: #8b949e; }}
176
+ </style>
177
+ </head>
178
+ <body>
179
+ {body}
180
+ </body>
181
+ </html>"""
182
+
183
+
184
+ # ── Transcript Export ────────────────────────────────────────────────
185
+
186
+
187
+ def to_transcript(
188
+ result: AgentResult,
189
+ events: list[Any] | None = None,
190
+ ) -> dict[str, Any]:
191
+ """Export the full result as a structured JSON transcript."""
192
+ return {
193
+ "version": "1.0",
194
+ "exported_at": datetime.now(timezone.utc).isoformat(),
195
+ "result": {
196
+ "success": result.success,
197
+ "status": result.status,
198
+ "summary": result.summary,
199
+ "model": result.model,
200
+ "cost": result.cost,
201
+ "duration_ms": result.duration_ms,
202
+ "tokens": {
203
+ "input": result.tokens.input if result.tokens else 0,
204
+ "output": result.tokens.output if result.tokens else 0,
205
+ "thinking": result.tokens.thinking if result.tokens else 0,
206
+ "total": result.tokens.total if result.tokens else 0,
207
+ },
208
+ "files_changed": [
209
+ {"path": f.path, "action": f.action}
210
+ for f in (result.files_changed or [])
211
+ ],
212
+ },
213
+ "events": events,
214
+ }
215
+
216
+
217
+ # ── Helpers ──────────────────────────────────────────────────────────
218
+
219
+
220
+ def _format_duration(ms: float) -> str:
221
+ if ms < 1000:
222
+ return f"{ms:.0f}ms"
223
+ if ms < 60_000:
224
+ return f"{ms / 1000:.1f}s"
225
+ mins = int(ms // 60_000)
226
+ secs = round((ms % 60_000) / 1000)
227
+ return f"{mins}m {secs}s"
228
+
229
+
230
+ def _markdown_to_html(md: str) -> str:
231
+ """Minimal Markdown → HTML converter (no dependencies)."""
232
+ lines = md.split("\n")
233
+ out: list[str] = []
234
+ in_code = False
235
+ in_table = False
236
+ in_list = False
237
+
238
+ for line in lines:
239
+ if line.startswith("```"):
240
+ if in_code:
241
+ out.append("</code></pre>")
242
+ in_code = False
243
+ else:
244
+ lang = line[3:].strip() or "text"
245
+ out.append(f'<pre><code class="language-{lang}">')
246
+ in_code = True
247
+ continue
248
+ if in_code:
249
+ out.append(html_mod.escape(line))
250
+ continue
251
+
252
+ if in_table and not line.startswith("|"):
253
+ out.append("</tbody></table>")
254
+ in_table = False
255
+ if in_list and not line.startswith("- "):
256
+ out.append("</ul>")
257
+ in_list = False
258
+
259
+ stripped = line.strip()
260
+ if not stripped:
261
+ out.append("")
262
+ continue
263
+
264
+ # Headings
265
+ m = re.match(r"^(#{1,6})\s+(.+)", line)
266
+ if m:
267
+ level = len(m.group(1))
268
+ out.append(f"<h{level}>{_inline(m.group(2))}</h{level}>")
269
+ continue
270
+
271
+ if re.match(r"^---+$", stripped):
272
+ out.append("<hr>")
273
+ continue
274
+
275
+ if stripped.startswith("> "):
276
+ out.append(f"<blockquote>{_inline(stripped[2:])}</blockquote>")
277
+ continue
278
+
279
+ if stripped.startswith("|"):
280
+ if re.match(r"^\|[\s\-|]+\|$", stripped):
281
+ continue
282
+ cells = [c.strip() for c in stripped.split("|") if c.strip()]
283
+ if not in_table:
284
+ out.append("<table><thead><tr>")
285
+ out.extend(f"<th>{_inline(c)}</th>" for c in cells)
286
+ out.append("</tr></thead><tbody>")
287
+ in_table = True
288
+ else:
289
+ out.append("<tr>")
290
+ out.extend(f"<td>{_inline(c)}</td>" for c in cells)
291
+ out.append("</tr>")
292
+ continue
293
+
294
+ if stripped.startswith("- "):
295
+ if not in_list:
296
+ out.append("<ul>")
297
+ in_list = True
298
+ out.append(f"<li>{_inline(stripped[2:])}</li>")
299
+ continue
300
+
301
+ out.append(f"<p>{_inline(line)}</p>")
302
+
303
+ if in_code:
304
+ out.append("</code></pre>")
305
+ if in_table:
306
+ out.append("</tbody></table>")
307
+ if in_list:
308
+ out.append("</ul>")
309
+
310
+ return "\n".join(out)
311
+
312
+
313
+ def _inline(text: str) -> str:
314
+ """Convert inline markdown."""
315
+ text = html_mod.escape(text)
316
+ text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
317
+ text = re.sub(r"`(.+?)`", r"<code>\1</code>", text)
318
+ text = re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
319
+ return text
monkeyscode/hooks.py ADDED
@@ -0,0 +1,190 @@
1
+ """
2
+ MonkeysCode SDK — Hooks System.
3
+
4
+ Intercept, modify, or block tool calls before/after execution.
5
+ Matches Claude Agent SDK's PreToolUse / PostToolUse pattern.
6
+
7
+ Usage::
8
+
9
+ from monkeyscode import MonkeysCode
10
+
11
+ agent = MonkeysCode(api_key="mc_...")
12
+
13
+ @agent.on_pre_tool_use
14
+ async def block_destructive(ctx):
15
+ if ctx.tool == "shell_command" and "rm -rf" in (ctx.args.get("command") or ""):
16
+ return PreToolResult(action="block", reason="Destructive command blocked")
17
+ return PreToolResult(action="allow")
18
+
19
+ @agent.on_post_tool_use
20
+ async def log_tools(ctx):
21
+ print(f"{ctx.tool}: {'FAIL' if ctx.is_error else 'OK'} ({ctx.duration_ms}ms)")
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from collections.abc import Awaitable, Callable
28
+ from dataclasses import dataclass
29
+ from typing import Any, Literal
30
+
31
+ logger = logging.getLogger("monkeyscode.hooks")
32
+
33
+ # ── Types ────────────────────────────────────────────────────────────
34
+
35
+ HookAction = Literal["allow", "block", "modify"]
36
+
37
+
38
+ @dataclass
39
+ class PreToolContext:
40
+ """Context passed to pre-tool hooks."""
41
+
42
+ tool: str
43
+ args: dict[str, Any]
44
+ run_id: str
45
+ turn_index: int
46
+ session_id: str | None = None
47
+ timestamp: float = 0.0
48
+
49
+
50
+ @dataclass
51
+ class PreToolResult:
52
+ """Result from a pre-tool hook."""
53
+
54
+ action: HookAction = "allow"
55
+ args: dict[str, Any] | None = None # for 'modify'
56
+ reason: str | None = None # for 'block'
57
+
58
+
59
+ @dataclass
60
+ class PostToolContext:
61
+ """Context passed to post-tool hooks."""
62
+
63
+ tool: str
64
+ args: dict[str, Any]
65
+ result: Any
66
+ is_error: bool
67
+ duration_ms: float
68
+ run_id: str
69
+ turn_index: int
70
+ session_id: str | None = None
71
+ timestamp: float = 0.0
72
+
73
+
74
+ PreToolHook = Callable[[PreToolContext], Awaitable[PreToolResult] | PreToolResult]
75
+ PostToolHook = Callable[[PostToolContext], Awaitable[None] | None]
76
+
77
+
78
+ # ── Hook Manager ─────────────────────────────────────────────────────
79
+
80
+
81
+ class HookManager:
82
+ """
83
+ Manages pre- and post-tool hooks for an agent instance.
84
+
85
+ Hook execution order:
86
+ 1. All pre-hooks run in registration order
87
+ 2. First hook to return 'block' wins (short-circuits)
88
+ 3. Last hook to return 'modify' wins (args are cumulative)
89
+ 4. Tool executes (or is blocked)
90
+ 5. All post-hooks run in registration order (fire-and-forget)
91
+ """
92
+
93
+ def __init__(self) -> None:
94
+ self._pre_hooks: list[PreToolHook] = []
95
+ self._post_hooks: list[PostToolHook] = []
96
+
97
+ @property
98
+ def has_hooks(self) -> bool:
99
+ """Whether any hooks are registered."""
100
+ return bool(self._pre_hooks or self._post_hooks)
101
+
102
+ @property
103
+ def pre_hook_count(self) -> int:
104
+ return len(self._pre_hooks)
105
+
106
+ @property
107
+ def post_hook_count(self) -> int:
108
+ return len(self._post_hooks)
109
+
110
+ def add_pre_hook(self, hook: PreToolHook) -> None:
111
+ """Register a pre-tool hook."""
112
+ self._pre_hooks.append(hook)
113
+
114
+ def add_post_hook(self, hook: PostToolHook) -> None:
115
+ """Register a post-tool hook."""
116
+ self._post_hooks.append(hook)
117
+
118
+ def remove_pre_hook(self, hook: PreToolHook) -> bool:
119
+ """Remove a pre-tool hook. Returns True if found."""
120
+ try:
121
+ self._pre_hooks.remove(hook)
122
+ return True
123
+ except ValueError:
124
+ return False
125
+
126
+ def remove_post_hook(self, hook: PostToolHook) -> bool:
127
+ """Remove a post-tool hook. Returns True if found."""
128
+ try:
129
+ self._post_hooks.remove(hook)
130
+ return True
131
+ except ValueError:
132
+ return False
133
+
134
+ def clear_all(self) -> None:
135
+ """Remove all hooks."""
136
+ self._pre_hooks.clear()
137
+ self._post_hooks.clear()
138
+
139
+ async def run_pre_hooks(self, context: PreToolContext) -> PreToolResult:
140
+ """
141
+ Run all pre-tool hooks in order.
142
+
143
+ - First 'block' result short-circuits and returns it.
144
+ - 'modify' results accumulate.
145
+ - If all return 'allow', returns PreToolResult(action='allow').
146
+ """
147
+ if not self._pre_hooks:
148
+ return PreToolResult(action="allow")
149
+
150
+ current_args = dict(context.args)
151
+ last_modify: PreToolResult | None = None
152
+
153
+ for hook in self._pre_hooks:
154
+ try:
155
+ result = hook(PreToolContext(
156
+ tool=context.tool,
157
+ args=current_args,
158
+ run_id=context.run_id,
159
+ turn_index=context.turn_index,
160
+ session_id=context.session_id,
161
+ timestamp=context.timestamp,
162
+ ))
163
+ # Await if coroutine
164
+ if hasattr(result, "__await__"):
165
+ result = await result # type: ignore[misc]
166
+
167
+ if result.action == "block":
168
+ return result
169
+
170
+ if result.action == "modify" and result.args is not None:
171
+ current_args.update(result.args)
172
+ last_modify = PreToolResult(action="modify", args=dict(current_args))
173
+
174
+ except Exception:
175
+ logger.exception("Pre-hook error (tool: %s)", context.tool)
176
+
177
+ if last_modify:
178
+ return last_modify
179
+
180
+ return PreToolResult(action="allow")
181
+
182
+ async def run_post_hooks(self, context: PostToolContext) -> None:
183
+ """Run all post-tool hooks. Errors are logged, never raised."""
184
+ for hook in self._post_hooks:
185
+ try:
186
+ result = hook(context)
187
+ if hasattr(result, "__await__"):
188
+ await result # type: ignore[misc]
189
+ except Exception:
190
+ logger.exception("Post-hook error (tool: %s)", context.tool)