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.
Files changed (83) hide show
  1. rockycode/__init__.py +1 -0
  2. rockycode/banner.py +37 -0
  3. rockycode/cli.py +1386 -0
  4. rockycode/config.py +178 -0
  5. rockycode/dream/__init__.py +9 -0
  6. rockycode/dream/core.py +523 -0
  7. rockycode/dream/judge.py +134 -0
  8. rockycode/dream/mining.py +152 -0
  9. rockycode/dream/proposals.py +440 -0
  10. rockycode/engine/__init__.py +10 -0
  11. rockycode/engine/artifact.py +367 -0
  12. rockycode/engine/budget.py +90 -0
  13. rockycode/engine/checks.py +157 -0
  14. rockycode/engine/compaction.py +181 -0
  15. rockycode/engine/container.py +225 -0
  16. rockycode/engine/effort.py +46 -0
  17. rockycode/engine/events.py +101 -0
  18. rockycode/engine/explore.py +592 -0
  19. rockycode/engine/goal.py +541 -0
  20. rockycode/engine/goal_review.py +161 -0
  21. rockycode/engine/goal_session.py +259 -0
  22. rockycode/engine/headless.py +481 -0
  23. rockycode/engine/loop.py +711 -0
  24. rockycode/engine/lsp.py +473 -0
  25. rockycode/engine/mcp.py +364 -0
  26. rockycode/engine/modes.py +123 -0
  27. rockycode/engine/outcome.py +81 -0
  28. rockycode/engine/permission.py +198 -0
  29. rockycode/engine/planmode.py +249 -0
  30. rockycode/engine/providers.py +196 -0
  31. rockycode/engine/redact.py +83 -0
  32. rockycode/engine/safety.py +139 -0
  33. rockycode/engine/sandbox.py +219 -0
  34. rockycode/engine/server.py +431 -0
  35. rockycode/engine/skills.py +178 -0
  36. rockycode/engine/titler.py +46 -0
  37. rockycode/engine/tools.py +479 -0
  38. rockycode/engine/trajectory.py +131 -0
  39. rockycode/engine/web.py +431 -0
  40. rockycode/engine/worktree.py +128 -0
  41. rockycode/memory/__init__.py +7 -0
  42. rockycode/memory/index.py +260 -0
  43. rockycode/memory/store.py +331 -0
  44. rockycode/modes/learn/learn.md +46 -0
  45. rockycode/modes/research/deep-research.md +53 -0
  46. rockycode/modes/research/paper-reading.md +49 -0
  47. rockycode/modes/research/prove.md +60 -0
  48. rockycode/modes/research/whiteboard.md +64 -0
  49. rockycode/onboarding.py +332 -0
  50. rockycode/palette.py +15 -0
  51. rockycode/pricing.py +178 -0
  52. rockycode/prompts/__init__.py +0 -0
  53. rockycode/prompts/rocky.py +257 -0
  54. rockycode/routines.py +287 -0
  55. rockycode/runners/__init__.py +0 -0
  56. rockycode/runners/agent.py +273 -0
  57. rockycode/runners/data.py +61 -0
  58. rockycode/runners/raw.py +176 -0
  59. rockycode/score.py +114 -0
  60. rockycode/session.py +298 -0
  61. rockycode/skills/architecture-viz/SKILL.md +71 -0
  62. rockycode/skills/architecture-viz/template.html +87 -0
  63. rockycode/skills/lean-prover/SKILL.md +155 -0
  64. rockycode/skills/lean-prover/torchlean-api.md +85 -0
  65. rockycode/tui/__init__.py +1 -0
  66. rockycode/tui/app.py +2450 -0
  67. rockycode/tui/exitsheet.py +181 -0
  68. rockycode/tui/goal_screen.py +315 -0
  69. rockycode/tui/mdterm.py +232 -0
  70. rockycode/tui/mdview.py +99 -0
  71. rockycode/tui/modepicker.py +103 -0
  72. rockycode/tui/permission.py +154 -0
  73. rockycode/tui/plangate.py +110 -0
  74. rockycode/tui/prompt_history.py +77 -0
  75. rockycode/tui/proposalcard.py +126 -0
  76. rockycode/tui/resume.py +142 -0
  77. rockycode/tui/rocky_pet.py +96 -0
  78. rockycode/tui/routinecard.py +123 -0
  79. rockycode-0.1.0.dist-info/METADATA +488 -0
  80. rockycode-0.1.0.dist-info/RECORD +83 -0
  81. rockycode-0.1.0.dist-info/WHEEL +4 -0
  82. rockycode-0.1.0.dist-info/entry_points.txt +2 -0
  83. 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")}