rubyn-code 0.3.0 → 0.5.0

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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +263 -21
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +34 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +57 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +11 -1
  7. data/lib/rubyn_code/agent/loop.rb +14 -3
  8. data/lib/rubyn_code/agent/response_modes.rb +2 -1
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +49 -4
  10. data/lib/rubyn_code/agent/tool_processor.rb +25 -3
  11. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  12. data/lib/rubyn_code/auth/token_store.rb +50 -9
  13. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  14. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  15. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  16. data/lib/rubyn_code/cli/app.rb +116 -11
  17. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  18. data/lib/rubyn_code/cli/commands/install_skills.rb +44 -0
  19. data/lib/rubyn_code/cli/commands/list_skills.rb +149 -0
  20. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  21. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  22. data/lib/rubyn_code/cli/commands/provider.rb +124 -0
  23. data/lib/rubyn_code/cli/commands/remove_skills.rb +35 -0
  24. data/lib/rubyn_code/cli/commands/skill.rb +54 -3
  25. data/lib/rubyn_code/cli/commands/skills.rb +104 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  27. data/lib/rubyn_code/cli/first_run.rb +159 -0
  28. data/lib/rubyn_code/cli/repl.rb +15 -0
  29. data/lib/rubyn_code/cli/repl_commands.rb +3 -1
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +74 -1
  32. data/lib/rubyn_code/config/defaults.rb +3 -0
  33. data/lib/rubyn_code/config/schema.json +49 -0
  34. data/lib/rubyn_code/config/settings.rb +12 -6
  35. data/lib/rubyn_code/config/validator.rb +63 -0
  36. data/lib/rubyn_code/context/context_budget.rb +18 -2
  37. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  38. data/lib/rubyn_code/context/manager.rb +37 -3
  39. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  40. data/lib/rubyn_code/hooks/registry.rb +4 -0
  41. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  42. data/lib/rubyn_code/ide/client.rb +110 -0
  43. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  44. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  45. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  46. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  47. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  48. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  49. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  50. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +218 -0
  51. data/lib/rubyn_code/ide/handlers/review_handler.rb +127 -0
  52. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  53. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  54. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  55. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  56. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  57. data/lib/rubyn_code/ide/handlers.rb +76 -0
  58. data/lib/rubyn_code/ide/protocol.rb +112 -0
  59. data/lib/rubyn_code/ide/server.rb +186 -0
  60. data/lib/rubyn_code/index/codebase_index.rb +69 -2
  61. data/lib/rubyn_code/learning/extractor.rb +4 -2
  62. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  63. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  64. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  65. data/lib/rubyn_code/llm/client.rb +29 -4
  66. data/lib/rubyn_code/llm/model_router.rb +2 -1
  67. data/lib/rubyn_code/mcp/config.rb +2 -1
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +1 -1
  69. data/lib/rubyn_code/memory/search.rb +1 -0
  70. data/lib/rubyn_code/observability/usage_reporter.rb +4 -2
  71. data/lib/rubyn_code/output/diff_renderer.rb +3 -2
  72. data/lib/rubyn_code/self_test.rb +316 -0
  73. data/lib/rubyn_code/skills/auto_suggest.rb +131 -0
  74. data/lib/rubyn_code/skills/catalog.rb +76 -0
  75. data/lib/rubyn_code/skills/document.rb +8 -2
  76. data/lib/rubyn_code/skills/gemfile_parser.rb +40 -0
  77. data/lib/rubyn_code/skills/loader.rb +43 -0
  78. data/lib/rubyn_code/skills/matcher.rb +89 -0
  79. data/lib/rubyn_code/skills/pack_context.rb +163 -0
  80. data/lib/rubyn_code/skills/pack_installer.rb +194 -0
  81. data/lib/rubyn_code/skills/pack_manager.rb +230 -0
  82. data/lib/rubyn_code/skills/registry_autoload.rb +112 -0
  83. data/lib/rubyn_code/skills/registry_client.rb +241 -0
  84. data/lib/rubyn_code/tasks/models.rb +1 -0
  85. data/lib/rubyn_code/tools/base.rb +13 -0
  86. data/lib/rubyn_code/tools/bash.rb +5 -0
  87. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  88. data/lib/rubyn_code/tools/executor.rb +65 -8
  89. data/lib/rubyn_code/tools/glob.rb +6 -0
  90. data/lib/rubyn_code/tools/grep.rb +7 -0
  91. data/lib/rubyn_code/tools/ide_diagnostics.rb +53 -0
  92. data/lib/rubyn_code/tools/ide_symbols.rb +55 -0
  93. data/lib/rubyn_code/tools/load_skill.rb +2 -1
  94. data/lib/rubyn_code/tools/output_compressor.rb +9 -7
  95. data/lib/rubyn_code/tools/read_file.rb +6 -0
  96. data/lib/rubyn_code/tools/registry.rb +11 -0
  97. data/lib/rubyn_code/tools/review_pr.rb +15 -4
  98. data/lib/rubyn_code/tools/web_search.rb +2 -1
  99. data/lib/rubyn_code/tools/write_file.rb +17 -0
  100. data/lib/rubyn_code/version.rb +1 -1
  101. data/lib/rubyn_code.rb +34 -0
  102. data/skills/rubyn_self_test.md +88 -1
  103. metadata +43 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f91f304118c243f82ce9165db84f303b111d9c18daf74c75cdc7dcec4289647b
