rubyn-code 0.3.0 → 0.4.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -19
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/conversation.rb +32 -3
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +56 -3
  6. data/lib/rubyn_code/agent/llm_caller.rb +9 -1
  7. data/lib/rubyn_code/agent/loop.rb +7 -0
  8. data/lib/rubyn_code/agent/system_prompt_builder.rb +10 -4
  9. data/lib/rubyn_code/agent/tool_processor.rb +21 -1
  10. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  11. data/lib/rubyn_code/auth/token_store.rb +50 -9
  12. data/lib/rubyn_code/autonomous/daemon.rb +117 -14
  13. data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
  14. data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
  15. data/lib/rubyn_code/cli/app.rb +32 -1
  16. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  17. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  18. data/lib/rubyn_code/cli/commands/model.rb +32 -2
  19. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  20. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  21. data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
  22. data/lib/rubyn_code/cli/first_run.rb +159 -0
  23. data/lib/rubyn_code/cli/repl.rb +6 -1
  24. data/lib/rubyn_code/cli/repl_commands.rb +2 -1
  25. data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
  26. data/lib/rubyn_code/cli/repl_setup.rb +36 -0
  27. data/lib/rubyn_code/config/defaults.rb +1 -0
  28. data/lib/rubyn_code/config/schema.json +49 -0
  29. data/lib/rubyn_code/config/settings.rb +7 -4
  30. data/lib/rubyn_code/config/validator.rb +63 -0
  31. data/lib/rubyn_code/context/context_budget.rb +16 -1
  32. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  33. data/lib/rubyn_code/context/manager.rb +37 -3
  34. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  35. data/lib/rubyn_code/hooks/registry.rb +4 -0
  36. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  37. data/lib/rubyn_code/ide/client.rb +110 -0
  38. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  39. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  40. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  41. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  42. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  43. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  44. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  45. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  46. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  47. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  48. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  49. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  50. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  51. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  52. data/lib/rubyn_code/ide/handlers.rb +76 -0
  53. data/lib/rubyn_code/ide/protocol.rb +111 -0
  54. data/lib/rubyn_code/ide/server.rb +186 -0
  55. data/lib/rubyn_code/index/codebase_index.rb +67 -1
  56. data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
  57. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  58. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
  59. data/lib/rubyn_code/llm/client.rb +29 -4
  60. data/lib/rubyn_code/mcp/config.rb +2 -1
  61. data/lib/rubyn_code/memory/search.rb +1 -0
  62. data/lib/rubyn_code/self_test.rb +315 -0
  63. data/lib/rubyn_code/skills/catalog.rb +66 -0
  64. data/lib/rubyn_code/skills/loader.rb +43 -0
  65. data/lib/rubyn_code/tasks/models.rb +1 -0
  66. data/lib/rubyn_code/tools/base.rb +13 -0
  67. data/lib/rubyn_code/tools/bash.rb +5 -0
  68. data/lib/rubyn_code/tools/edit_file.rb +62 -5
  69. data/lib/rubyn_code/tools/executor.rb +61 -6
  70. data/lib/rubyn_code/tools/glob.rb +6 -0
  71. data/lib/rubyn_code/tools/grep.rb +6 -0
  72. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  73. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  74. data/lib/rubyn_code/tools/output_compressor.rb +6 -1
  75. data/lib/rubyn_code/tools/read_file.rb +6 -0
  76. data/lib/rubyn_code/tools/registry.rb +11 -0
  77. data/lib/rubyn_code/tools/write_file.rb +17 -0
  78. data/lib/rubyn_code/version.rb +1 -1
  79. data/lib/rubyn_code.rb +22 -0
  80. data/skills/rubyn_self_test.md +13 -1
  81. metadata +31 -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: 1a3ef81b040a1c9a75050f2545f229ba69aa88808ced47ad5cc8634ea52f115c
4
+ data.tar.gz: 9020382400cdf70d332858a9b85c89e5aa4a90e07965709e8520a803273d43b2
5
5
  SHA512:
