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.
- checksums.yaml +4 -4
- data/README.md +77 -19
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/conversation.rb +32 -3
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +56 -3
- data/lib/rubyn_code/agent/llm_caller.rb +9 -1
- data/lib/rubyn_code/agent/loop.rb +7 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +10 -4
- data/lib/rubyn_code/agent/tool_processor.rb +21 -1
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/token_store.rb +50 -9
- data/lib/rubyn_code/autonomous/daemon.rb +117 -14
- data/lib/rubyn_code/autonomous/idle_poller.rb +0 -20
- data/lib/rubyn_code/autonomous/task_claimer.rb +17 -11
- data/lib/rubyn_code/cli/app.rb +32 -1
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +32 -2
- data/lib/rubyn_code/cli/commands/provider.rb +123 -0
- data/lib/rubyn_code/cli/commands/skill.rb +52 -3
- data/lib/rubyn_code/cli/daemon_runner.rb +36 -0
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/repl.rb +6 -1
- data/lib/rubyn_code/cli/repl_commands.rb +2 -1
- data/lib/rubyn_code/cli/repl_lifecycle.rb +1 -0
- data/lib/rubyn_code/cli/repl_setup.rb +36 -0
- data/lib/rubyn_code/config/defaults.rb +1 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +7 -4
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/context_budget.rb +16 -1
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/manager.rb +37 -3
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +111 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +67 -1
- data/lib/rubyn_code/llm/adapters/anthropic.rb +6 -2
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +6 -2
- data/lib/rubyn_code/llm/client.rb +29 -4
- data/lib/rubyn_code/mcp/config.rb +2 -1
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/base.rb +13 -0
- data/lib/rubyn_code/tools/bash.rb +5 -0
- data/lib/rubyn_code/tools/edit_file.rb +62 -5
- data/lib/rubyn_code/tools/executor.rb +61 -6
- data/lib/rubyn_code/tools/glob.rb +6 -0
- data/lib/rubyn_code/tools/grep.rb +6 -0
- data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
- data/lib/rubyn_code/tools/output_compressor.rb +6 -1
- data/lib/rubyn_code/tools/read_file.rb +6 -0
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/write_file.rb +17 -0
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +22 -0
- data/skills/rubyn_self_test.md +13 -1
- metadata +31 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1a3ef81b040a1c9a75050f2545f229ba69aa88808ced47ad5cc8634ea52f115c
|
|
4
|
+
data.tar.gz: 9020382400cdf70d332858a9b85c89e5aa4a90e07965709e8520a803273d43b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
###
|
|
359
|
+
### Other Providers (Groq, Together, Ollama, etc.)
|
|
358
360
|
|
|
359
|
-
|
|
361
|
+
Add a provider and its API key in one command:
|
|
360
362
|
|
|
361
363
|
```bash
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
68
|
-
|
|
69
|
-
return unless loaded && index.nodes.any?
|
|
67
|
+
index = resolve_codebase_index
|
|
68
|
+
return unless index&.nodes&.any?
|
|
70
69
|
|
|
71
|
-
parts << "\n## #{index.
|
|
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 =
|
|
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
|