rockycode 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.
- rockycode/__init__.py +1 -0
- rockycode/banner.py +37 -0
- rockycode/cli.py +1386 -0
- rockycode/config.py +178 -0
- rockycode/dream/__init__.py +9 -0
- rockycode/dream/core.py +523 -0
- rockycode/dream/judge.py +134 -0
- rockycode/dream/mining.py +152 -0
- rockycode/dream/proposals.py +440 -0
- rockycode/engine/__init__.py +10 -0
- rockycode/engine/artifact.py +367 -0
- rockycode/engine/budget.py +90 -0
- rockycode/engine/checks.py +157 -0
- rockycode/engine/compaction.py +181 -0
- rockycode/engine/container.py +225 -0
- rockycode/engine/effort.py +46 -0
- rockycode/engine/events.py +101 -0
- rockycode/engine/explore.py +592 -0
- rockycode/engine/goal.py +541 -0
- rockycode/engine/goal_review.py +161 -0
- rockycode/engine/goal_session.py +259 -0
- rockycode/engine/headless.py +481 -0
- rockycode/engine/loop.py +711 -0
- rockycode/engine/lsp.py +473 -0
- rockycode/engine/mcp.py +364 -0
- rockycode/engine/modes.py +123 -0
- rockycode/engine/outcome.py +81 -0
- rockycode/engine/permission.py +198 -0
- rockycode/engine/planmode.py +249 -0
- rockycode/engine/providers.py +196 -0
- rockycode/engine/redact.py +83 -0
- rockycode/engine/safety.py +139 -0
- rockycode/engine/sandbox.py +219 -0
- rockycode/engine/server.py +431 -0
- rockycode/engine/skills.py +178 -0
- rockycode/engine/titler.py +46 -0
- rockycode/engine/tools.py +479 -0
- rockycode/engine/trajectory.py +131 -0
- rockycode/engine/web.py +431 -0
- rockycode/engine/worktree.py +128 -0
- rockycode/memory/__init__.py +7 -0
- rockycode/memory/index.py +260 -0
- rockycode/memory/store.py +331 -0
- rockycode/modes/learn/learn.md +46 -0
- rockycode/modes/research/deep-research.md +53 -0
- rockycode/modes/research/paper-reading.md +49 -0
- rockycode/modes/research/prove.md +60 -0
- rockycode/modes/research/whiteboard.md +64 -0
- rockycode/onboarding.py +332 -0
- rockycode/palette.py +15 -0
- rockycode/pricing.py +178 -0
- rockycode/prompts/__init__.py +0 -0
- rockycode/prompts/rocky.py +257 -0
- rockycode/routines.py +287 -0
- rockycode/runners/__init__.py +0 -0
- rockycode/runners/agent.py +273 -0
- rockycode/runners/data.py +61 -0
- rockycode/runners/raw.py +176 -0
- rockycode/score.py +114 -0
- rockycode/session.py +298 -0
- rockycode/skills/architecture-viz/SKILL.md +71 -0
- rockycode/skills/architecture-viz/template.html +87 -0
- rockycode/skills/lean-prover/SKILL.md +155 -0
- rockycode/skills/lean-prover/torchlean-api.md +85 -0
- rockycode/tui/__init__.py +1 -0
- rockycode/tui/app.py +2450 -0
- rockycode/tui/exitsheet.py +181 -0
- rockycode/tui/goal_screen.py +315 -0
- rockycode/tui/mdterm.py +232 -0
- rockycode/tui/mdview.py +99 -0
- rockycode/tui/modepicker.py +103 -0
- rockycode/tui/permission.py +154 -0
- rockycode/tui/plangate.py +110 -0
- rockycode/tui/prompt_history.py +77 -0
- rockycode/tui/proposalcard.py +126 -0
- rockycode/tui/resume.py +142 -0
- rockycode/tui/rocky_pet.py +96 -0
- rockycode/tui/routinecard.py +123 -0
- rockycode-0.1.0.dist-info/METADATA +488 -0
- rockycode-0.1.0.dist-info/RECORD +83 -0
- rockycode-0.1.0.dist-info/WHEEL +4 -0
- rockycode-0.1.0.dist-info/entry_points.txt +2 -0
- rockycode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Artifact tool — generate a self-contained HTML page and open it.
|
|
2
|
+
|
|
3
|
+
One tool: ``create_artifact``. The model supplies body content (or a full
|
|
4
|
+
document); rockycode wraps a fragment in its themed page and opens it.
|
|
5
|
+
|
|
6
|
+
Static (default): write the file and open it via ``file://`` — no server.
|
|
7
|
+
Live (opt-in): ``ArtifactServer`` serves it over ``http://localhost`` and
|
|
8
|
+
the open tab auto-reloads whenever rocky rebuilds the same
|
|
9
|
+
artifact. Lazy-started on the first artifact (asked once),
|
|
10
|
+
or from the start with ``--live``. Local only.
|
|
11
|
+
|
|
12
|
+
The TUI sets ``engine.artifact_target`` — an async callable that decides live
|
|
13
|
+
vs static (asking once), lazy-starts the server, and returns the running
|
|
14
|
+
``ArtifactServer`` (live) or ``None`` (static).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import secrets
|
|
22
|
+
import webbrowser
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from urllib.parse import quote, unquote
|
|
25
|
+
|
|
26
|
+
from rockycode.engine.tools import Tool, _fn_schema
|
|
27
|
+
|
|
28
|
+
ARTIFACT_DIR = ".rockycode/artifacts"
|
|
29
|
+
MAX_ARTIFACT_CHARS = 500_000 # 500 KB — generous for inline-SVG/data-URI HTML
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _open_browser(url: str) -> None:
|
|
33
|
+
"""Best-effort browser open.
|
|
34
|
+
|
|
35
|
+
Suppressed when ROCKYCODE_ARTIFACT_NO_BROWSER=1: an embedding editor (the
|
|
36
|
+
VS Code extension) sets it because it opens the URL from the tool output
|
|
37
|
+
itself — without this, every artifact opened twice. The output lines
|
|
38
|
+
("url: ...", "opened in browser: file://...") are the extension's parse
|
|
39
|
+
surface and must keep their format either way.
|
|
40
|
+
"""
|
|
41
|
+
if os.getenv("ROCKYCODE_ARTIFACT_NO_BROWSER") == "1":
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
webbrowser.open(url)
|
|
45
|
+
except Exception: # noqa: BLE001 — best-effort
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _safe_filename(name: str) -> str:
|
|
50
|
+
"""Sanitise *name* into a safe file stem (<=80 chars)."""
|
|
51
|
+
safe = "".join(c if c.isalnum() or c in "._- " else "_" for c in name)
|
|
52
|
+
return safe.strip().replace(" ", "_")[:80] or "artifact"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _extract_body(html: str) -> str:
|
|
56
|
+
"""Reduce model HTML to themable body content.
|
|
57
|
+
|
|
58
|
+
The model often emits a whole <html> document with its own (usually dark)
|
|
59
|
+
theme — DeepSeek loves to. We keep only the inner <body> and strip every
|
|
60
|
+
<style> block, so rocky's light-purple theme always wins. A plain fragment
|
|
61
|
+
is returned with just its <style> blocks removed.
|
|
62
|
+
"""
|
|
63
|
+
low = html.lower()
|
|
64
|
+
i = low.find("<body")
|
|
65
|
+
if i != -1:
|
|
66
|
+
start = low.find(">", i)
|
|
67
|
+
end = low.rfind("</body>")
|
|
68
|
+
if start != -1 and end != -1:
|
|
69
|
+
html = html[start + 1:end]
|
|
70
|
+
return re.sub(r"(?is)<style\b.*?</style>", "", html)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _artifact_html(title: str, body: str) -> str:
|
|
74
|
+
"""Wrap *body* in rockycode's light, soft-purple themed page.
|
|
75
|
+
|
|
76
|
+
Palette tracks rockycode/palette.py + ROCKY_THEME_LIGHT.
|
|
77
|
+
"""
|
|
78
|
+
return f"""<!DOCTYPE html>
|
|
79
|
+
<html lang="en">
|
|
80
|
+
<head>
|
|
81
|
+
<meta charset="UTF-8">
|
|
82
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
83
|
+
<title>{title}</title>
|
|
84
|
+
<style>
|
|
85
|
+
:root {{ --bg:#f6f4fb; --card:#ffffff; --panel:#ece8f7; --border:#ddd4ef;
|
|
86
|
+
--text:#2a2a38; --dim:#5a5e7a; --heading:#6a4ca3; --accent:#7c5cba; --brand:#bb9af7; }}
|
|
87
|
+
* {{ box-sizing:border-box; margin:0; padding:0; }}
|
|
88
|
+
body {{ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
|
|
89
|
+
background:var(--bg); color:var(--text); line-height:1.6; max-width:900px; margin:0 auto; padding:2rem; }}
|
|
90
|
+
h1 {{ color:var(--heading); border-bottom:2px solid var(--border); padding-bottom:.5rem; margin-bottom:.75rem; font-weight:650; }}
|
|
91
|
+
h2 {{ color:var(--accent); margin:1.5rem 0 .5rem; font-size:1.15rem; }}
|
|
92
|
+
.card {{ background:var(--card); border:1px solid var(--border); border-radius:10px;
|
|
93
|
+
padding:1rem 1.1rem; margin:1rem 0; box-shadow:0 1px 3px rgba(106,76,163,0.06); }}
|
|
94
|
+
.tag {{ display:inline-block; padding:.1rem .55rem; border-radius:10px; font-size:.72rem; font-weight:600; }}
|
|
95
|
+
.tag-purple {{ background:#efe9fb; color:#6a4ca3; }}
|
|
96
|
+
.tag-amber {{ background:#f7efe0; color:#8a6420; }}
|
|
97
|
+
.tag-red {{ background:#fbe9eb; color:#b23a44; }}
|
|
98
|
+
table {{ width:100%; border-collapse:collapse; margin:.5rem 0; }}
|
|
99
|
+
th,td {{ border:1px solid var(--border); padding:.45rem .65rem; text-align:left; font-size:.85rem; }}
|
|
100
|
+
th {{ background:var(--panel); color:var(--heading); }}
|
|
101
|
+
pre {{ background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:.85rem; overflow-x:auto; font-size:.82rem; }}
|
|
102
|
+
code {{ font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; color:var(--heading); }}
|
|
103
|
+
a {{ color:var(--accent); }}
|
|
104
|
+
</style>
|
|
105
|
+
</head>
|
|
106
|
+
<body>
|
|
107
|
+
<h1>{title}</h1>
|
|
108
|
+
{body}
|
|
109
|
+
</body>
|
|
110
|
+
</html>"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _reload_script(sse_url: str, name: str) -> str:
|
|
114
|
+
"""A tiny SSE client: reload this page when its artifact is rebuilt."""
|
|
115
|
+
return f"""
|
|
116
|
+
<script>
|
|
117
|
+
(function() {{
|
|
118
|
+
try {{
|
|
119
|
+
const es = new EventSource("{sse_url}");
|
|
120
|
+
es.addEventListener("reload", function(e) {{
|
|
121
|
+
let d = {{}}; try {{ d = JSON.parse(e.data); }} catch(_) {{}}
|
|
122
|
+
if (!d.name || d.name === "{name}") location.reload();
|
|
123
|
+
}});
|
|
124
|
+
}} catch(_) {{}}
|
|
125
|
+
}})();
|
|
126
|
+
</script>"""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _inject_reload(doc: str, sse_url: str, name: str) -> str:
|
|
130
|
+
"""Insert the live-reload script before </body> (or append)."""
|
|
131
|
+
script = _reload_script(sse_url, name)
|
|
132
|
+
idx = doc.lower().rfind("</body>")
|
|
133
|
+
return doc[:idx] + script + doc[idx:] if idx != -1 else doc + script
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build_artifact_tools(*, workdir: Path, engine=None) -> dict[str, Tool]:
|
|
137
|
+
"""Return {create_artifact} bound to *workdir*.
|
|
138
|
+
|
|
139
|
+
At call time the tool consults ``engine.artifact_target`` (set by the TUI)
|
|
140
|
+
to decide live vs static and obtain the running server.
|
|
141
|
+
"""
|
|
142
|
+
artifact_dir = workdir / ARTIFACT_DIR
|
|
143
|
+
|
|
144
|
+
async def create_artifact(title: str, html: str) -> str:
|
|
145
|
+
"""Save a self-contained HTML artifact and open it in the browser."""
|
|
146
|
+
if len(html) > MAX_ARTIFACT_CHARS:
|
|
147
|
+
return (
|
|
148
|
+
f"[error] artifact too large: {len(html):,} chars "
|
|
149
|
+
f"(max {MAX_ARTIFACT_CHARS:,}). simplify the content."
|
|
150
|
+
)
|
|
151
|
+
artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
# The TUI decides live vs static (asking once on first use, lazy-starting
|
|
154
|
+
# the server). Headless/bench have no target → static file://.
|
|
155
|
+
target = getattr(engine, "artifact_target", None)
|
|
156
|
+
server = await target() if target is not None else None
|
|
157
|
+
|
|
158
|
+
stem = _safe_filename(title)
|
|
159
|
+
doc = _artifact_html(title, _extract_body(html))
|
|
160
|
+
|
|
161
|
+
if server is not None:
|
|
162
|
+
# Live: stable name, overwrite in place, auto-reload the open tab.
|
|
163
|
+
path = artifact_dir / f"{stem}.html"
|
|
164
|
+
existed = path.exists()
|
|
165
|
+
doc = _inject_reload(doc, f"{server.base_url}/api/events?t={server.token}", stem)
|
|
166
|
+
path.write_text(doc, encoding="utf-8")
|
|
167
|
+
# Percent-encode: CJK stems survive _safe_filename (isalnum), and a
|
|
168
|
+
# raw non-ASCII URL gets mangled by webbrowser/terminal handlers.
|
|
169
|
+
# ?t= is the per-session nonce every server endpoint requires.
|
|
170
|
+
url = f"{server.base_url}/artifacts/{quote(stem)}?t={server.token}"
|
|
171
|
+
if existed:
|
|
172
|
+
await server.broadcast("reload", {"name": stem}) # open tab refreshes
|
|
173
|
+
return f"[ok] artifact '{title}' updated live — {len(doc):,} chars\n url: {url}"
|
|
174
|
+
_open_browser(url)
|
|
175
|
+
return f"[ok] artifact '{title}' opened live — {len(doc):,} chars\n url: {url}"
|
|
176
|
+
|
|
177
|
+
# Static: unique filename, file://.
|
|
178
|
+
path = artifact_dir / f"{stem}.html"
|
|
179
|
+
n = 1
|
|
180
|
+
while path.exists():
|
|
181
|
+
path = artifact_dir / f"{stem}_{n}.html"
|
|
182
|
+
n += 1
|
|
183
|
+
path.write_text(doc, encoding="utf-8")
|
|
184
|
+
file_uri = path.resolve().as_uri()
|
|
185
|
+
_open_browser(file_uri)
|
|
186
|
+
return (
|
|
187
|
+
f"[ok] artifact saved — {len(doc):,} chars\n"
|
|
188
|
+
f" file: {path}\n"
|
|
189
|
+
f" opened in browser: {file_uri}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
schema = _fn_schema(
|
|
193
|
+
"create_artifact",
|
|
194
|
+
"Create a visual artifact (report, diagram, dashboard, summary) and open "
|
|
195
|
+
"it in the browser. Use for output better seen outside the terminal. "
|
|
196
|
+
"Provide the BODY content ONLY — headings (<h1>/<h2>), paragraphs, cards "
|
|
197
|
+
"(<div class='card'>), tables, <pre>/<code>, inline SVG/Mermaid. Do NOT "
|
|
198
|
+
"write <html>/<head>/<style>, and do NOT set your own colors or "
|
|
199
|
+
"background — rockycode applies its own LIGHT theme (soft purple on "
|
|
200
|
+
"near-white). Use the provided classes: card, tag/tag-purple/tag-amber/"
|
|
201
|
+
"tag-red. No external CDN/network references. Reuse the SAME title to "
|
|
202
|
+
"update in place (in live mode the open tab auto-refreshes).",
|
|
203
|
+
{
|
|
204
|
+
"title": {
|
|
205
|
+
"type": "string",
|
|
206
|
+
"description": "Short descriptive title (also the filename; reuse to update).",
|
|
207
|
+
},
|
|
208
|
+
"html": {
|
|
209
|
+
"type": "string",
|
|
210
|
+
"description": (
|
|
211
|
+
"Body content only — no <html>/<head>/<style> and no color/background "
|
|
212
|
+
"styles (rocky themes it). Use rocky's classes; embed SVG/Mermaid inline."
|
|
213
|
+
),
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
["title", "html"],
|
|
217
|
+
)
|
|
218
|
+
return {"create_artifact": Tool(name="create_artifact", schema=schema, fn=create_artifact)}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
# ArtifactServer — live mode (host browser reaches localhost)
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
class ArtifactServer:
|
|
226
|
+
"""Serve artifact files over localhost + an SSE stream for live reload.
|
|
227
|
+
|
|
228
|
+
Every endpoint requires the per-session nonce (``?t=<token>``, audit #7):
|
|
229
|
+
localhost services are reachable by any local process and — via the
|
|
230
|
+
browser — probeable by any web page the user has open, so unauthenticated
|
|
231
|
+
``/list``/``/api/events`` would leak artifact names and reload activity.
|
|
232
|
+
The token is baked into every URL rocky emits (open link, /list entries,
|
|
233
|
+
the injected reload script); it never needs to be typed.
|
|
234
|
+
|
|
235
|
+
Binding: 127.0.0.1 by default. Inside Docker (ROCKYCODE_IN_DOCKER) it must
|
|
236
|
+
bind 0.0.0.0 — Docker can't forward a published port to the container's
|
|
237
|
+
127.0.0.1 — so the compose/run mapping MUST publish the port on host
|
|
238
|
+
loopback only (``127.0.0.1:PORT:PORT``, as docker-compose.yml does);
|
|
239
|
+
publishing bare ``PORT:PORT`` would expose the server to the LAN.
|
|
240
|
+
Lifecycle: ``await start()`` when live mode turns on, ``await stop()`` on
|
|
241
|
+
exit.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def __init__(self, workdir: Path) -> None:
|
|
245
|
+
self.workdir = workdir
|
|
246
|
+
self.artifact_dir = workdir / ARTIFACT_DIR
|
|
247
|
+
self.port: int = 0
|
|
248
|
+
# Unguessable per-session nonce gating every endpoint (see class doc).
|
|
249
|
+
self.token: str = secrets.token_urlsafe(16)
|
|
250
|
+
self._app: "web.Application | None" = None
|
|
251
|
+
self._runner: "web.AppRunner | None" = None
|
|
252
|
+
self._sse_queues: set["asyncio.Queue"] = set()
|
|
253
|
+
|
|
254
|
+
def _require_token(self, request: "web.Request") -> None:
|
|
255
|
+
"""401 unless the request carries the session token (?t=...)."""
|
|
256
|
+
from aiohttp import web
|
|
257
|
+
|
|
258
|
+
if not secrets.compare_digest(request.query.get("t", ""), self.token):
|
|
259
|
+
raise web.HTTPUnauthorized(text="missing or invalid artifact token")
|
|
260
|
+
|
|
261
|
+
async def broadcast(self, event_type: str, data: dict | None = None) -> None:
|
|
262
|
+
"""Push an SSE event to all connected clients."""
|
|
263
|
+
import json as _json
|
|
264
|
+
chunk = f"event: {event_type}\ndata: {_json.dumps(data or {})}\n\n"
|
|
265
|
+
for q in list(self._sse_queues):
|
|
266
|
+
try:
|
|
267
|
+
q.put_nowait(chunk)
|
|
268
|
+
except asyncio.QueueFull:
|
|
269
|
+
self._sse_queues.discard(q)
|
|
270
|
+
|
|
271
|
+
async def start(self) -> None:
|
|
272
|
+
from aiohttp import web
|
|
273
|
+
|
|
274
|
+
self._app = web.Application()
|
|
275
|
+
self._app.router.add_get("/artifacts/{name}", self._serve_artifact)
|
|
276
|
+
self._app.router.add_get("/list", self._list_artifacts)
|
|
277
|
+
self._app.router.add_get("/api/events", self._handle_events)
|
|
278
|
+
|
|
279
|
+
self._runner = web.AppRunner(self._app)
|
|
280
|
+
await self._runner.setup()
|
|
281
|
+
# Host: loopback. Docker: 0.0.0.0 is required (the published port forwards
|
|
282
|
+
# to the container bridge, not its 127.0.0.1) — but compose binds the host
|
|
283
|
+
# side to 127.0.0.1, so the server is never LAN-exposed.
|
|
284
|
+
bind_host = "0.0.0.0" if os.environ.get("ROCKYCODE_IN_DOCKER") else "127.0.0.1"
|
|
285
|
+
fixed_port = int(os.environ["ROCKYCODE_ARTIFACT_PORT"]) if "ROCKYCODE_ARTIFACT_PORT" in os.environ else 0
|
|
286
|
+
site = web.TCPSite(self._runner, bind_host, fixed_port)
|
|
287
|
+
await site.start()
|
|
288
|
+
for sock in site._server.sockets:
|
|
289
|
+
self.port = sock.getsockname()[1]
|
|
290
|
+
break
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def base_url(self) -> str:
|
|
294
|
+
return f"http://localhost:{self.port}"
|
|
295
|
+
|
|
296
|
+
async def _serve_artifact(self, request: "web.Request") -> "web.Response":
|
|
297
|
+
from aiohttp import web
|
|
298
|
+
|
|
299
|
+
self._require_token(request)
|
|
300
|
+
# unquote: percent-encoded requests must match on-disk stems (which are
|
|
301
|
+
# stored decoded, CJK and all). Stems never contain '%' (_safe_filename
|
|
302
|
+
# maps it to '_'), so decoding an already-decoded name is a no-op.
|
|
303
|
+
name = unquote(request.match_info.get("name", "index"))
|
|
304
|
+
safe = _safe_filename(name)
|
|
305
|
+
if safe != name:
|
|
306
|
+
raise web.HTTPBadRequest(text="invalid artifact name")
|
|
307
|
+
fpath = self.artifact_dir / f"{safe}.html"
|
|
308
|
+
if not fpath.exists():
|
|
309
|
+
raise web.HTTPNotFound(text="artifact not found")
|
|
310
|
+
return web.FileResponse(fpath, headers={"Content-Type": "text/html; charset=utf-8"})
|
|
311
|
+
|
|
312
|
+
async def _list_artifacts(self, request: "web.Request") -> "web.Response":
|
|
313
|
+
from aiohttp import web
|
|
314
|
+
|
|
315
|
+
self._require_token(request)
|
|
316
|
+
files = sorted(self.artifact_dir.glob("*.html"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
317
|
+
items = [
|
|
318
|
+
{"name": f.stem, "size": f.stat().st_size,
|
|
319
|
+
"url": f"{self.base_url}/artifacts/{quote(f.stem)}?t={self.token}"}
|
|
320
|
+
for f in files[:50]
|
|
321
|
+
]
|
|
322
|
+
return web.json_response(items)
|
|
323
|
+
|
|
324
|
+
async def _handle_events(self, request: "web.Request") -> "web.StreamResponse":
|
|
325
|
+
"""SSE endpoint — streams reload events to open artifact tabs.
|
|
326
|
+
|
|
327
|
+
No CORS header: the only consumer is the reload script injected into
|
|
328
|
+
artifact pages served from this same origin. A wildcard here let any
|
|
329
|
+
web page EventSource the stream cross-origin (audit #7).
|
|
330
|
+
"""
|
|
331
|
+
from aiohttp import web
|
|
332
|
+
|
|
333
|
+
self._require_token(request)
|
|
334
|
+
resp = web.StreamResponse(
|
|
335
|
+
status=200,
|
|
336
|
+
reason="OK",
|
|
337
|
+
headers={
|
|
338
|
+
"Content-Type": "text/event-stream",
|
|
339
|
+
"Cache-Control": "no-cache",
|
|
340
|
+
"Connection": "keep-alive",
|
|
341
|
+
"X-Accel-Buffering": "no",
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
await resp.prepare(request)
|
|
345
|
+
queue: "asyncio.Queue" = asyncio.Queue(maxsize=64)
|
|
346
|
+
self._sse_queues.add(queue)
|
|
347
|
+
try:
|
|
348
|
+
await resp.write(b"event: connected\ndata: {}\n\n")
|
|
349
|
+
while True:
|
|
350
|
+
chunk = await asyncio.wait_for(queue.get(), timeout=30)
|
|
351
|
+
await resp.write(chunk.encode())
|
|
352
|
+
except asyncio.TimeoutError:
|
|
353
|
+
try:
|
|
354
|
+
await resp.write(b": keepalive\n\n")
|
|
355
|
+
except (ConnectionResetError, ConnectionError):
|
|
356
|
+
pass
|
|
357
|
+
except (ConnectionResetError, ConnectionError):
|
|
358
|
+
pass
|
|
359
|
+
finally:
|
|
360
|
+
self._sse_queues.discard(queue)
|
|
361
|
+
return resp
|
|
362
|
+
|
|
363
|
+
async def stop(self) -> None:
|
|
364
|
+
if self._runner is not None:
|
|
365
|
+
await self._runner.cleanup()
|
|
366
|
+
self._runner = None
|
|
367
|
+
self._app = None
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Budget caps for autonomous (goal) mode.
|
|
2
|
+
|
|
3
|
+
An overnight run stops — with a graceful finalize, never a crash — the moment it
|
|
4
|
+
hits ANY configured cap: a spend ceiling (in the user's currency, priced from
|
|
5
|
+
the real DeepSeek tables incl. the peak-hour surcharge), a wallclock limit, or a
|
|
6
|
+
total-token budget. The user sets any subset or takes the recommended defaults;
|
|
7
|
+
before the run we surface the worst-case spend so there are no surprises.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from rockycode.pricing import UsageLedger
|
|
16
|
+
|
|
17
|
+
# Recommended ceilings per currency (real numbers, not a conversion — the user
|
|
18
|
+
# overrides freely). A night, and a spend the user is comfortable leaving alone.
|
|
19
|
+
_RECOMMENDED_USD = {"usd": 5.0, "cny": 35.0}
|
|
20
|
+
_RECOMMENDED_SECONDS = 8 * 3600
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class GoalBudget:
|
|
25
|
+
max_usd: Optional[float] = None # spend ceiling, in `currency`
|
|
26
|
+
max_seconds: Optional[float] = None # wallclock limit
|
|
27
|
+
max_tokens: Optional[int] = None # total (prompt + completion) tokens
|
|
28
|
+
currency: str = "usd"
|
|
29
|
+
_t0: float = field(default=0.0, init=False, repr=False)
|
|
30
|
+
|
|
31
|
+
def start(self) -> None:
|
|
32
|
+
self._t0 = time.monotonic()
|
|
33
|
+
|
|
34
|
+
def elapsed(self) -> float:
|
|
35
|
+
return time.monotonic() - self._t0 if self._t0 else 0.0
|
|
36
|
+
|
|
37
|
+
def exceeded(self, ledger: UsageLedger) -> Optional[str]:
|
|
38
|
+
"""A human reason if any cap is now hit, else None. Checked each step;
|
|
39
|
+
the caller finalizes gracefully on a non-None result."""
|
|
40
|
+
if self.max_seconds is not None and self.elapsed() >= self.max_seconds:
|
|
41
|
+
return f"wallclock cap ({_dur(self.max_seconds)}) reached"
|
|
42
|
+
if self.max_usd is not None:
|
|
43
|
+
spent = ledger.cost(self.currency)
|
|
44
|
+
if spent >= self.max_usd:
|
|
45
|
+
return (f"spend cap ({_money(self.max_usd, self.currency)}) reached — "
|
|
46
|
+
f"{_money(spent, self.currency)} spent")
|
|
47
|
+
if self.max_tokens is not None:
|
|
48
|
+
t = ledger.totals()
|
|
49
|
+
if t["prompt"] + t["completion"] >= self.max_tokens:
|
|
50
|
+
return f"token cap ({self.max_tokens:,}) reached"
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def describe(self) -> str:
|
|
54
|
+
parts = []
|
|
55
|
+
if self.max_usd is not None:
|
|
56
|
+
parts.append(_money(self.max_usd, self.currency))
|
|
57
|
+
if self.max_seconds is not None:
|
|
58
|
+
parts.append(_dur(self.max_seconds))
|
|
59
|
+
if self.max_tokens is not None:
|
|
60
|
+
parts.append(f"{self.max_tokens:,} tokens")
|
|
61
|
+
return " or ".join(parts) if parts else "NO LIMIT (not recommended)"
|
|
62
|
+
|
|
63
|
+
def preflight_note(self) -> str:
|
|
64
|
+
"""One line shown before the run so the potential spend is explicit."""
|
|
65
|
+
if self.max_usd is not None:
|
|
66
|
+
return f"stops at {self.describe()} — up to {_money(self.max_usd, self.currency)} of API spend"
|
|
67
|
+
if self.max_tokens is not None or self.max_seconds is not None:
|
|
68
|
+
return (f"stops at {self.describe()} — no hard $ cap, so watch the token/time "
|
|
69
|
+
f"limit (add max_usd for a spend ceiling)")
|
|
70
|
+
return "WARNING: no budget cap set — an overnight run could spend without limit"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def recommended(currency: str = "usd") -> GoalBudget:
|
|
74
|
+
return GoalBudget(
|
|
75
|
+
max_usd=_RECOMMENDED_USD.get(currency, 5.0),
|
|
76
|
+
max_seconds=_RECOMMENDED_SECONDS,
|
|
77
|
+
currency=currency,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _money(amount: float, currency: str) -> str:
|
|
82
|
+
return f"{'¥' if currency == 'cny' else '$'}{amount:.2f}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _dur(seconds: float) -> str:
|
|
86
|
+
h, rem = divmod(int(seconds), 3600)
|
|
87
|
+
m = rem // 60
|
|
88
|
+
if h and m:
|
|
89
|
+
return f"{h}h{m}m"
|
|
90
|
+
return f"{h}h" if h else f"{m}m"
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Post-edit checks — run the project's OWN linter / type-checker and report.
|
|
2
|
+
|
|
3
|
+
The lightweight alternative to an LSP stack (see lsp.py): instead of a
|
|
4
|
+
language-server protocol, run the analyzers a human runs, auto-detected from the
|
|
5
|
+
repo. Same value ("did my change break lint/types?") at a fraction of the
|
|
6
|
+
machinery, and it matches what Claude Code does.
|
|
7
|
+
|
|
8
|
+
SAFETY: only DECLARATIVE analyzers are auto-run — ruff and tsc read config but
|
|
9
|
+
don't execute repo-authored code, and pyflakes has no config at all. We
|
|
10
|
+
deliberately do NOT auto-run mypy / eslint / `npm run lint`: their config can
|
|
11
|
+
load repo-authored plugins or scripts, which in a cloned repo is arbitrary code
|
|
12
|
+
execution (the same untrusted-clone vector we harden elsewhere). When no linter
|
|
13
|
+
is installed we fall back to BUNDLED pyflakes, so check_code always does
|
|
14
|
+
something useful on Python.
|
|
15
|
+
|
|
16
|
+
SAFETY (binary resolution): check-tool binaries are resolved from rocky's OWN
|
|
17
|
+
PATH only — NEVER from workdir/.venv. A cloned repo is untrusted; a trojan
|
|
18
|
+
`.venv/bin/pyright` there would be arbitrary code execution the moment
|
|
19
|
+
check_code (or goal-mode verify()) runs it — and check_code is risk="safe", so
|
|
20
|
+
it never prompts. Any resolved path that lands under workdir is refused too (an
|
|
21
|
+
activated project venv, or `.` on PATH). See _resolve.
|
|
22
|
+
|
|
23
|
+
detect_checks(workdir) -> [(label, argv)]
|
|
24
|
+
run_checks(workdir) -> str
|
|
25
|
+
build_check_tool(workdir) -> {"check_code": Tool} # the model-callable tool
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import importlib.util
|
|
31
|
+
import shutil
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _pyflakes_available() -> bool:
|
|
37
|
+
return importlib.util.find_spec("pyflakes") is not None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _under(path: Path, root: Path) -> bool:
|
|
41
|
+
"""True if *path* is *root* or lives beneath it (both resolved)."""
|
|
42
|
+
try:
|
|
43
|
+
path, root = path.resolve(), root.resolve()
|
|
44
|
+
except OSError:
|
|
45
|
+
return True # unresolvable → treat as unsafe (fail closed)
|
|
46
|
+
return path == root or root in path.parents
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve(tool: str, workdir: Path) -> str | None:
|
|
50
|
+
"""Find *tool* on rocky's OWN PATH — never inside the (untrusted) *workdir*.
|
|
51
|
+
|
|
52
|
+
We deliberately do NOT look in workdir/.venv or any project-local dir: a
|
|
53
|
+
cloned repo could ship a trojan `.venv/bin/pyright`, and check_code (risk=
|
|
54
|
+
"safe", and auto-run by goal-mode verify()) would execute it with no prompt.
|
|
55
|
+
So resolution is PATH-only, and a PATH hit that resolves to somewhere under
|
|
56
|
+
workdir (an activated project venv, or `.` on PATH) is refused as well —
|
|
57
|
+
check_code only ever runs a binary from rocky's own environment. A user who
|
|
58
|
+
wants a project-only linter can put it on PATH themselves. (pyflakes is
|
|
59
|
+
bundled with rocky and invoked via sys.executable, so Python always has a
|
|
60
|
+
fallback that needs no external binary.)
|
|
61
|
+
"""
|
|
62
|
+
found = shutil.which(tool)
|
|
63
|
+
if not found:
|
|
64
|
+
return None
|
|
65
|
+
if _under(Path(found), workdir):
|
|
66
|
+
return None # binary lives under the untrusted repo — refuse it
|
|
67
|
+
return found
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def detect_checks(workdir: Path) -> list[tuple[str, list[str]]]:
|
|
71
|
+
"""Return [(label, argv)] of applicable SAFE fast checks for *workdir*.
|
|
72
|
+
|
|
73
|
+
Only DECLARATIVE analyzers — ruff, pyright, tsc read config but never
|
|
74
|
+
execute repo-authored code, and pyflakes has no config. (mypy/eslint/npm are
|
|
75
|
+
excluded: their configs can load repo plugins/scripts = RCE in a cloned
|
|
76
|
+
repo.) Binaries resolve from rocky's own PATH only, never workdir/.venv
|
|
77
|
+
(see _resolve), so a trojan project-local binary is never executed.
|
|
78
|
+
"""
|
|
79
|
+
checks: list[tuple[str, list[str]]] = []
|
|
80
|
+
has_py = (workdir / "pyproject.toml").exists() or any(workdir.glob("*.py"))
|
|
81
|
+
if has_py:
|
|
82
|
+
ruff = _resolve("ruff", workdir)
|
|
83
|
+
if ruff:
|
|
84
|
+
checks.append(("ruff", [ruff, "check", "."]))
|
|
85
|
+
elif _pyflakes_available():
|
|
86
|
+
# Bundled fallback: pure-Python, no config, catches real bugs
|
|
87
|
+
# (undefined names, unused imports) — the F-rules ruff also runs.
|
|
88
|
+
checks.append(("pyflakes", [sys.executable, "-m", "pyflakes", "."]))
|
|
89
|
+
pyright = _resolve("pyright", workdir)
|
|
90
|
+
if pyright: # declarative config, no plugin code-exec → safe to auto-run
|
|
91
|
+
checks.append(("pyright", [pyright]))
|
|
92
|
+
tsc = _resolve("tsc", workdir)
|
|
93
|
+
if (workdir / "tsconfig.json").exists() and tsc:
|
|
94
|
+
checks.append(("tsc", [tsc, "--noEmit"]))
|
|
95
|
+
return checks
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _run_one(argv: list[str], workdir: Path, timeout: int) -> tuple[int, str]:
|
|
99
|
+
try:
|
|
100
|
+
proc = await asyncio.create_subprocess_exec(
|
|
101
|
+
*argv,
|
|
102
|
+
cwd=str(workdir),
|
|
103
|
+
stdout=asyncio.subprocess.PIPE,
|
|
104
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
105
|
+
)
|
|
106
|
+
except (FileNotFoundError, OSError) as e:
|
|
107
|
+
return 127, f"could not run {argv[0]}: {e}"
|
|
108
|
+
try:
|
|
109
|
+
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
110
|
+
except asyncio.TimeoutError:
|
|
111
|
+
proc.kill()
|
|
112
|
+
return 124, f"{argv[0]} timed out after {timeout}s"
|
|
113
|
+
return proc.returncode or 0, out.decode(errors="replace")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def run_checks(workdir: Path, *, timeout: int = 120, tail: int = 15) -> str:
|
|
117
|
+
"""Run every detected check; return a concise report ('' if nothing applies).
|
|
118
|
+
|
|
119
|
+
A non-zero exit shows the last *tail* lines so the model sees the concrete
|
|
120
|
+
errors without the whole log.
|
|
121
|
+
"""
|
|
122
|
+
checks = detect_checks(workdir)
|
|
123
|
+
if not checks:
|
|
124
|
+
return ""
|
|
125
|
+
blocks: list[str] = []
|
|
126
|
+
for label, argv in checks:
|
|
127
|
+
code, out = await _run_one(argv, workdir, timeout)
|
|
128
|
+
if code == 0:
|
|
129
|
+
blocks.append(f"[ok] {label} — no issues")
|
|
130
|
+
else:
|
|
131
|
+
body = "\n ".join(out.strip().splitlines()[-tail:])
|
|
132
|
+
blocks.append(f"[FAILED] {label} (exit {code})\n {body}")
|
|
133
|
+
return "\n".join(blocks)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build_check_tool(workdir: Path) -> dict:
|
|
137
|
+
"""The model-callable `check_code` tool: run the project's checks on demand."""
|
|
138
|
+
from rockycode.engine.tools import Tool, _fn_schema
|
|
139
|
+
|
|
140
|
+
async def check_code() -> str:
|
|
141
|
+
report = await run_checks(workdir)
|
|
142
|
+
return report or (
|
|
143
|
+
"[no checks] no supported linter / type-checker for this project "
|
|
144
|
+
"(ruff or bundled pyflakes for Python, tsc for TypeScript)."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
schema = _fn_schema(
|
|
148
|
+
"check_code",
|
|
149
|
+
"Run the project's own linter / type-checker — ruff and pyright (or "
|
|
150
|
+
"bundled pyflakes when ruff isn't installed) for Python, tsc for "
|
|
151
|
+
"TypeScript — resolved from rocky's PATH, and report "
|
|
152
|
+
"errors. Call it after editing to verify you didn't introduce lint or "
|
|
153
|
+
"type errors — grounded feedback, not a guess. Read-only; project-wide.",
|
|
154
|
+
{},
|
|
155
|
+
[],
|
|
156
|
+
)
|
|
157
|
+
return {"check_code": Tool(name="check_code", schema=schema, fn=check_code, risk="safe")}
|