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,473 @@
1
+ """LSP bridge — connect to a language server via MCP and expose diagnostics.
2
+
3
+ Design: reuses the MCP actor pattern from mcp.py (one asyncio task per
4
+ connection, Queue-based dispatch, ready Future). The external MCP server
5
+ (e.g. karellen-lsp-mcp, zircote/lsp-tools) wraps an actual LSP server
6
+ (pyright / gopls / typescript-language-server) and exposes its operations
7
+ as MCP tools.
8
+
9
+ Phase 1: diagnostics-only. Phase 2 adds the 4 semantic tools.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import os
15
+ from pathlib import Path
16
+
17
+
18
+ class LSPManager:
19
+ """Connect to an LSP MCP server and provide diagnostics.
20
+
21
+ If *config* is None the manager is a no-op — diagnostics always returns
22
+ an empty string. This lets callers unconditionally append without
23
+ checking whether LSP is active.
24
+ """
25
+
26
+ CALL_TIMEOUT_S = 30 # diagnostics on a cold LSP can take a moment
27
+
28
+ def __init__(self, command: str, args: list[str] | None = None) -> None:
29
+ self.command = command
30
+ self.args = args or []
31
+ self._queue: asyncio.Queue | None = None
32
+ self.ready: asyncio.Future | None = None
33
+ self._task: asyncio.Task | None = None
34
+ self._tool_names: set[str] = set()
35
+
36
+ # ------------------------------------------------------------------
37
+ async def start(self) -> None:
38
+ """Launch the LSP MCP server subprocess and initialise the session."""
39
+ self._queue = asyncio.Queue()
40
+ self.ready = asyncio.get_event_loop().create_future()
41
+ self._task = asyncio.create_task(self._run(), name="lsp-bridge")
42
+
43
+ async def _run(self) -> None:
44
+ from mcp import ClientSession, StdioServerParameters
45
+ from mcp.client.stdio import stdio_client
46
+
47
+ from rockycode.engine.mcp import _safe_child_env
48
+
49
+ devnull = open(os.devnull, "w")
50
+ params = StdioServerParameters(
51
+ command=self.command,
52
+ args=self.args,
53
+ env=_safe_child_env({}), # scrub secrets from the LSP subprocess too
54
+ )
55
+ try:
56
+ async with stdio_client(params, errlog=devnull) as (read, write):
57
+ async with ClientSession(read, write) as session:
58
+ await session.initialize()
59
+ listed = await session.list_tools()
60
+ self._tool_names = {t.name for t in listed.tools}
61
+ if not self.ready.done():
62
+ self.ready.set_result(None)
63
+ while True:
64
+ req = await self._queue.get()
65
+ if req is None:
66
+ return
67
+ tool_name, arguments, fut = req
68
+ try:
69
+ result = await asyncio.wait_for(
70
+ session.call_tool(tool_name, arguments),
71
+ timeout=self.CALL_TIMEOUT_S,
72
+ )
73
+ if not fut.done():
74
+ fut.set_result(result)
75
+ except Exception as exc: # noqa: BLE001
76
+ if not fut.done():
77
+ fut.set_exception(exc)
78
+ except Exception as exc: # noqa: BLE001
79
+ if not self.ready.done():
80
+ self.ready.set_exception(exc)
81
+ finally:
82
+ # Worker is exiting (server died, or stop requested): fail every
83
+ # still-queued request so its caller's `await fut` returns instead of
84
+ # hanging forever. Pairs with the wait_for timeout in _call.
85
+ devnull.close()
86
+ if self._queue is not None:
87
+ while not self._queue.empty():
88
+ try:
89
+ item = self._queue.get_nowait()
90
+ except asyncio.QueueEmpty:
91
+ break
92
+ if item is not None and not item[2].done():
93
+ item[2].set_exception(RuntimeError("lsp server stopped"))
94
+
95
+ async def _call(self, tool_name: str, arguments: dict) -> str:
96
+ # No live worker (never started, or the server process died) → don't
97
+ # enqueue into a queue nothing will drain; that used to hang forever.
98
+ if self._task is None or self._queue is None or self._task.done():
99
+ return ""
100
+ fut: asyncio.Future = asyncio.get_event_loop().create_future()
101
+ await self._queue.put((tool_name, arguments, fut))
102
+ try:
103
+ # Bound the wait: a worker that dies AFTER we enqueue, or a wedged
104
+ # server, must never block the agent loop. A hair above the per-call
105
+ # timeout inside _run so a genuinely slow call reports its own error.
106
+ result = await asyncio.wait_for(fut, timeout=self.CALL_TIMEOUT_S + 5)
107
+ except asyncio.TimeoutError:
108
+ return ""
109
+ # MCP CallToolResult — first text content
110
+ for c in (result.content or []):
111
+ if hasattr(c, "text"):
112
+ return c.text
113
+ return ""
114
+
115
+ # ------------------------------------------------------------------
116
+ # public API
117
+ # ------------------------------------------------------------------
118
+
119
+ async def get_diagnostics(self, path: str | Path) -> str:
120
+ """Return formatted diagnostics for *path*, or '' if none / no LSP."""
121
+ if self._task is None:
122
+ return ""
123
+ try:
124
+ raw = await self._call("diagnostics", {"path": str(path)})
125
+ except Exception: # noqa: BLE001
126
+ return ""
127
+ if not raw or not raw.strip():
128
+ return ""
129
+ lines = raw.strip().splitlines()
130
+ if not lines:
131
+ return ""
132
+ header = "\n LSP diagnostics\n " + "-" * 39
133
+ body = "\n ".join(lines[:20]) # cap at 20 diag lines
134
+ if len(lines) > 20:
135
+ body += f"\n ... and {len(lines) - 20} more"
136
+ return header + "\n " + body
137
+
138
+ async def stop(self) -> None:
139
+ if self._task is None or self._queue is None:
140
+ return
141
+ await self._queue.put(None)
142
+ try:
143
+ await asyncio.wait_for(self._task, timeout=5)
144
+ except (asyncio.TimeoutError, Exception): # noqa: BLE001
145
+ self._task.cancel()
146
+
147
+ @property
148
+ def is_running(self) -> bool:
149
+ return self._task is not None and not self._task.done()
150
+
151
+ # ------------------------------------------------------------------
152
+ # tool names exposed by the LSP MCP server (for Phase 2)
153
+ # ------------------------------------------------------------------
154
+
155
+ @property
156
+ def available_tools(self) -> set[str]:
157
+ return self._tool_names
158
+
159
+
160
+ # ------------------------------------------------------------------
161
+ # resolve an LSP MCP server config from the environment
162
+ # ------------------------------------------------------------------
163
+
164
+ def resolve_lsp_config() -> tuple[str, list[str]] | None:
165
+ """Return (command, args) for an LSP MCP server, or None.
166
+
167
+ Checks, in order:
168
+ 1. ROCKYCODE_LSP_COMMAND env var (shell-split on whitespace)
169
+ 2. Auto-detection: pyright --version → karellen-lsp-mcp-like wrapper
170
+ """
171
+ env_cmd = os.getenv("ROCKYCODE_LSP_COMMAND")
172
+ if env_cmd:
173
+ parts = env_cmd.split()
174
+ return parts[0], parts[1:]
175
+ return None
176
+
177
+
178
+ # ------------------------------------------------------------------
179
+ # Phase 2 — four composite LSP tools
180
+ # ------------------------------------------------------------------
181
+
182
+ # Map our semantic actions to likely MCP tool names on the LSP server.
183
+ # Multiple variants are tried in order — the first available one wins.
184
+ _ACTION_TOOL_MAP: dict[str, list[str]] = {
185
+ "definition": ["definition", "go_to_definition", "goToDefinition"],
186
+ "references": ["references", "find_references", "findReferences"],
187
+ "hover": ["hover", "hover_info", "hoverInfo"],
188
+ "implementation": ["implementation", "go_to_implementation", "goToImplementation"],
189
+ "callers": ["callers", "incoming_calls", "incomingCalls", "call_hierarchy_incoming"],
190
+ "callees": ["callees", "outgoing_calls", "outgoingCalls", "call_hierarchy_outgoing"],
191
+ }
192
+
193
+
194
+ def build_lsp_tools(lsp: "LSPManager") -> dict:
195
+ """Return 4 composite LSP tools bound to *lsp*.
196
+
197
+ Each tool produces human-readable text, not raw JSON — designed for
198
+ DeepSeek's reasoning_content to digest efficiently.
199
+ """
200
+ from rockycode.engine.tools import Tool, _fn_schema, _truncate
201
+
202
+ def _pick_tool(actions: list[str]) -> str | None:
203
+ """Return the first MCP tool name from *actions* that the server supports."""
204
+ for name in actions:
205
+ if name in lsp.available_tools:
206
+ return name
207
+ return None
208
+
209
+ async def _call_or_fallback(tool_names: list[str], arguments: dict) -> str:
210
+ tool = _pick_tool(tool_names)
211
+ if tool is None:
212
+ return f"[error] lsp server does not support this operation (tried: {', '.join(tool_names[:3])})"
213
+ try:
214
+ raw = await lsp._call(tool, arguments)
215
+ except Exception as exc: # noqa: BLE001
216
+ return f"[error] lsp call failed: {type(exc).__name__}: {exc}"
217
+ if not raw or not raw.strip():
218
+ return "[no results]"
219
+ return raw.strip()
220
+
221
+ # -- lsp_lookup -------------------------------------------------------
222
+
223
+ _LOOKUP_ACTIONS = ["definition", "references", "hover", "implementation", "callers", "callees"]
224
+
225
+ async def lsp_lookup(symbol: str, action: str) -> str:
226
+ """Look up a symbol — definition, references, hover info, etc."""
227
+ action = action.lower().strip()
228
+ if action not in _LOOKUP_ACTIONS:
229
+ return f"[error] unknown action '{action}'. choose: {', '.join(_LOOKUP_ACTIONS)}"
230
+ tool_names = _ACTION_TOOL_MAP[action]
231
+ result = await _call_or_fallback(tool_names, {"symbol": symbol})
232
+ header = f"lsp {action}: {symbol}\n" + "-" * (len(action) + len(symbol) + 5)
233
+ return _truncate(f"{header}\n{result}", 8000)
234
+
235
+ # -- lsp_symbol_search -------------------------------------------------
236
+
237
+ async def lsp_symbol_search(query: str) -> str:
238
+ """Search for symbols across the project by name (fuzzy)."""
239
+ result = await _call_or_fallback(
240
+ ["workspace_symbol", "workspaceSymbol", "symbol_search", "search_symbols"],
241
+ {"query": query},
242
+ )
243
+ header = f"symbol search: {query}\n" + "-" * (len(query) + 15)
244
+ return _truncate(f"{header}\n{result}", 8000)
245
+
246
+ # -- lsp_file_symbols --------------------------------------------------
247
+
248
+ async def lsp_file_symbols(path: str) -> str:
249
+ """List all symbols in a file (classes, functions, etc.) with signatures."""
250
+ result = await _call_or_fallback(
251
+ ["document_symbol", "documentSymbol", "file_symbols"],
252
+ {"path": path},
253
+ )
254
+ header = f"symbols in {path}\n" + "-" * (len(path) + 12)
255
+ return _truncate(f"{header}\n{result}", 8000)
256
+
257
+ # -- lsp_diagnostics (active query) ------------------------------------
258
+
259
+ async def lsp_diagnostics(path: str = "") -> str:
260
+ """Get diagnostics for a file, or project-wide if path is empty."""
261
+ args = {"path": path} if path else {}
262
+ result = await _call_or_fallback(
263
+ ["diagnostics", "get_diagnostics", "publish_diagnostics"],
264
+ args,
265
+ )
266
+ label = path or "project"
267
+ header = f"diagnostics: {label}\n" + "-" * (len(label) + 14)
268
+ return _truncate(f"{header}\n{result}", 8000)
269
+
270
+ # -- schemas -----------------------------------------------------------
271
+
272
+ schemas = {
273
+ "lsp_lookup": _fn_schema(
274
+ "lsp_lookup",
275
+ "Look up a symbol using LSP code intelligence — get its definition, "
276
+ "all references, type signature (hover), interface implementations, "
277
+ "or who calls / is called by it. One call replaces several grep "
278
+ "searches with precise, compiler-accurate results.",
279
+ {
280
+ "symbol": {
281
+ "type": "string",
282
+ "description": "Fully-qualified or plain symbol name, e.g. 'UserModel.get_name' or 'get_name'.",
283
+ },
284
+ "action": {
285
+ "type": "string",
286
+ "enum": _LOOKUP_ACTIONS,
287
+ "description": "What to look up: definition, references, hover, implementation, callers, callees.",
288
+ },
289
+ },
290
+ ["symbol", "action"],
291
+ ),
292
+ "lsp_symbol_search": _fn_schema(
293
+ "lsp_symbol_search",
294
+ "Search for symbols across the entire project by name. "
295
+ "Returns matching classes, functions, interfaces — fuzzy, not regex. "
296
+ "Use this to find where something is defined when you don't know the file.",
297
+ {
298
+ "query": {
299
+ "type": "string",
300
+ "description": "Partial symbol name to search for, e.g. 'UserAuth' or 'handle_login'.",
301
+ },
302
+ },
303
+ ["query"],
304
+ ),
305
+ "lsp_file_symbols": _fn_schema(
306
+ "lsp_file_symbols",
307
+ "List all symbols (classes, functions, methods, variables) in a file "
308
+ "with their type signatures. Use to quickly understand a file's "
309
+ "structure without reading every line.",
310
+ {
311
+ "path": {
312
+ "type": "string",
313
+ "description": "Path to the file to inspect.",
314
+ },
315
+ },
316
+ ["path"],
317
+ ),
318
+ "lsp_diagnostics": _fn_schema(
319
+ "lsp_diagnostics",
320
+ "Get compiler/linter diagnostics for a file, or project-wide if no "
321
+ "path is given. Returns errors, warnings, and hints with line numbers. "
322
+ "Use after making changes to verify correctness.",
323
+ {
324
+ "path": {
325
+ "type": "string",
326
+ "description": "File path to check. Omit for project-wide diagnostics.",
327
+ },
328
+ },
329
+ [], # path is optional
330
+ ),
331
+ }
332
+
333
+ fns = {
334
+ "lsp_lookup": lsp_lookup,
335
+ "lsp_symbol_search": lsp_symbol_search,
336
+ "lsp_file_symbols": lsp_file_symbols,
337
+ "lsp_diagnostics": lsp_diagnostics,
338
+ }
339
+ return {name: Tool(name=name, schema=schemas[name], fn=fns[name]) for name in fns}
340
+
341
+
342
+ # ------------------------------------------------------------------
343
+ # Phase 4 — multi-tenant LSP manager
344
+ # ------------------------------------------------------------------
345
+
346
+ class MultiTenantLSPManager:
347
+ """Lazy per-session LSP connections with idle timeout.
348
+
349
+ Each session (sandbox / worktree / bench task) gets its own LSPManager
350
+ instance, keyed by a string session_id. Sessions are created on first
351
+ access and destroyed after *idle_timeout* seconds of inactivity.
352
+
353
+ In single-session mode the key ``"default"`` is used automatically,
354
+ making MultiTenantLSPManager a drop-in replacement for LSPManager.
355
+ """
356
+
357
+ IDLE_TIMEOUT = 300 # 5 minutes
358
+
359
+ def __init__(
360
+ self,
361
+ command: str,
362
+ args: list[str] | None = None,
363
+ *,
364
+ idle_timeout: int = IDLE_TIMEOUT,
365
+ ) -> None:
366
+ self._command = command
367
+ self._args = args or []
368
+ self._idle_timeout = idle_timeout
369
+ self._sessions: dict[str, LSPManager] = {}
370
+ self._last_used: dict[str, float] = {}
371
+ self._cleanup_task: asyncio.Task | None = None
372
+
373
+ # -- lifecycle -----------------------------------------------------------
374
+
375
+ async def start(self, session_id: str = "default") -> None:
376
+ """Ensure a session is started. Safe to call multiple times."""
377
+ import time as _time
378
+ if session_id not in self._sessions:
379
+ mgr = LSPManager(self._command, list(self._args))
380
+ await mgr.start()
381
+ self._sessions[session_id] = mgr
382
+ self._last_used[session_id] = _time.monotonic()
383
+
384
+ async def start_cleanup_loop(self) -> None:
385
+ """Begin background cleanup of idle sessions. Call once."""
386
+ if self._cleanup_task is not None:
387
+ return
388
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop(), name="lsp-cleanup")
389
+
390
+ async def _cleanup_loop(self) -> None:
391
+ import time as _time
392
+ while True:
393
+ await asyncio.sleep(min(self._idle_timeout // 2, 60))
394
+ now = _time.monotonic()
395
+ stale = [
396
+ sid for sid, last in self._last_used.items()
397
+ if sid != "default" and now - last > self._idle_timeout
398
+ ]
399
+ for sid in stale:
400
+ mgr = self._sessions.pop(sid, None)
401
+ self._last_used.pop(sid, None)
402
+ if mgr is not None:
403
+ try:
404
+ await mgr.stop()
405
+ except Exception: # noqa: BLE001
406
+ pass
407
+
408
+ # -- per-session ---------------------------------------------------------
409
+
410
+ async def get_or_create(self, session_id: str = "default") -> LSPManager:
411
+ """Return the LSPManager for *session_id*, creating it if needed."""
412
+ import time as _time
413
+ if session_id not in self._sessions:
414
+ await self.start(session_id)
415
+ self._last_used[session_id] = _time.monotonic()
416
+ return self._sessions[session_id]
417
+
418
+ async def evict(self, session_id: str) -> None:
419
+ """Stop and remove a session (e.g. sandbox destroyed)."""
420
+ mgr = self._sessions.pop(session_id, None)
421
+ self._last_used.pop(session_id, None)
422
+ if mgr is not None:
423
+ await mgr.stop()
424
+
425
+ # -- delegation (for the default session) --------------------------------
426
+
427
+ async def get_diagnostics(self, path: str | Path) -> str:
428
+ """Get diagnostics from the default session (no auto-create)."""
429
+ mgr = self._sessions.get("default")
430
+ if mgr is None:
431
+ return ""
432
+ try:
433
+ return await mgr.get_diagnostics(path)
434
+ except Exception: # noqa: BLE001
435
+ return ""
436
+
437
+ @property
438
+ def is_running(self) -> bool:
439
+ mgr = self._sessions.get("default")
440
+ return mgr is not None and mgr.is_running
441
+
442
+ @property
443
+ def available_tools(self) -> set[str]:
444
+ mgr = self._sessions.get("default")
445
+ return mgr.available_tools if mgr else set()
446
+
447
+ @property
448
+ def command(self) -> str:
449
+ return self._command
450
+
451
+ async def _call(self, tool_name: str, arguments: dict) -> str:
452
+ mgr = self._sessions.get("default")
453
+ if mgr is None:
454
+ return "[error] lsp not connected — start a session first"
455
+ return await mgr._call(tool_name, arguments)
456
+
457
+ async def stop(self) -> None:
458
+ if self._cleanup_task is not None:
459
+ self._cleanup_task.cancel()
460
+ self._cleanup_task = None
461
+ for mgr in self._sessions.values():
462
+ try:
463
+ await mgr.stop()
464
+ except Exception: # noqa: BLE001
465
+ pass
466
+ self._sessions.clear()
467
+ self._last_used.clear()
468
+
469
+ @property
470
+ def ready(self) -> "asyncio.Future | None":
471
+ """Expose the default session's ready Future for compatibility."""
472
+ mgr = self._sessions.get("default")
473
+ return mgr.ready if mgr else None