6
- metadata.gz: 6bb339794d0cbe8d149e0b2e94c408b304d48f786aa2a297af5c8028c15c9796d4e5f0313be20c1ecf8b449c0ef80afa4263f581603c5c915b540c7a89b8257c
7
- data.tar.gz: 2f1fc37549f2223a5c5f07be4817bade9a4d6466ef30b8927fb0de94cf4c78c41b1b5e9e8a3379fb69e503d34226df9d22e4988bb45231666afc49e4f325d789
6
+ metadata.gz: d7c2b95f2eec7e20e589c377784a2236f5ca7fa62402012358137a895f18e899e521ab2a3280854dda2376d515d906a40ad56dcc9c21d65062da6c9549a18912
7
+ data.tar.gz: cbb80d2749475054829c46aadd8ed5b7244d99f9d6ee05f73a0cd85618866e4e43881ae77e6718dbdcc6cced9e57222752b2a5c403185ae92a039a13da5105e1
data/README.md CHANGED
@@ -333,6 +333,8 @@ rubyn-code --help # Show help
333
333
  | `/budget [amt]` | Show or set session budget |
334
334
  | `/skill [name]` | Load or list available skills |
335
335
  | `/resume [id]` | Resume or list sessions |
336
+ | `/provider` | Add or list providers |
337
+ | `/model` | Show/switch model and provider |
336
338
 
337
339
  ## Authentication
338
340
 
@@ -354,14 +356,50 @@ export OPENAI_API_KEY=sk-...
354
356
 
355
357
  Available models: `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-4o`, `gpt-4o-mini`, `o3`, `o4-mini`
356
358
 
357
- ### OpenAI-Compatible Providers (Groq, Together, Ollama, etc.)
359
+ ### Other Providers (Groq, Together, Ollama, etc.)
358
360
 
359
- Set the provider-specific API key and configure via `config.yml`:
361
+ Add a provider and its API key in one command:
360
362
 
361
363
  ```bash
362
- export GROQ_API_KEY=gsk-...
364
+ /provider add groq https://api.groq.com/openai/v1 --key gsk-xxx --models llama-3.3-70b
365
+
366
+ # For Anthropic-format proxies (e.g., Bedrock, custom gateways)
367
+ /provider add my-proxy https://proxy.example.com/v1 --format anthropic --key sk-xxx --models claude-sonnet-4-6
368
+
369
+ # Update a key later
370
+ /provider set-key groq gsk-new-key
371
+
372
+ # List configured providers
373
+ /provider list
374
+ ```
375
+
376
+ API keys are **encrypted at rest** using AES-256-GCM. The encryption key is derived from
377
+ your machine identity (username, hostname, home directory) via PBKDF2, so keys are only
378
+ decryptable on the same machine by the same user. Rubyn decrypts them automatically at
379
+ runtime and re-encrypts on save — no manual steps required.
380
+
381
+ Keys stored via environment variables (`GROQ_API_KEY`, `TOGETHER_API_KEY`, etc.) also work
382
+ as a fallback if you prefer that approach.
383
+
384
+ Or add directly to `~/.rubyn-code/config.yml`:
385
+
386
+ ```yaml
387
+ providers:
388
+ groq:
389
+ base_url: https://api.groq.com/openai/v1
390
+ env_key: GROQ_API_KEY
391
+ models:
392
+ top: llama-3.3-70b
393
+ my-proxy:
394
+ api_format: anthropic # 'openai' (default) or 'anthropic'
395
+ base_url: https://proxy.example.com/v1
396
+ env_key: PROXY_API_KEY
397
+ models:
398
+ top: claude-sonnet-4-6
363
399
  ```
364
400
 
401
+ Then switch with `/model groq:llama-3.3-70b`.
402
+
365
403
  Local providers (Ollama, LM Studio) running on `localhost`/`127.0.0.1` don't require an API key.
366
404
 
367
405
  ## Architecture
@@ -391,30 +429,21 @@ Local providers (Ollama, LM Studio) running on `localhost`/`127.0.0.1` don't req
391
429
 
392
430
  ## Configuration
393
431
 
