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,431 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 server over stdin/stdout — wraps the Engine for editor clients.
|
|
2
|
+
|
|
3
|
+
One line per JSON message (NDJSON, same transport as LSP). The server manages
|
|
4
|
+
multiple sessions; each session has its own Engine instance and its own
|
|
5
|
+
run_turn task. Events stream out as notifications; permission requests become
|
|
6
|
+
blocking request/response pairs.
|
|
7
|
+
|
|
8
|
+
This is the backend the VS Code extension connects through.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import traceback
|
|
17
|
+
import uuid
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import AsyncIterator, Optional
|
|
21
|
+
|
|
22
|
+
from rockycode.engine import Engine
|
|
23
|
+
from rockycode.engine.events import (
|
|
24
|
+
Compacted,
|
|
25
|
+
EngineError,
|
|
26
|
+
StateChanged,
|
|
27
|
+
TextDelta,
|
|
28
|
+
ThinkingDelta,
|
|
29
|
+
ToolFinished,
|
|
30
|
+
ToolStarted,
|
|
31
|
+
TurnFinished,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
# protocol helpers
|
|
36
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _notify(method: str, params: dict) -> dict:
|
|
40
|
+
return {"jsonrpc": "2.0", "method": method, "params": params}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _response(msg_id, result):
|
|
44
|
+
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _error(msg_id, code: int, message: str):
|
|
48
|
+
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_DEBUG = os.getenv("ROCKYCODE_SERVE_DEBUG") == "1"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _client_error() -> str:
|
|
55
|
+
"""A message safe to send to the client. The full traceback goes to stderr
|
|
56
|
+
for the operator; the client sees a generic string unless
|
|
57
|
+
ROCKYCODE_SERVE_DEBUG=1 — so a hostile client can't harvest local paths /
|
|
58
|
+
internals from an error over the wire. Call only from inside an `except`."""
|
|
59
|
+
tb = traceback.format_exc()
|
|
60
|
+
print(tb, file=sys.stderr, flush=True)
|
|
61
|
+
return tb if _DEBUG else "internal error"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _scrub_message(msg: str) -> str:
|
|
65
|
+
"""Client-safe error text for streamed session/error events: redact secrets
|
|
66
|
+
and the user's home path so a hostile editor client can't harvest local
|
|
67
|
+
paths / internals (parity with _client_error). Full text under
|
|
68
|
+
ROCKYCODE_SERVE_DEBUG=1."""
|
|
69
|
+
if _DEBUG:
|
|
70
|
+
return msg
|
|
71
|
+
try:
|
|
72
|
+
from rockycode.engine.redact import redact
|
|
73
|
+
msg = redact(msg)
|
|
74
|
+
except Exception: # noqa: BLE001 — best-effort
|
|
75
|
+
pass
|
|
76
|
+
try:
|
|
77
|
+
home = str(Path.home())
|
|
78
|
+
if home and home != "/":
|
|
79
|
+
msg = msg.replace(home, "~")
|
|
80
|
+
except Exception: # noqa: BLE001
|
|
81
|
+
pass
|
|
82
|
+
return msg
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _event_to_notification(session_id: str, ev) -> Optional[dict]:
|
|
86
|
+
"""Convert an engine Event to a JSON-RPC notification dict, or None to skip."""
|
|
87
|
+
if isinstance(ev, StateChanged):
|
|
88
|
+
return _notify("session/state_changed", {
|
|
89
|
+
"session_id": session_id, "state": ev.state.value,
|
|
90
|
+
})
|
|
91
|
+
if isinstance(ev, ThinkingDelta):
|
|
92
|
+
return _notify("session/thinking_delta", {
|
|
93
|
+
"session_id": session_id, "text": ev.text,
|
|
94
|
+
})
|
|
95
|
+
if isinstance(ev, TextDelta):
|
|
96
|
+
return _notify("session/text_delta", {
|
|
97
|
+
"session_id": session_id, "text": ev.text,
|
|
98
|
+
})
|
|
99
|
+
if isinstance(ev, ToolStarted):
|
|
100
|
+
return _notify("session/tool_started", {
|
|
101
|
+
"session_id": session_id, "call_id": ev.call_id, "tool": ev.tool,
|
|
102
|
+
"args": ev.args,
|
|
103
|
+
})
|
|
104
|
+
if isinstance(ev, ToolFinished):
|
|
105
|
+
return _notify("session/tool_finished", {
|
|
106
|
+
"session_id": session_id, "call_id": ev.call_id, "tool": ev.tool,
|
|
107
|
+
"output": ev.output, "ok": ev.ok, "duration_s": ev.duration_s,
|
|
108
|
+
})
|
|
109
|
+
if isinstance(ev, Compacted):
|
|
110
|
+
return _notify("session/compacted", {
|
|
111
|
+
"session_id": session_id, "strategy": ev.strategy,
|
|
112
|
+
"tokens_before": ev.tokens_before, "tokens_after": ev.tokens_after,
|
|
113
|
+
})
|
|
114
|
+
if isinstance(ev, TurnFinished):
|
|
115
|
+
return _notify("session/turn_finished", {
|
|
116
|
+
"session_id": session_id, "steps": ev.steps, "usage": ev.usage,
|
|
117
|
+
})
|
|
118
|
+
if isinstance(ev, EngineError):
|
|
119
|
+
return _notify("session/error", {
|
|
120
|
+
"session_id": session_id, "message": _scrub_message(ev.message),
|
|
121
|
+
})
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
126
|
+
# output — thread-safe single-line JSON to stdout
|
|
127
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
def _write_line(msg: dict) -> None:
|
|
130
|
+
"""Write one JSON-RPC message line to stdout. NOT async — called from both
|
|
131
|
+
the main server loop and session tasks. The write+flush has no await between
|
|
132
|
+
them, so within one event loop two coroutines can't interleave bytes. stdout
|
|
133
|
+
carries ONLY protocol JSON; logs and errors go to stderr."""
|
|
134
|
+
line = json.dumps(msg, ensure_ascii=False, default=str) + "\n"
|
|
135
|
+
sys.stdout.write(line)
|
|
136
|
+
sys.stdout.flush()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
# session
|
|
141
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Session:
|
|
145
|
+
def __init__(self, session_id: str, engine: Engine):
|
|
146
|
+
self.session_id = session_id
|
|
147
|
+
self.engine = engine
|
|
148
|
+
self._task: Optional[asyncio.Task] = None
|
|
149
|
+
self.created_at = datetime.now(timezone.utc).isoformat()
|
|
150
|
+
|
|
151
|
+
def cancel(self) -> None:
|
|
152
|
+
if self._task and not self._task.done():
|
|
153
|
+
self._task.cancel()
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def running(self) -> bool:
|
|
157
|
+
return self._task is not None and not self._task.done()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
# session manager
|
|
162
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class SessionManager:
|
|
166
|
+
def __init__(self, model: str, workdir: Path, **engine_kwargs):
|
|
167
|
+
self._model = model
|
|
168
|
+
self._workdir = workdir
|
|
169
|
+
self._engine_kwargs = engine_kwargs
|
|
170
|
+
self._system_prompt = engine_kwargs.pop("system_prompt", "") or ""
|
|
171
|
+
self._sessions: dict[str, Session] = {}
|
|
172
|
+
# Permission bridge: engine approver → JSON-RPC notification → wait
|
|
173
|
+
# for client's session/permission_response.
|
|
174
|
+
self._perm_events: dict[str, asyncio.Event] = {}
|
|
175
|
+
self._perm_results: dict[str, bool] = {}
|
|
176
|
+
self._perm_sessions: dict[str, str] = {} # event_id → session_id
|
|
177
|
+
|
|
178
|
+
def _new_session(self) -> Session:
|
|
179
|
+
sid = uuid.uuid4().hex[:12]
|
|
180
|
+
kwargs = dict(self._engine_kwargs)
|
|
181
|
+
if self._system_prompt:
|
|
182
|
+
kwargs["system_prompt"] = self._system_prompt
|
|
183
|
+
engine = Engine(model=self._model, workdir=self._workdir, **kwargs)
|
|
184
|
+
|
|
185
|
+
# Wire up artifact support: create_artifact tool + live server
|
|
186
|
+
try:
|
|
187
|
+
from rockycode.engine.artifact import ArtifactServer, build_artifact_tools
|
|
188
|
+
tools = build_artifact_tools(workdir=self._workdir, engine=engine)
|
|
189
|
+
engine.registry.update(tools)
|
|
190
|
+
server = ArtifactServer(self._workdir)
|
|
191
|
+
async def _artifact_target():
|
|
192
|
+
await server.start()
|
|
193
|
+
return server
|
|
194
|
+
engine.artifact_target = _artifact_target
|
|
195
|
+
except Exception:
|
|
196
|
+
pass # artifacts are optional
|
|
197
|
+
|
|
198
|
+
sess = Session(sid, engine)
|
|
199
|
+
self._sessions[sid] = sess
|
|
200
|
+
return sess
|
|
201
|
+
|
|
202
|
+
def _make_approver(self, session_id: str):
|
|
203
|
+
"""Return an async approve(name, args) → bool that bridges to the
|
|
204
|
+
JSON-RPC client via a notification + wait."""
|
|
205
|
+
|
|
206
|
+
async def approve(name: str, args: dict) -> bool:
|
|
207
|
+
eid = uuid.uuid4().hex[:8]
|
|
208
|
+
ev = asyncio.Event()
|
|
209
|
+
self._perm_events[eid] = ev
|
|
210
|
+
self._perm_sessions[eid] = session_id
|
|
211
|
+
risk = getattr(
|
|
212
|
+
self._sessions[session_id].engine.registry.get(name), "risk", "risky"
|
|
213
|
+
)
|
|
214
|
+
_write_line(_notify("session/request_permission", {
|
|
215
|
+
"session_id": session_id, "event_id": eid,
|
|
216
|
+
"tool": name, "args": args, "risk": risk,
|
|
217
|
+
}))
|
|
218
|
+
try:
|
|
219
|
+
await asyncio.wait_for(ev.wait(), timeout=120.0)
|
|
220
|
+
except asyncio.TimeoutError:
|
|
221
|
+
self._cleanup_perm(eid)
|
|
222
|
+
return False
|
|
223
|
+
result = self._perm_results.pop(eid, False)
|
|
224
|
+
self._cleanup_perm(eid)
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
return approve
|
|
228
|
+
|
|
229
|
+
def resolve_permission(self, event_id: str, allowed: bool) -> bool:
|
|
230
|
+
if event_id in self._perm_events:
|
|
231
|
+
self._perm_results[event_id] = allowed
|
|
232
|
+
self._perm_events[event_id].set()
|
|
233
|
+
return True
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
def _cleanup_perm(self, eid: str) -> None:
|
|
237
|
+
self._perm_events.pop(eid, None)
|
|
238
|
+
self._perm_sessions.pop(eid, None)
|
|
239
|
+
|
|
240
|
+
def get(self, session_id: str) -> Optional[Session]:
|
|
241
|
+
return self._sessions.get(session_id)
|
|
242
|
+
|
|
243
|
+
async def handle_chat(self, session_id: str, message: str) -> None:
|
|
244
|
+
sess = self.get(session_id)
|
|
245
|
+
if sess is None:
|
|
246
|
+
return
|
|
247
|
+
sess.engine.approver = self._make_approver(session_id)
|
|
248
|
+
sess._task = asyncio.current_task()
|
|
249
|
+
try:
|
|
250
|
+
async for ev in sess.engine.run_turn(message):
|
|
251
|
+
n = _event_to_notification(session_id, ev)
|
|
252
|
+
if n is not None:
|
|
253
|
+
_write_line(n)
|
|
254
|
+
except asyncio.CancelledError:
|
|
255
|
+
pass # _repair_history runs in run_turn's finally
|
|
256
|
+
except Exception:
|
|
257
|
+
_write_line(_notify("session/error", {
|
|
258
|
+
"session_id": session_id,
|
|
259
|
+
"message": _client_error(),
|
|
260
|
+
}))
|
|
261
|
+
finally:
|
|
262
|
+
sess._task = None
|
|
263
|
+
|
|
264
|
+
def list_sessions(self) -> list[dict]:
|
|
265
|
+
return [
|
|
266
|
+
{
|
|
267
|
+
"session_id": s.session_id,
|
|
268
|
+
"created_at": s.created_at,
|
|
269
|
+
"n_messages": len(s.engine.history),
|
|
270
|
+
"running": s.running,
|
|
271
|
+
}
|
|
272
|
+
for s in self._sessions.values()
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
def cancel_session(self, session_id: str) -> bool:
|
|
276
|
+
sess = self.get(session_id)
|
|
277
|
+
if sess is None:
|
|
278
|
+
return False
|
|
279
|
+
sess.cancel()
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
async def shutdown(self) -> None:
|
|
283
|
+
tasks = []
|
|
284
|
+
for sess in self._sessions.values():
|
|
285
|
+
sess.cancel()
|
|
286
|
+
if sess._task is not None:
|
|
287
|
+
tasks.append(sess._task)
|
|
288
|
+
# Await the cancellations rather than sleeping — otherwise the tasks are
|
|
289
|
+
# still pending at interpreter exit ("Task was destroyed but it is
|
|
290
|
+
# pending" noise on stderr).
|
|
291
|
+
for t in tasks:
|
|
292
|
+
try:
|
|
293
|
+
await t
|
|
294
|
+
except asyncio.CancelledError:
|
|
295
|
+
pass
|
|
296
|
+
except Exception: # a dying session must not block shutdown
|
|
297
|
+
pass
|
|
298
|
+
# Reward lines for the dream (self-evolve): flush each session's
|
|
299
|
+
# heuristic outcome now that its turns are done. Idempotent, and a
|
|
300
|
+
# no-turn session writes nothing.
|
|
301
|
+
for sess in self._sessions.values():
|
|
302
|
+
try:
|
|
303
|
+
sess.engine.finalize_outcome()
|
|
304
|
+
except Exception: # noqa: BLE001 — best-effort at teardown
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
309
|
+
# server loop
|
|
310
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def _read_messages() -> AsyncIterator[dict]:
|
|
314
|
+
"""Read NDJSON messages from stdin using a thread so we don't block the
|
|
315
|
+
event loop on synchronous stdin reads."""
|
|
316
|
+
loop = asyncio.get_running_loop()
|
|
317
|
+
|
|
318
|
+
def _read() -> str:
|
|
319
|
+
return sys.stdin.readline()
|
|
320
|
+
|
|
321
|
+
while True:
|
|
322
|
+
line = await loop.run_in_executor(None, _read)
|
|
323
|
+
if not line:
|
|
324
|
+
break
|
|
325
|
+
line = line.strip()
|
|
326
|
+
if not line:
|
|
327
|
+
continue
|
|
328
|
+
try:
|
|
329
|
+
msg = json.loads(line)
|
|
330
|
+
except json.JSONDecodeError:
|
|
331
|
+
continue
|
|
332
|
+
# A hostile/buggy client can send valid JSON that isn't an object
|
|
333
|
+
# (42, "x", null, []). Drop it here so the dispatch loop only ever sees
|
|
334
|
+
# dicts — one bad line must not crash the server on msg.get(...).
|
|
335
|
+
if isinstance(msg, dict):
|
|
336
|
+
yield msg
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def run_server(
|
|
340
|
+
model: str,
|
|
341
|
+
workdir: Path,
|
|
342
|
+
thinking: bool = True,
|
|
343
|
+
reasoning_effort: str = "max",
|
|
344
|
+
max_tokens: int = 16384,
|
|
345
|
+
context_window: int = 131072,
|
|
346
|
+
max_steps: int = 0,
|
|
347
|
+
system_prompt: str = "",
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Blocking: reads JSON-RPC from stdin, writes to stdout, runs until EOF."""
|
|
350
|
+
|
|
351
|
+
mgr = SessionManager(
|
|
352
|
+
model=model, workdir=workdir,
|
|
353
|
+
thinking=thinking, reasoning_effort=reasoning_effort,
|
|
354
|
+
max_tokens=max_tokens, context_window=context_window,
|
|
355
|
+
max_steps=max_steps, system_prompt=system_prompt,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
chat_tasks: dict[str, asyncio.Task] = {}
|
|
359
|
+
|
|
360
|
+
async for msg in _read_messages():
|
|
361
|
+
method = msg.get("method", "")
|
|
362
|
+
msg_id = msg.get("id")
|
|
363
|
+
params = msg.get("params") or {} # tolerate a missing or null "params"
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
if method == "initialize":
|
|
367
|
+
sess = mgr._new_session()
|
|
368
|
+
# Report whether a usable key is present (rocky's credential
|
|
369
|
+
# chain: env, .env files serve loaded, keychain) so the client's
|
|
370
|
+
# "setup required" card reflects reality instead of guessing
|
|
371
|
+
# from the editor's own environment.
|
|
372
|
+
from rockycode.onboarding import is_configured
|
|
373
|
+
_write_line(_response(msg_id, {
|
|
374
|
+
"version": "0.1.0",
|
|
375
|
+
"session_id": sess.session_id,
|
|
376
|
+
"model": model,
|
|
377
|
+
"configured": is_configured(),
|
|
378
|
+
}))
|
|
379
|
+
|
|
380
|
+
elif method == "chat":
|
|
381
|
+
sid = params.get("session_id", "")
|
|
382
|
+
if not sid or sid not in mgr._sessions:
|
|
383
|
+
sess = mgr._new_session()
|
|
384
|
+
sid = sess.session_id
|
|
385
|
+
# Cancel any running turn on this session before starting a new one
|
|
386
|
+
prev = chat_tasks.get(sid)
|
|
387
|
+
if prev and not prev.done():
|
|
388
|
+
mgr.cancel_session(sid)
|
|
389
|
+
prev.cancel()
|
|
390
|
+
try:
|
|
391
|
+
await prev
|
|
392
|
+
except asyncio.CancelledError:
|
|
393
|
+
pass
|
|
394
|
+
task = asyncio.create_task(mgr.handle_chat(sid, params.get("message", "")))
|
|
395
|
+
chat_tasks[sid] = task
|
|
396
|
+
_write_line(_response(msg_id, {"session_id": sid}))
|
|
397
|
+
|
|
398
|
+
elif method == "cancel":
|
|
399
|
+
sid = params.get("session_id", "")
|
|
400
|
+
ok = mgr.cancel_session(sid)
|
|
401
|
+
_write_line(_response(msg_id, {"cancelled": ok}))
|
|
402
|
+
|
|
403
|
+
elif method == "list_sessions":
|
|
404
|
+
_write_line(_response(msg_id, {"sessions": mgr.list_sessions()}))
|
|
405
|
+
|
|
406
|
+
elif method == "get_status":
|
|
407
|
+
sid = params.get("session_id", "")
|
|
408
|
+
sess = mgr.get(sid)
|
|
409
|
+
_write_line(_response(msg_id, {
|
|
410
|
+
"session_id": sid,
|
|
411
|
+
"state": "busy" if (sess and sess.running) else "idle",
|
|
412
|
+
}))
|
|
413
|
+
|
|
414
|
+
elif method == "session/permission_response":
|
|
415
|
+
eid = params.get("event_id", "")
|
|
416
|
+
allowed = params.get("allowed", False)
|
|
417
|
+
mgr.resolve_permission(eid, allowed)
|
|
418
|
+
_write_line(_response(msg_id, {"ack": True}))
|
|
419
|
+
|
|
420
|
+
elif method == "shutdown":
|
|
421
|
+
_write_line(_response(msg_id, {"ok": True}))
|
|
422
|
+
break
|
|
423
|
+
|
|
424
|
+
else:
|
|
425
|
+
_write_line(_error(msg_id, -32601, f"unknown method: {method}"))
|
|
426
|
+
|
|
427
|
+
except Exception:
|
|
428
|
+
if msg_id is not None:
|
|
429
|
+
_write_line(_error(msg_id, -32603, _client_error()))
|
|
430
|
+
|
|
431
|
+
await mgr.shutdown() # cancel any in-flight turns on EOF / shutdown
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Skills: reuse what users already have, zero migration.
|
|
2
|
+
|
|
3
|
+
Discovery, in priority order (first definition of a name wins):
|
|
4
|
+
|
|
5
|
+
1. <project>/.claude/skills/<name>/SKILL.md — Claude Code project scope
|
|
6
|
+
2. <project>/.rockycode/skills/<name>/SKILL.md — rockycode's own
|
|
7
|
+
3. ~/.rockycode/skills/<name>/SKILL.md — rocky global ($ROCKYCODE_HOME;
|
|
8
|
+
dream-approved skills install here)
|
|
9
|
+
4. ~/.claude/skills/<name>/SKILL.md — Claude Code user scope
|
|
10
|
+
5. ~/.codex/prompts/<name>.md — Codex custom prompts
|
|
11
|
+
6. rockycode/skills/<name>/SKILL.md — packaged built-ins, last on
|
|
12
|
+
purpose: any project or user copy of the same name shadows a built-in
|
|
13
|
+
|
|
14
|
+
Progressive disclosure, same pattern as Claude Code: only `name — description`
|
|
15
|
+
lines go into the system prompt; the model calls the `skill` tool to load a
|
|
16
|
+
skill's full instructions on demand, so fifty installed skills cost fifty
|
|
17
|
+
lines of context, not fifty files.
|
|
18
|
+
|
|
19
|
+
SKILL.md frontmatter is parsed leniently (name:/description: lines between
|
|
20
|
+
--- markers) — no YAML dependency. Codex prompts have no frontmatter; the
|
|
21
|
+
filename is the name and the first body line is the description.
|
|
22
|
+
|
|
23
|
+
Chat-only, like MCP: bench never loads skills, so scores measure the harness.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Optional
|
|
32
|
+
|
|
33
|
+
from rockycode.engine.tools import Tool, _truncate
|
|
34
|
+
|
|
35
|
+
MAX_SKILLS_IN_PROMPT = 100
|
|
36
|
+
MAX_DESCRIPTION_CHARS = 200
|
|
37
|
+
|
|
38
|
+
# Skills that ship inside the package (rockycode/skills/<name>/SKILL.md).
|
|
39
|
+
BUILTIN_DIR = Path(__file__).resolve().parents[1] / "skills"
|
|
40
|
+
|
|
41
|
+
_FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Skill:
|
|
46
|
+
name: str
|
|
47
|
+
description: str
|
|
48
|
+
path: Path # the markdown file
|
|
49
|
+
source: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
|
|
53
|
+
"""Lenient `key: value` frontmatter parse. Returns (fields, body)."""
|
|
54
|
+
m = _FRONTMATTER.match(text)
|
|
55
|
+
if not m:
|
|
56
|
+
return {}, text
|
|
57
|
+
fields: dict[str, str] = {}
|
|
58
|
+
for line in m.group(1).splitlines():
|
|
59
|
+
if ":" in line and not line.startswith((" ", "\t", "#")):
|
|
60
|
+
key, _, value = line.partition(":")
|
|
61
|
+
fields[key.strip().lower()] = value.strip().strip("'\"")
|
|
62
|
+
return fields, text[m.end():]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _first_line(text: str) -> str:
|
|
66
|
+
for line in text.splitlines():
|
|
67
|
+
line = line.strip().lstrip("#").strip()
|
|
68
|
+
if line:
|
|
69
|
+
return line
|
|
70
|
+
return "(no description)"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_skill_md(path: Path, fallback_name: str, source: str) -> Optional[Skill]:
|
|
74
|
+
try:
|
|
75
|
+
text = path.read_text(errors="replace")
|
|
76
|
+
except OSError:
|
|
77
|
+
return None
|
|
78
|
+
fields, body = _parse_frontmatter(text)
|
|
79
|
+
return Skill(
|
|
80
|
+
name=fields.get("name") or fallback_name,
|
|
81
|
+
description=(fields.get("description") or _first_line(body))[:MAX_DESCRIPTION_CHARS],
|
|
82
|
+
path=path,
|
|
83
|
+
source=source,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def discover_skills(workdir: Path, *, home: Optional[Path] = None,
|
|
88
|
+
builtin: bool = True) -> list[Skill]:
|
|
89
|
+
"""Collect skills from project + user locations, then packaged built-ins.
|
|
90
|
+
`home=None` disables user-level lookup (tests); pass Path.home() for the
|
|
91
|
+
real thing. `builtin=False` disables the packaged set (tests)."""
|
|
92
|
+
found: dict[str, Skill] = {}
|
|
93
|
+
|
|
94
|
+
def add(skill: Optional[Skill]) -> None:
|
|
95
|
+
if skill is not None and skill.name not in found:
|
|
96
|
+
found[skill.name] = skill
|
|
97
|
+
|
|
98
|
+
def scan_skill_dirs(root: Path, source: str) -> None:
|
|
99
|
+
if not root.is_dir():
|
|
100
|
+
return
|
|
101
|
+
for d in sorted(root.iterdir()):
|
|
102
|
+
md = d / "SKILL.md"
|
|
103
|
+
if d.is_dir() and md.exists():
|
|
104
|
+
add(_load_skill_md(md, d.name, source))
|
|
105
|
+
|
|
106
|
+
scan_skill_dirs(workdir / ".claude" / "skills", "project .claude/skills")
|
|
107
|
+
scan_skill_dirs(workdir / ".rockycode" / "skills", "project .rockycode/skills")
|
|
108
|
+
if home is not None:
|
|
109
|
+
# Rocky's own global store ($ROCKYCODE_HOME-aware, like trajectories) —
|
|
110
|
+
# where dream-approved proposals install. After the project dirs on
|
|
111
|
+
# purpose: a project skill always wins over a dream-drafted one.
|
|
112
|
+
base = os.environ.get("ROCKYCODE_HOME")
|
|
113
|
+
rocky_global = (Path(base).expanduser() if base else home / ".rockycode") / "skills"
|
|
114
|
+
scan_skill_dirs(rocky_global, "~/.rockycode/skills")
|
|
115
|
+
scan_skill_dirs(home / ".claude" / "skills", "~/.claude/skills")
|
|
116
|
+
codex_prompts = home / ".codex" / "prompts"
|
|
117
|
+
if codex_prompts.is_dir():
|
|
118
|
+
for f in sorted(codex_prompts.glob("*.md")):
|
|
119
|
+
add(_load_skill_md(f, f.stem, "~/.codex/prompts"))
|
|
120
|
+
if builtin:
|
|
121
|
+
scan_skill_dirs(BUILTIN_DIR, "built-in")
|
|
122
|
+
|
|
123
|
+
return list(found.values())[:MAX_SKILLS_IN_PROMPT]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def skills_prompt_section(skills: list[Skill]) -> str:
|
|
127
|
+
lines = [f"- {s.name} — {s.description}" for s in skills]
|
|
128
|
+
return (
|
|
129
|
+
"\n\n# Skills available\n\n"
|
|
130
|
+
"These are installed playbooks — rocky's built-ins plus the user's own. "
|
|
131
|
+
"When a task matches one, call the "
|
|
132
|
+
"`skill` tool with its name FIRST and follow its instructions.\n\n"
|
|
133
|
+
+ "\n".join(lines)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_skill_tool(skills: list[Skill]) -> Tool:
|
|
138
|
+
by_name = {s.name: s for s in skills}
|
|
139
|
+
schema = {
|
|
140
|
+
"type": "function",
|
|
141
|
+
"function": {
|
|
142
|
+
"name": "skill",
|
|
143
|
+
"description": (
|
|
144
|
+
"Load the full instructions of an installed skill. "
|
|
145
|
+
"Call this before attempting a task that matches a skill's description."
|
|
146
|
+
),
|
|
147
|
+
"parameters": {
|
|
148
|
+
"type": "object",
|
|
149
|
+
"properties": {
|
|
150
|
+
"name": {
|
|
151
|
+
"type": "string",
|
|
152
|
+
"description": "Skill name from the 'Skills available' list.",
|
|
153
|
+
"enum": sorted(by_name),
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
"required": ["name"],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async def fn(name: str) -> str:
|
|
162
|
+
skill = by_name.get(name)
|
|
163
|
+
if skill is None:
|
|
164
|
+
return f"[error] no skill named '{name}'. available: {', '.join(sorted(by_name))}"
|
|
165
|
+
try:
|
|
166
|
+
text = skill.path.read_text(errors="replace")
|
|
167
|
+
except OSError as e:
|
|
168
|
+
return f"[error] could not read skill: {e}"
|
|
169
|
+
_, body = _parse_frontmatter(text)
|
|
170
|
+
# The directory matters: skills often ship scripts referenced
|
|
171
|
+
# relative to their own folder.
|
|
172
|
+
return _truncate(
|
|
173
|
+
f"# skill: {skill.name}\n# directory: {skill.path.parent}\n\n{body.strip()}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Loading a skill only returns SKILL.md text — it runs nothing. The real risk
|
|
177
|
+
# is whatever bash/web_fetch the model then attempts, which is gated on its own.
|
|
178
|
+
return Tool(name="skill", schema=schema, fn=fn, risk="safe")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Session titles: one tiny flash call, fired-and-forgotten after the first
|
|
2
|
+
exchange, so the resume picker and exit card show "credential namespace fix"
|
|
3
|
+
instead of the first message echoed back.
|
|
4
|
+
|
|
5
|
+
Best-effort by design: offline, no key, a slow provider, or a weird reply all
|
|
6
|
+
mean "no title" — the first-message summary remains the fallback everywhere,
|
|
7
|
+
and nothing here may ever surface an error into the chat.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
_MAX_CHARS = 60
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _title_model() -> str:
|
|
17
|
+
return os.getenv("ROCKYCODE_TITLE_MODEL", "deepseek-v4-flash")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _clean(raw: str) -> str | None:
|
|
21
|
+
"""First line, quotes/periods stripped, hard cap — a title, not a reply."""
|
|
22
|
+
t = raw.strip().splitlines()[0].strip().strip('"\'“”「」').rstrip(".。").strip()
|
|
23
|
+
if not t:
|
|
24
|
+
return None
|
|
25
|
+
return t[:_MAX_CHARS]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def generate_title(client, first_user: str, first_reply: str) -> str | None:
|
|
29
|
+
"""3-8 word session title from the opening exchange, or None."""
|
|
30
|
+
try:
|
|
31
|
+
r = await client.chat.completions.create(
|
|
32
|
+
model=_title_model(),
|
|
33
|
+
max_tokens=24,
|
|
34
|
+
messages=[
|
|
35
|
+
{"role": "system", "content": (
|
|
36
|
+
"Write a 3-8 word title for this coding-chat session, in the "
|
|
37
|
+
"user's language. Output ONLY the title — no quotes, no period."
|
|
38
|
+
)},
|
|
39
|
+
{"role": "user", "content": (
|
|
40
|
+
f"[user]\n{first_user[:1000]}\n\n[assistant]\n{first_reply[:1000]}"
|
|
41
|
+
)},
|
|
42
|
+
],
|
|
43
|
+
)
|
|
44
|
+
return _clean(r.choices[0].message.content or "")
|
|
45
|
+
except Exception: # noqa: BLE001 — titles are decoration; never break a session
|
|
46
|
+
return None
|