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,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