432
+ The `provider` and `model` keys at the top set the **default provider and model** used at startup.
433
+ These must match a provider defined in the `providers` section (or a built-in like `anthropic`/`openai`).
434
+
394
435
  ```yaml
395
436
  # ~/.rubyn-code/config.yml (global)
396
- model: claude-opus-4-6
437
+ provider: anthropic # default provider on startup
438
+ model: claude-opus-4-6 # default model on startup
397
439
  permission_mode: allow_read
398
440
  session_budget: 5.00
399
441
  daily_budget: 10.00
400
442
 
401
443
  # .rubyn-code/config.yml (project — overrides global)
402
- model: claude-sonnet-4-6
444
+ provider: minimax # this project uses MiniMax by default
445
+ model: MiniMax-M2.7-highspeed
403
446
  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
447
  ```
419
448
 
420
449
  ### Multi-Provider Model Routing
@@ -469,6 +498,35 @@ providers:
469
498
 
470
499
  You can also set custom pricing per model so `/cost` reports accurate spending for third-party providers.
471
500
 
501
+ ## Security
502
+
503
+ ### Credential Storage
504
+
505
+ All provider API keys are encrypted at rest using **AES-256-GCM** (authenticated encryption).
506
+ Keys are never stored as plaintext on disk.
507
+
508
+ | Layer | Detail |
509
+ |-------|--------|
510
+ | **Cipher** | AES-256-GCM (authenticated — detects tampering) |
511
+ | **Key derivation** | PBKDF2-HMAC-SHA256, 100,000 iterations |
512
+ | **Machine binding** | Key derived from username + hostname + home directory |
513
+ | **Salt** | Random 32-byte salt, generated once, stored in `~/.rubyn-code/.encryption_salt` |
514
+ | **File permissions** | `tokens.yml` and `.encryption_salt` are `0600` (owner read/write only) |
515
+
516
+ This means:
517
+ - Keys copied to another machine or user account cannot be decrypted
518
+ - The encryption key is never stored — it is derived at runtime
519
+ - Plaintext keys from older versions are automatically encrypted on first read
520
+
521
+ ### File Permissions
522
+
523
+ | File | Permissions | Contents |
524
+ |------|------------|----------|
525
+ | `~/.rubyn-code/` | `0700` | Home directory |
526
+ | `~/.rubyn-code/tokens.yml` | `0600` | Encrypted API keys, OAuth tokens |
527
+ | `~/.rubyn-code/.encryption_salt` | `0600` | PBKDF2 salt (not secret alone, but protected) |
528
+ | `~/.rubyn-code/config.yml` | `0600` | Provider config (no secrets) |
529
+
472
530
  ## Development
473
531
 
474
532
  Requires Ruby 4.0+.
@@ -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
@@ -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,9 @@ 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
+ def detect_context(message, codebase_index: nil) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- context detection dispatch
59
68
  msg = message.to_s.downcase
60
69
  return :testing if msg.match?(/\b(test|spec|rspec)\b/)
61
70
  return :git if msg.match?(/\b(commit|push|diff|branch|merge|git)\b/)
@@ -65,7 +74,27 @@ module RubynCode
65
74
  return :explore if msg.match?(/\b(explore|architecture|structure)\b/)
66
75
  return :teams if msg.match?(/\b(team|spawn|message|inbox)\b/)
67
76
 
68
- nil
77
+ # Fall back to index-based detection when keyword matching yields nothing
78
+ return nil unless codebase_index
79
+
80
+ index_contexts = detect_index_contexts(message, codebase_index)
81
+ index_contexts.first
82
+ end
83
+
84
+ # Detect additional tool contexts based on codebase index content.
85
+ #
86
+ # @param message [String] user message
87
+ # @param codebase_index [RubynCode::Index::CodebaseIndex] codebase index instance
88
+ # @return [Array<Symbol>] detected context symbols
89
+ def detect_index_contexts(message, codebase_index)
90
+ contexts = []
91
+ return contexts unless codebase_index
92
+
93
+ contexts << :rails if message_mentions_model?(message, codebase_index)
94
+ contexts << :testing if message_mentions_specced_file?(message, codebase_index)
95
+ contexts.uniq
96
+ rescue StandardError
97
+ []
69
98
  end