4
- data.tar.gz: 2cfc8263c1532d805ea9b9592c3601033e4fdab8dc19c6b0329a0eec40316cbb
3
+ metadata.gz: 342d51b0944ce35908b8fd56f52b0321cf698ea01ec3cc8fa572d92b9c332b22
4
+ data.tar.gz: '033868c98fedc4814cb9e8c94eb8f013b15641d5a3e7cecf7e819d3d3b57f771'
5
5
  SHA512:
6
- metadata.gz: 6bb339794d0cbe8d149e0b2e94c408b304d48f786aa2a297af5c8028c15c9796d4e5f0313be20c1ecf8b449c0ef80afa4263f581603c5c915b540c7a89b8257c
7
- data.tar.gz: 2f1fc37549f2223a5c5f07be4817bade9a4d6466ef30b8927fb0de94cf4c78c41b1b5e9e8a3379fb69e503d34226df9d22e4988bb45231666afc49e4f325d789
6
+ metadata.gz: 283cff44827418497c9ed9d27e4331d8fdf24c04825ee3f28e46a33da28bf61f65d0a2b85173187947393856053fad5466dce5782fcef3d0e1dcce68d37e00eb
7
+ data.tar.gz: 6c96e7beba37f74c0fe947470a1a37d50b521087b573407d6ba7d0d0649d71385b263b70b126fe9c2b7da3c910a233862fdbfb8c1d7c534db6f6cc1d813ec305
data/README.md CHANGED
@@ -22,12 +22,49 @@ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code f
22
22
 