70
99
 
71
100
  # Filter full tool definitions to only include active tools.
@@ -93,6 +122,30 @@ module RubynCode
93
122
  []
94
123
  end
95
124
  end
125
+
126
+ # Check if the user message mentions a model name from the index.
127
+ def message_mentions_model?(message, codebase_index)
128
+ model_names = codebase_index.nodes
129
+ .select { |n| n['type'] == 'model' }
130
+ .map { |n| n['name'] }
131
+ return false if model_names.empty?
132
+
133
+ msg_lower = message.to_s.downcase
134
+ model_names.any? { |name| msg_lower.include?(name.downcase) }
135
+ end
136
+
137
+ # Check if the user message mentions a file that has specs in the index.
138
+ def message_mentions_specced_file?(message, codebase_index)
139
+ spec_edges = codebase_index.edges.select { |e| e['relationship'] == 'tests' }
140
+ return false if spec_edges.empty?
141
+
142
+ tested_files = spec_edges.map { |e| e['to'] }.compact
143
+ msg_lower = message.to_s.downcase
144
+ tested_files.any? do |file|
145
+ basename = File.basename(file, '.rb')
146
+ msg_lower.include?(basename)
147
+ end
148
+ end
96
149
  end
97
150
  end
98
151
  end
@@ -43,7 +43,9 @@ 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
- def routed_model
46
+ def routed_model # rubocop:disable Metrics/CyclomaticComplexity -- guard clauses for provider/mode checks
47
+ return nil if manual_model_mode?
48
+
47
49
  last_user = last_user_message_text
48
50
  return nil unless last_user
49
51
 
@@ -60,6 +62,12 @@ module RubynCode
60
62
  nil
61
63
  end
62
64
 
65
+ def manual_model_mode?
66
+ Config::Settings.new.get('model_mode', 'auto') == 'manual'
67
+ rescue StandardError
68
+ false
69
+ end
70
+
63
71
  def last_user_message_text
64
72
  msg = @conversation.messages.reverse_each.find { |m| m[:role] == 'user' }
65
73
  return nil unless msg
@@ -44,6 +44,9 @@ module RubynCode
44
44
  # @return [Boolean]
45
45
  attr_accessor :plan_mode
46
46
 
47
+ # @return [Index::CodebaseIndex, nil]
48
+ attr_reader :codebase_index
49
+
47
50
  # Send a user message and run the agent loop until a final text
48
51
  # response is produced or the iteration limit is reached.
49
52
  #
@@ -91,6 +94,7 @@ module RubynCode
91
94
  @stall_detector = opts.fetch(:stall_detector, LoopDetector.new)
92
95
  @skill_loader = opts[:skill_loader]
93
96
  @project_root = opts[:project_root]
97
+ @tool_wrapper = opts[:tool_wrapper]
94
98
  @decision_compactor = build_decision_compactor
95
99
  @skill_ttl = Skills::TtlManager.new
96
100
  @session_initialized = false
@@ -123,6 +127,7 @@ module RubynCode
123
127
  def build_codebase_index!
124
128
  index = Index::CodebaseIndex.new(project_root: @project_root)
125
129
  index.load_or_build!
130
+ @codebase_index = index
126
131
  RubynCode::Debug.agent("Codebase index: #{index.stats[:nodes]} nodes, #{index.stats[:files_indexed]} files")
127
132
  rescue StandardError => e
128
133
  RubynCode::Debug.warn("Codebase index failed: #{e.message}")
@@ -143,6 +148,7 @@ module RubynCode
143
148
 
144
149
  def run_iteration(iteration)
145
150
  log_iteration(iteration)
151
+ @context_manager.advance_turn!
146
152
  compact_if_needed # ensure context is under threshold before LLM call
147
153
  response = call_llm
148
154
  tool_calls = extract_tool_calls(response)
@@ -224,6 +230,7 @@ module RubynCode
224
230
  @conversation.add_assistant_message(get_content(response))
225
231
  process_tool_calls(tool_calls)
226
232
  drain_background_notifications
233
+ @decision_compactor&.check!(@conversation)
227
234
  run_maintenance(iteration)
228
235
  nil
229
236
  end
@@ -64,15 +64,21 @@ module RubynCode
64
64
  def append_codebase_index(parts)
65
65
  return unless @project_root
66
66
 
67
- index = Index::CodebaseIndex.new(project_root: @project_root)
68
- loaded = index.load
69
- return unless loaded && index.nodes.any?
67
+ index = resolve_codebase_index
68
+ return unless index&.nodes&.any?
70
69
 
71
- parts << "\n## #{index.to_prompt_summary}"
70
+ parts << "\n## #{index.to_structural_summary}"
72
71
  rescue StandardError
73
72
  nil
74
73
  end
75
74
 
75
+ def resolve_codebase_index
76
+ return @codebase_index if defined?(@codebase_index) && @codebase_index
77
+
78
+ idx = Index::CodebaseIndex.new(project_root: @project_root)
79
+ idx.load
80
+ end
81
+
76
82
  def append_memories(parts)
77
83
  memories = load_memories
78
84
  return if memories.empty?
@@ -51,6 +51,7 @@ module RubynCode
51
51
  Tools::Registry.all.select { |t| PLAN_MODE_RISK_LEVELS.include?(t::RISK_LEVEL) }.map(&:to_schema)
52
52
  end
53
53
 
54
+ # -- tool dispatch with budget + signals
54
55
  def process_tool_calls(tool_calls)
55
56
  aggregate_chars = 0
56
57
  budget = Config::Defaults::MAX_MESSAGE_TOOL_RESULTS_CHARS
@@ -62,6 +63,7 @@ module RubynCode
62
63
  notify_tool_result(field(tool_call, :name), result, is_error)
63
64
  record_tool_result(tool_call, result, is_error)
64
65
  end
66
+ @decision_compactor&.signal_edit_batch_complete!
65
67
  end
66
68
 
67
69
  def run_single_tool(tool_call)
@@ -114,14 +116,32 @@ module RubynCode
114
116
  def execute_tool(tool_name, tool_input)
115
117
  discover_tool(tool_name)
116
118
  @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
117
- result = @tool_executor.execute(tool_name, symbolize_keys(tool_input))
119
+ result = dispatch_tool(tool_name, tool_input)
118
120
  @hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
119
121
  signal_decision_compactor(tool_name, tool_input, result)
120
122
  [result.to_s, false]
123
+ rescue RubynCode::UserDeniedError => e
124
+ # User refused this call via the IDE. Surface as is_error so the model
125
+ # knows the tool did not run, not that it ran and returned text.
126
+ [e.message, true]
121
127
  rescue StandardError => e
122
128
  ["Error executing #{tool_name}: #{e.message}", true]
123
129
  end
124
130
 
131
+ # Run the tool through @tool_wrapper if one is configured (IDE mode),
132
+ # otherwise call the executor directly. The wrapper receives the raw
133
+ # tool name/input so it can emit protocol notifications and gate the
134
+ # call; the block below is what actually performs the work.
135
+ def dispatch_tool(tool_name, tool_input)
136
+ if @tool_wrapper
137
+ @tool_wrapper.call(tool_name, tool_input) do
138
+ @tool_executor.execute(tool_name, symbolize_keys(tool_input))
139
+ end
140
+ else
141
+ @tool_executor.execute(tool_name, symbolize_keys(tool_input))
142
+ end
143
+ end
144
+
125
145
  def signal_decision_compactor(tool_name, tool_input, result) # rubocop:disable Metrics/CyclomaticComplexity -- tool dispatch
126
146
  return unless @decision_compactor