23
23
  > **Rubyn is going open source.** The original [Rubyn gem](https://github.com/Rubyn-AI/rubyn) provided AI-assisted refactoring, spec generation, and code review through the Rubyn API. **Rubyn Code** is the next evolution — a complete agentic coding assistant that runs locally, thinks for itself, and learns from every session. No API keys. No separate billing. Just `gem install rubyn-code` and go.
24
24
 
25
+ ---
26
+
27
+ ## Table of Contents
28
+
29
+ - [Why Rubyn?](#why-rubyn)
30
+ - [Install](#install)
31
+ - [Quick Start](#quick-start)
32
+ - [What Can Rubyn Do?](#what-can-rubyn-do)
33
+ - [VS Code Extension](#vs-code-extension)
34
+ - [29 Built-in Tools](#29-built-in-tools)
35
+ - [MCP — External Tool Servers](#mcp--external-tool-servers)
36
+ - [Codebase Indexing](#codebase-indexing)
37
+ - [112 Best Practice Skills](#112-best-practice-skills)
38
+ - [Context Architecture](#context-architecture)
39
+ - [RUBYN.md — Project Instructions](#rubynmd--project-instructions)
40
+ - [PR Review](#pr-review)
41
+ - [Sub-Agents & Teams](#sub-agents--teams)
42
+ - [GOLEM — Autonomous Daemon](#golem--autonomous-daemon)
43
+ - [Continuous Learning](#continuous-learning)
44
+ - [Streaming Output](#streaming-output)
45
+ - [Search Providers](#search-providers)
46
+ - [User Hooks](#user-hooks)
47
+ - [CLI Reference](#cli-reference)
48
+ - [Authentication](#authentication)
49
+ - [Architecture](#architecture)
50
+ - [Configuration](#configuration)
51
+ - [Security](#security)
52
+ - [Diagnostics](#diagnostics)
53
+ - [Development](#development)
54
+ - [From Rubyn to Rubyn Code](#from-rubyn-to-rubyn-code)
55
+ - [Contributing](#contributing)
56
+ - [License](#license)
57
+
58
+ ---
59
+
25
60
  ## Why Rubyn?
26
61
 
27
62
  - **Rails-native** — understands service object extraction, RSpec conventions, ActiveRecord patterns, and Hotwire
28
63
  - **Context-aware** — automatically incorporates schema, routes, specs, factories, and models
29
64
  - **Best practices built in** — ships with 112 curated Ruby and Rails guidelines that load on demand
30
65
  - **Agentic** — doesn't just answer questions. Reads files, writes code, runs specs, commits, reviews PRs, spawns sub-agents, and remembers what it learns
66
+ - **IDE-ready** — works in the terminal and inside VS Code with full bidirectional communication
67
+ - **Extensible** — connect external tool servers via MCP, add custom skills, or wire up your own providers
31
68
 
32
69
  ## Install
33
70
 
@@ -93,6 +130,9 @@ rubyn-code --yolo
93
130
 
94
131
  # Single prompt
95
132
  rubyn-code -p "Refactor app/controllers/orders_controller.rb into service objects"
133
+
134
+ # VS Code IDE mode (used by the extension)
135
+ rubyn-code --ide
96
136
  ```
97
137
 
98
138
  ## What Can Rubyn Do?
@@ -118,7 +158,7 @@ rubyn > Write specs for the new service objects
118
158
  > write_file: path=spec/services/orders/create_service_spec.rb
119
159
  > run_specs: path=spec/services/orders/
120
160
 
121
- 4 examples, 0 failures. All green.
161
+ 4 examples, 0 failures. All green.
122
162
  ```
123
163
 
124
164
  ### Review code
@@ -142,6 +182,29 @@ Agent finished (23 tool calls).
142
182
  This is a Rails 7.1 e-commerce app with...
143
183
  ```
144
184
 
185
+ ## VS Code Extension
186
+
187
+ Rubyn Code includes a VS Code extension that provides a full IDE experience with bidirectional JSON-RPC communication. The extension runs Rubyn as a subprocess and connects over stdin/stdout.
188
+
189
+ **Capabilities:**
190
+
191
+ - Chat panel with streaming responses and syntax-highlighted code blocks
192
+ - Inline diffs — review and accept generated code changes directly in the editor
193
+ - Tool approval prompts in the IDE (or skip them in YOLO mode)
194
+ - Full session management — resume, list, fork, and reset conversations
195
+ - Structured code review feedback with severity ratings
196
+ - IDE config get/set for persistent settings
197
+ - All 29 tools available, including MCP tools
198
+
199
+ **Permission modes:**
200
+
201
+ | Mode | Behavior |
202
+ |------|----------|
203
+ | `default` | Per-tool approval required |
204
+ | `bypass` | YOLO — skip all approval prompts |
205
+
206
+ The extension communicates over 14 RPC methods: `initialize`, `prompt`, `cancel`, `review`, `approveToolUse`, `acceptEdit`, `session/*`, `config/*`, `models/list`, and `shutdown`.
207
+
145
208
  ## 29 Built-in Tools
146
209
 
147
210
  | Category | Tools |
@@ -159,6 +222,58 @@ This is a Rails 7.1 e-commerce app with...
159
222
  | **Teams** | `send_message`, `read_inbox` |
160
223
  | **Interactive** | `ask_user` (ask clarifying questions mid-task) |
161
224
 
225
+ ## MCP — External Tool Servers
226
+
227
+ Connect external tool servers via the [Model Context Protocol](https://modelcontextprotocol.io). MCP tools are dynamically discovered and registered as native Rubyn tools, available in the REPL, IDE, and daemon.
228
+
229
+ ### Configuration
230
+
231
+ Create `.rubyn-code/mcp.json` in your project or `~/.rubyn-code/mcp.json` globally:
232
+
233
+ ```json
234
+ {
235
+ "mcpServers": {
236
+ "github": {
237
+ "command": "npx",
238
+ "args": ["-y", "@modelcontextprotocol/server-github"],
239
+ "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }
240
+ },
241
+ "my-api": {
242
+ "url": "http://localhost:3001/mcp",
243
+ "timeout": 30
244
+ }
245
+ }
246
+ }
247
+ ```
248
+
249
+ - **Stdio transport** — specify `command` and `args` to run a subprocess
250
+ - **SSE transport** — specify `url` for HTTP-based servers
251
+ - Environment variables are interpolated with `${VAR}` syntax
252
+
253
+ MCP tools appear in the tool palette prefixed with `mcp_` and require confirmation before execution. Run `/doctor` to verify server connectivity.
254
+
255
+ Rubyn ships with three example MCP servers: **database explorer**, **RubyGems lookup**, and **Rails routes**. See `/mcp` for documentation.
256
+
257
+ ## Codebase Indexing
258
+
259
+ Rubyn builds a structural index of your codebase on first session and incrementally updates it as files change. The index powers smarter context injection, skill suggestions, and impact analysis.
260
+
261
+ **What it tracks:**
262
+
263
+ - Classes, modules, methods, callbacks, scopes, validations, associations
264
+ - Relationships between files (associations, test coverage, caller/callee)
265
+ - Rails patterns: `has_many`, `belongs_to`, `before_action`, `validates`, etc.
266
+ - File classification: model, controller, service, concern, spec
267
+
268
+ **How it's used:**
269
+
270
+ - Injects a compact structural summary into the system prompt
271
+ - Feeds the dynamic tool schema for smarter tool selection
272
+ - Powers `impact_analysis(file)` to find affected tests and dependents
273
+ - Suggests relevant skills based on the code you're working with
274
+
275
+ Stored at `.rubyn-code/codebase_index.json`. The `/doctor` command flags stale indexes (>24 hours).
276
+
162
277
  ## 112 Best Practice Skills
163
278
 
164
279
  Rubyn ships with curated best practice documents that load on demand. Only skill names are in memory — full content loads when Rubyn needs it.
@@ -176,6 +291,17 @@ Rubyn ships with curated best practice documents that load on demand. Only skill
176
291
  | **Gems** | Sidekiq, Devise, FactoryBot, Pundit, Faraday, Stripe, RuboCop, dry-rb |
177
292
  | **Sinatra** | Application structure, middleware, testing |
178
293
 
294
+ ### Skill search & filter
295
+
296
+ ```
297
+ rubyn > /skill search factory # search by name, description, or tags
298
+ rubyn > /skill list rails # filter by category
299
+ rubyn > /skill list # show all categories
300
+ rubyn > /skill load rspec_matchers # inject a skill into context
301
+ ```
302
+
303
+ Results are relevance-ranked: name matches score highest, then description, then tags.
304
+
179
305
  ### Custom skills
180
306
 
181
307
  Override or extend with your own:
@@ -199,6 +325,8 @@ Rubyn automatically loads relevant context based on what you're working on:
199
325
  - **Service objects** → includes referenced models and their specs
200
326
  - **Any file** → checks for `RUBYN.md`, `CLAUDE.md`, or `AGENT.md` instructions
201
327
 
328
+ The [codebase index](#codebase-indexing) enhances this with structural awareness — Rubyn knows which files depend on each other before it reads them.
329
+
202
330
  ## RUBYN.md — Project Instructions
203
331
 
204
332
  Drop a `RUBYN.md` in your project root and Rubyn follows your conventions:
@@ -259,6 +387,32 @@ rubyn > Send alice a message to write specs for the User model
259
387
 
260
388
  Teammates run in background threads with their own agent loop and mailbox.
261
389
 
390
+ ## GOLEM — Autonomous Daemon
391
+
392
+ GOLEM is an always-on autonomous agent that claims tasks from a queue and works through them independently. It runs a full agent loop per task with access to all tools, MCP servers, and memory.
393
+
394
+ ```bash
395
+ rubyn-code daemon \
396
+ --name golem-1 \
397
+ --role "Backend Engineer" \
398
+ --max-runs 100 \
399
+ --max-cost 10.0 \
400
+ --poll-interval 5 \
401
+ --idle-timeout 60
402
+ ```
403
+
404
+ **Lifecycle:** `spawned → working ⇄ idle → shutting_down → stopped`
405
+
406
+ **Safety limits:**
407
+
408
+ | Guard | Description |
409
+ |-------|-------------|
410
+ | `--max-runs` | Auto-shutdown after N completed tasks |
411
+ | `--max-cost` | Stop when cumulative USD spend exceeds limit |
412
+ | **Retry backoff** | 3 retries per task before marking failed |
413
+ | **Audit trail** | Full conversation saved per task via session persistence |
414
+ | **Cost tracking** | Accurate per-task spend via the observability layer |
415
+
262
416
  ## Continuous Learning
263
417
 
264
418
  Rubyn gets smarter with every session:
@@ -310,12 +464,14 @@ post_tool_use:
310
464
  rubyn-code # Interactive REPL
311
465
  rubyn-code --yolo # Auto-approve all tools
312
466
  rubyn-code -p "prompt" # Single prompt, exit when done
467
+ rubyn-code --ide # IDE server mode (JSON-RPC over stdin/stdout)
313
468
  rubyn-code --resume [ID] # Resume previous session
314
469
  rubyn-code --setup # Pin to this Ruby (run once after install)
315
470
  rubyn-code --debug # Enable debug output
316
471
  rubyn-code --auth # Authenticate with Claude
317
472
  rubyn-code --version # Show version
318
473
  rubyn-code --help # Show help
474
+ rubyn-code daemon [OPTIONS] # Run GOLEM autonomous daemon
319
475
  ```
320
476
 
321
477
  ### Slash Commands
@@ -331,8 +487,12 @@ rubyn-code --help # Show help
331
487
  | `/cost` | Show token usage and costs |
332
488
  | `/tasks` | List all tasks |
333
489
  | `/budget [amt]` | Show or set session budget |
334
- | `/skill [name]` | Load or list available skills |
490
+ | `/skill [name]` | Load, search, or list available skills |
335
491
  | `/resume [id]` | Resume or list sessions |
492
+ | `/provider` | Add or list providers |
493
+ | `/model` | Show/switch model and provider |
494
+ | `/doctor` | Run environment health checks |
495
+ | `/mcp` | MCP server documentation and status |
336
496
 
337
497
  ## Authentication
338
498
 
@@ -354,14 +514,50 @@ export OPENAI_API_KEY=sk-...
354
514
 
355
515
  Available models: `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-4o`, `gpt-4o-mini`, `o3`, `o4-mini`
356
516
 
357
- ### OpenAI-Compatible Providers (Groq, Together, Ollama, etc.)
517
+ ### Other Providers (Groq, Together, Ollama, etc.)
358
518
 
359
- Set the provider-specific API key and configure via `config.yml`:
519
+ Add a provider and its API key in one command:
360
520
 
361
521
  ```bash
362
- export GROQ_API_KEY=gsk-...
522
+ /provider add groq https://api.groq.com/openai/v1 --key gsk-xxx --models llama-3.3-70b
523
+
524
+ # For Anthropic-format proxies (e.g., Bedrock, custom gateways)
525
+ /provider add my-proxy https://proxy.example.com/v1 --format anthropic --key sk-xxx --models claude-sonnet-4-6
526
+
527
+ # Update a key later
528
+ /provider set-key groq gsk-new-key
529
+
530
+ # List configured providers
531
+ /provider list
363
532
  ```
364
533
 
534
+ API keys are **encrypted at rest** using AES-256-GCM. The encryption key is derived from
535
+ your machine identity (username, hostname, home directory) via PBKDF2, so keys are only
536
+ decryptable on the same machine by the same user. Rubyn decrypts them automatically at
537
+ runtime and re-encrypts on save — no manual steps required.
538
+
539
+ Keys stored via environment variables (`GROQ_API_KEY`, `TOGETHER_API_KEY`, etc.) also work
540
+ as a fallback if you prefer that approach.
541
+
542
+ Or add directly to `~/.rubyn-code/config.yml`:
543
+
544
+ ```yaml
545
+ providers:
546
+ groq:
547
+ base_url: https://api.groq.com/openai/v1
548
+ env_key: GROQ_API_KEY
549
+ models:
550
+ top: llama-3.3-70b
551
+ my-proxy:
552
+ api_format: anthropic # 'openai' (default) or 'anthropic'
553
+ base_url: https://proxy.example.com/v1
554
+ env_key: PROXY_API_KEY
555
+ models:
556
+ top: claude-sonnet-4-6
557
+ ```
558
+
559
+ Then switch with `/model groq:llama-3.3-70b`.
560
+
365
561
  Local providers (Ollama, LM Studio) running on `localhost`/`127.0.0.1` don't require an API key.
366
562
 
367
563
  ## Architecture
@@ -391,30 +587,21 @@ Local providers (Ollama, LM Studio) running on `localhost`/`127.0.0.1` don't req
391
587
 
392
588
  ## Configuration
393
589
 
590
+ The `provider` and `model` keys at the top set the **default provider and model** used at startup.
591
+ These must match a provider defined in the `providers` section (or a built-in like `anthropic`/`openai`).
592
+
394
593
  ```yaml
395
594
  # ~/.rubyn-code/config.yml (global)
396
- model: claude-opus-4-6
595
+ provider: anthropic # default provider on startup
596
+ model: claude-opus-4-6 # default model on startup
397
597
  permission_mode: allow_read
398
598
  session_budget: 5.00
399
599
  daily_budget: 10.00
400
600
 
401
601
  # .rubyn-code/config.yml (project — overrides global)
402
- model: claude-sonnet-4-6
602
+ provider: minimax # this project uses MiniMax by default
603
+ model: MiniMax-M2.7-highspeed
403
604
  permission_mode: autonomous
404
-
405
- # Use OpenAI instead of Anthropic
406
- # provider: openai
407
- # model: gpt-4o
408
-
409
- # Use an OpenAI-compatible provider
410
- # provider: groq
411
- # provider_base_url: https://api.groq.com/openai/v1
412
- # model: llama-3.3-70b
413
-
414
- # Local Ollama (no API key needed)
415
- # provider: ollama
416
- # provider_base_url: http://localhost:11434/v1
417
- # model: llama3
418
605
  ```
419
606
 
420
607
  ### Multi-Provider Model Routing
@@ -469,6 +656,55 @@ providers:
469
656
 
470
657
  You can also set custom pricing per model so `/cost` reports accurate spending for third-party providers.
471
658
 
659
+ ## Security
660
+
661
+ ### Credential Storage
662
+
663
+ All provider API keys are encrypted at rest using **AES-256-GCM** (authenticated encryption).
664
+ Keys are never stored as plaintext on disk.
665
+
666
+ | Layer | Detail |
667
+ |-------|--------|
668
+ | **Cipher** | AES-256-GCM (authenticated — detects tampering) |
669
+ | **Key derivation** | PBKDF2-HMAC-SHA256, 100,000 iterations |
670
+ | **Machine binding** | Key derived from username + hostname + home directory |
671
+ | **Salt** | Random 32-byte salt, generated once, stored in `~/.rubyn-code/.encryption_salt` |
672
+ | **File permissions** | `tokens.yml` and `.encryption_salt` are `0600` (owner read/write only) |
673
+
674
+ This means:
675
+ - Keys copied to another machine or user account cannot be decrypted
676
+ - The encryption key is never stored — it is derived at runtime
677
+ - Plaintext keys from older versions are automatically encrypted on first read
678
+
679
+ ### File Permissions
680
+
681
+ | File | Permissions | Contents |
682
+ |------|------------|----------|
683
+ | `~/.rubyn-code/` | `0700` | Home directory |
684
+ | `~/.rubyn-code/tokens.yml` | `0600` | Encrypted API keys, OAuth tokens |
685
+ | `~/.rubyn-code/.encryption_salt` | `0600` | PBKDF2 salt (not secret alone, but protected) |
686
+ | `~/.rubyn-code/config.yml` | `0600` | Provider config (no secrets) |
687
+
688
+ ## Diagnostics
689
+
690
+ Run `/doctor` to check your environment:
691
+
692
+ ```
693
+ rubyn > /doctor
694
+
695
+ ✓ Ruby version 4.0.2
696
+ ✓ Bundler installed
697
+ ✓ Database 12 migrations applied
698
+ ✓ Authentication valid (keychain)
699
+ ✓ Skills 112 available
700
+ ✓ Project detected Rails 7.1
701
+ ✓ MCP servers 2 connected
702
+ ✓ Codebase index fresh (2 hours ago)
703
+ ✓ Skill catalog 112 skills, 0 malformed
704
+ ```
705
+
706
+ Checks Ruby version, bundler, database state, authentication, skills, project type, MCP server connectivity, codebase index freshness, and skill catalog integrity.
707
+
472
708
  ## Development
473
709
 
474
710
  Requires Ruby 4.0+.
@@ -480,6 +716,12 @@ bundle install
480
716
  bundle exec rspec
481
717
  ```
482
718
 
719
+ Quick rebuild from source:
720
+
721
+ ```bash
722
+ bin/dev-install
723
+ ```
724
+
483
725
  ## From Rubyn to Rubyn Code
484
726
 
485
727
  If you used the original [Rubyn gem](https://github.com/Rubyn-AI/rubyn), here's what changed:
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Expands the tasks CHECK constraint on status to include 'failed',
4
+ # used by the GOLEM daemon to mark tasks that have exceeded max retries.
5
+ #
6
+ # SQLite does not support ALTER CONSTRAINT, so we rebuild the table.
7
+ # The Migrator already wraps .up in a transaction — no manual BEGIN/COMMIT here.
8
+ module Migration013AddFailedStatusToTasks
9
+ module_function
10
+
11
+ def up(db)
12
+ create_new_tasks_table(db)
13
+ migrate_data(db)
14
+ swap_tables(db)
15
+ end
16
+
17
+ def create_new_tasks_table(db)
18
+ db.execute(<<~SQL)
19
+ CREATE TABLE tasks_new (
20
+ id TEXT PRIMARY KEY,
21
+ session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
22
+ title TEXT NOT NULL,
23
+ description TEXT,
24
+ status TEXT NOT NULL DEFAULT 'pending'
25
+ CHECK(status IN ('pending','in_progress','blocked','completed','cancelled','failed')),
26
+ priority INTEGER NOT NULL DEFAULT 0,
27
+ owner TEXT,
28
+ result TEXT,
29
+ metadata TEXT DEFAULT '{}',
30
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
31
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
32
+ )
33
+ SQL
34
+ end
35
+
36
+ def migrate_data(db)
37
+ db.execute(<<~SQL)
38
+ INSERT INTO tasks_new (id, session_id, title, description, status, priority, owner, result, metadata, created_at, updated_at)
39
+ SELECT id, session_id, title, description, status, priority, owner, result, metadata, created_at, updated_at
40
+ FROM tasks
41
+ SQL
42
+ end
43
+
44
+ def swap_tables(db)
45
+ db.execute('DROP TABLE tasks')
46
+ db.execute('ALTER TABLE tasks_new RENAME TO tasks')
47
+ db.execute('CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id)')
48
+ db.execute('CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)')
49
+ db.execute('CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner)')
50
+ end
51
+ end
@@ -126,19 +126,48 @@ module RubynCode
126
126
 
127
127
  # Ensure every tool_use block has a matching tool_result.
128
128
  # If a tool_use is orphaned (e.g. from Ctrl-C interruption),
129
- # inject a synthetic tool_result so the API doesn't reject the request.
129
+ # inject a synthetic tool_result immediately after the assistant
130
+ # message that contains the orphaned tool_use.
130
131
  def repair_orphaned_tool_uses(formatted)
131
132
  orphaned = collect_tool_use_ids(formatted) - collect_tool_result_ids(formatted)
132
133
  return formatted if orphaned.empty?
133
134
 
134
- orphan_results = orphaned.map do |id|
135
+ insert_orphan_results(formatted, orphaned)
136
+ end
137
+
138
+ # Walk backwards to find the assistant message containing each orphaned
139
+ # tool_use and insert a user/tool_result message right after it.
140
+ # -- walks messages to find insertion point
141
+ def insert_orphan_results(formatted, orphaned)
142
+ orphan_set = orphaned.to_a.to_set
143
+ insert_idx = find_orphan_insert_index(formatted, orphan_set)
144
+
145
+ results = orphaned.map do |id|
135
146
  { type: 'tool_result', tool_use_id: id, content: '[interrupted]', is_error: true }
136
147
  end
137
148
 
138
- formatted << { role: 'user', content: orphan_results }
149
+ formatted.insert(insert_idx, { role: 'user', content: results })
139
150
  formatted
140
151
  end
141
152
 
153
+ # Find the index right after the last assistant message that contains
154
+ # any of the orphaned tool_use IDs.
155
+ def find_orphan_insert_index(formatted, orphan_set)
156
+ formatted.each_with_index.reverse_each do |msg, idx|
157
+ next unless msg[:role] == 'assistant' && msg[:content].is_a?(Array)
158
+ return idx + 1 if assistant_has_orphan?(msg, orphan_set)
159
+ end
160
+
161
+ formatted.length # fallback: append at end
162
+ end
163
+
164
+ def assistant_has_orphan?(msg, orphan_set)
165
+ msg[:content].any? do |block|
166
+ block.is_a?(Hash) && block_matches_type?(block, 'tool_use') &&
167
+ orphan_set.include?(block[:id] || block['id'])
168
+ end
169
+ end
170
+
142
171
  def collect_tool_use_ids(formatted)
143
172
  collect_block_ids(formatted, role: 'assistant', type: 'tool_use', id_key: :id, id_str_key: 'id')
144
173
  end
@@ -148,7 +177,8 @@ module RubynCode
148
177
  id_str_key: 'tool_use_id')
149
178
  end
150
179
 
151
- def collect_block_ids(formatted, role:, type:, id_key:, id_str_key:) # rubocop:disable Metrics/CyclomaticComplexity -- iterates blocks with type+role guards
180
+ # -- iterates blocks with type+role guards
181
+ def collect_block_ids(formatted, role:, type:, id_key:, id_str_key:)
152
182
  ids = Set.new
153
183
  formatted.each do |msg|
154
184
  next unless msg[:role] == role && msg[:content].is_a?(Array)
@@ -31,8 +31,10 @@ module RubynCode
31
31
  #
32
32
  # @param task_context [Symbol, nil] detected task type
33
33
  # @param discovered_tools [Set<String>] tools already discovered this session
34
+ # @param codebase_index [RubynCode::Index::CodebaseIndex, nil] optional index for deeper context detection
35
+ # @param message [String, nil] original user message for index-based matching
34
36
  # @return [Array<String>] tool names to include in the schema
35
- def active_tools(task_context: nil, discovered_tools: Set.new)
37
+ def active_tools(task_context: nil, discovered_tools: Set.new, codebase_index: nil, message: nil)
36
38
  tools = BASE_TOOLS.dup
37
39
 
38
40
  # Always include interaction tools
@@ -45,6 +47,12 @@ module RubynCode
45
47
  tools.concat(context_tools)
46
48
  end
47
49
 
50
+ # Add index-aware tools when a codebase index and message are available
51
+ if codebase_index && message
52
+ index_contexts = detect_index_contexts(message, codebase_index)
53
+ index_contexts.each { |ctx| tools.concat(resolve_context_tools(ctx)) }
54
+ end
55
+
48
56
  # Always include previously discovered tools
49
57
  tools.concat(discovered_tools.to_a)
50
58
 
@@ -54,8 +62,10 @@ module RubynCode
54
62
  # Detect task context from a user message.
55
63
  #
56
64
  # @param message [String]
65
+ # @param codebase_index [RubynCode::Index::CodebaseIndex, nil] optional index for deeper detection
57
66
  # @return [Symbol, nil]
58
- def detect_context(message) # rubocop:disable Metrics/CyclomaticComplexity -- context detection dispatch
67
+ # -- context detection dispatch
68
+ def detect_context(message, codebase_index: nil)
59
69
  msg = message.to_s.downcase
60
70
  return :testing if msg.match?(/\b(test|spec|rspec)\b/)
61
71
  return :git if msg.match?(/\b(commit|push|diff|branch|merge|git)\b/)
@@ -65,7 +75,27 @@ module RubynCode
65
75
  return :explore if msg.match?(/\b(explore|architecture|structure)\b/)
66
76
  return :teams if msg.match?(/\b(team|spawn|message|inbox)\b/)
67
77
 
68
- nil
78
+ # Fall back to index-based detection when keyword matching yields nothing
79
+ return nil unless codebase_index
80
+
81
+ index_contexts = detect_index_contexts(message, codebase_index)
82
+ index_contexts.first
83
+ end
84
+
85
+ # Detect additional tool contexts based on codebase index content.
86
+ #
87
+ # @param message [String] user message
88
+ # @param codebase_index [RubynCode::Index::CodebaseIndex] codebase index instance
89
+ # @return [Array<Symbol>] detected context symbols
90
+ def detect_index_contexts(message, codebase_index)
91
+ contexts = []
92
+ return contexts unless codebase_index
93
+
94
+ contexts << :rails if message_mentions_model?(message, codebase_index)
95
+ contexts << :testing if message_mentions_specced_file?(message, codebase_index)
96
+ contexts.uniq
97
+ rescue StandardError
98
+ []
69
99
  end
70
100
 
71
101
  # Filter full tool definitions to only include active tools.
@@ -93,6 +123,30 @@ module RubynCode
93
123
  []
94
124
  end
95
125
  end
126
+
127
+ # Check if the user message mentions a model name from the index.
128
+ def message_mentions_model?(message, codebase_index)
129
+ model_names = codebase_index.nodes
130
+ .select { |n| n['type'] == 'model' }
131
+ .map { |n| n['name'] }
132
+ return false if model_names.empty?
133
+
134
+ msg_lower = message.to_s.downcase
135
+ model_names.any? { |name| msg_lower.include?(name.downcase) }
136
+ end
137
+
138
+ # Check if the user message mentions a file that has specs in the index.
139
+ def message_mentions_specced_file?(message, codebase_index)
140
+ spec_edges = codebase_index.edges.select { |e| e['relationship'] == 'tests' }
141
+ return false if spec_edges.empty?
142
+
143
+ tested_files = spec_edges.map { |e| e['to'] }.compact
144
+ msg_lower = message.to_s.downcase
145
+ tested_files.any? do |file|
146
+ basename = File.basename(file, '.rb')
147
+ msg_lower.include?(basename)
148
+ end
149
+ end
96
150
  end
97
151
  end
98
152
  end
@@ -43,7 +43,10 @@ module RubynCode
43
43
  # Only returns models from the active provider — never crosses
44
44
  # provider boundaries (e.g., won't send a GPT model to Anthropic).
45
45
  # Falls back to nil (use client's default) if routing fails.
46
+ # -- guard clauses for provider/mode checks
46
47
  def routed_model
48
+ return nil if manual_model_mode?
49
+
47
50
  last_user = last_user_message_text
48
51
  return nil unless last_user
49
52
 
@@ -60,6 +63,12 @@ module RubynCode
60
63
  nil
61
64
  end
62
65
 
66
+ def manual_model_mode?
67
+ Config::Settings.new.get('model_mode', 'auto') == 'manual'
68
+ rescue StandardError
69
+ false
70
+ end
71
+
63
72
  def last_user_message_text
64
73
  msg = @conversation.messages.reverse_each.find { |m| m[:role] == 'user' }
65
74
  return nil unless msg
@@ -68,7 +77,8 @@ module RubynCode
68
77
  content.is_a?(String) ? content : nil
69
78
  end
70
79
 
71
- def log_llm_call(opts) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- safe accessor checks
80
+ # -- safe accessor checks
81
+ def log_llm_call(opts)
72
82
  default_model = @llm_client.respond_to?(:model) ? @llm_client.model : 'default'
73
83
  routed = opts[:model]
74
84
  effective = routed || default_model