127
147
 
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+ require 'securerandom'
6
+ require 'etc'
7
+ require 'socket'
8
+
9
+ module RubynCode
10
+ module Auth
11
+ # Encrypts and decrypts provider API keys at rest using AES-256-GCM.
12
+ #
13
+ # The encryption key is derived via PBKDF2 from machine-specific identifiers
14
+ # (username, hostname, home directory) combined with a random salt stored in
15
+ # ~/.rubyn-code/.encryption_salt. This means keys are only decryptable on the
16
+ # same machine by the same user.
17
+ #
18
+ # Encrypted values are prefixed with "enc:v1:" so plaintext values from older
19
+ # versions are transparently migrated on first read.
20
+ module KeyEncryption
21
+ CIPHER = 'aes-256-gcm'
22
+ PREFIX = 'enc:v1:'
23
+ IV_LENGTH = 12
24
+ TAG_LENGTH = 16
25
+ PBKDF2_ITERATIONS = 100_000
26
+ KEY_LENGTH = 32
27
+ SALT_LENGTH = 32
28
+
29
+ class << self
30
+ def encrypt(plaintext)
31
+ return nil unless plaintext
32
+
33
+ cipher = OpenSSL::Cipher.new(CIPHER).encrypt
34
+ key = derive_key
35
+ cipher.key = key
36
+ iv = cipher.random_iv
37
+
38
+ ciphertext = cipher.update(plaintext) + cipher.final
39
+ tag = cipher.auth_tag(TAG_LENGTH)
40
+
41
+ encoded = Base64.strict_encode64(iv + ciphertext + tag)
42
+ "#{PREFIX}#{encoded}"
43
+ end
44
+
45
+ def decrypt(value)
46
+ return nil unless value
47
+ return value unless encrypted?(value)
48
+
49
+ raw = Base64.strict_decode64(value.delete_prefix(PREFIX))
50
+ decrypt_raw(raw)
51
+ rescue OpenSSL::Cipher::CipherError, ArgumentError
52
+ nil
53
+ end
54
+
55
+ def encrypted?(value)
56
+ value.is_a?(String) && value.start_with?(PREFIX)
57
+ end
58
+
59
+ private
60
+
61
+ def decrypt_raw(raw)
62
+ iv = raw[0, IV_LENGTH]
63
+ tag = raw[-TAG_LENGTH, TAG_LENGTH]
64
+ ciphertext = raw[IV_LENGTH...-TAG_LENGTH]
65
+
66
+ cipher = OpenSSL::Cipher.new(CIPHER).decrypt
67
+ cipher.key = derive_key
68
+ cipher.iv = iv
69
+ cipher.auth_tag = tag
70
+ (cipher.update(ciphertext) + cipher.final).force_encoding('UTF-8')
71
+ end
72
+
73
+ def derive_key
74
+ OpenSSL::KDF.pbkdf2_hmac(
75
+ machine_identity,
76
+ salt: load_or_create_salt,
77
+ iterations: PBKDF2_ITERATIONS,
78
+ length: KEY_LENGTH,
79
+ hash: 'SHA256'
80
+ )
81
+ end
82
+
83
+ def machine_identity
84
+ # Use the real UID's login name rather than Etc.getlogin. Etc.getlogin
85
+ # reads the controlling tty's owner and can return "root" when the tty
86
+ # is root-owned (common after `sudo`, and in some VSCode integrated
87
+ # terminal setups) — even though the process itself is running as the
88
+ # real user. That mismatch derives a different AES key on decrypt vs.
89
+ # encrypt and the AEAD tag check fails, which surfaces as a misleading
90
+ # "No <provider> API key configured" error.
91
+ user = begin
92
+ Etc.getpwuid(Process.uid).name
93
+ rescue StandardError
94
+ ENV['USER'] || Etc.getlogin || 'unknown'
95
+ end
96
+ [user, Socket.gethostname, Dir.home].join(':')
97
+ end
98
+
99
+ def load_or_create_salt
100
+ path = salt_path
101
+ if File.exist?(path)
102
+ File.binread(path)
103
+ else
104
+ salt = SecureRandom.random_bytes(SALT_LENGTH)
105
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
106
+ File.binwrite(path, salt)
107
+ File.chmod(0o600, path)
108
+ salt
109
+ end
110
+ end
111
+
112
+ def salt_path
113
+ File.join(Config::Defaults::HOME_DIR, '.encryption_salt')
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end