rubyn-code 0.2.2 → 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 +151 -5
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +84 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +157 -0
- data/lib/rubyn_code/agent/loop.rb +182 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
- data/lib/rubyn_code/agent/tool_processor.rb +178 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +80 -52
- data/lib/rubyn_code/autonomous/daemon.rb +146 -32
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
- data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +159 -114
- 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 +105 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- 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 +64 -11
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +48 -374
- data/lib/rubyn_code/cli/repl_commands.rb +177 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
- data/lib/rubyn_code/cli/repl_setup.rb +181 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +11 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +103 -1
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +182 -0
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +44 -8
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- 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 +311 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +75 -247
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +10 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +39 -32
- data/lib/rubyn_code/tools/bash.rb +7 -1
- data/lib/rubyn_code/tools/edit_file.rb +130 -17
- data/lib/rubyn_code/tools/executor.rb +130 -25
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +29 -7
- data/lib/rubyn_code/tools/grep.rb +8 -1
- 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/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +190 -0
- data/lib/rubyn_code/tools/read_file.rb +17 -6
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +76 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +62 -1
- data/skills/rubyn_self_test.md +133 -0
- metadata +83 -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
|
@@ -80,7 +80,7 @@ bundle exec ruby -Ilib exe/rubyn-code
|
|
|
80
80
|
|
|
81
81
|
</details>
|
|
82
82
|
|
|
83
|
-
**Authentication:** Rubyn Code reads your Claude Code OAuth token from the macOS Keychain automatically. Just make sure you've logged into Claude Code once (`claude` in your terminal). Also supports `ANTHROPIC_API_KEY` env var.
|
|
83
|
+
**Authentication:** Rubyn Code reads your Claude Code OAuth token from the macOS Keychain automatically. Just make sure you've logged into Claude Code once (`claude` in your terminal). Also supports `ANTHROPIC_API_KEY` env var. See [Authentication](#authentication) for OpenAI and other providers.
|
|
84
84
|
|
|
85
85
|
## Quick Start
|
|
86
86
|
|
|
@@ -142,7 +142,7 @@ Agent finished (23 tool calls).
|
|
|
142
142
|
This is a Rails 7.1 e-commerce app with...
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
-
##
|
|
145
|
+
## 29 Built-in Tools
|
|
146
146
|
|
|
147
147
|
| Category | Tools |
|
|
148
148
|
|----------|-------|
|
|
@@ -157,6 +157,7 @@ This is a Rails 7.1 e-commerce app with...
|
|
|
157
157
|
| **Context** | `compact`, `load_skill`, `task` |
|
|
158
158
|
| **Memory** | `memory_search`, `memory_write` |
|
|
159
159
|
| **Teams** | `send_message`, `read_inbox` |
|
|
160
|
+
| **Interactive** | `ask_user` (ask clarifying questions mid-task) |
|
|
160
161
|
|
|
161
162
|
## 112 Best Practice Skills
|
|
162
163
|
|
|
@@ -323,6 +324,7 @@ rubyn-code --help # Show help
|
|
|
323
324
|
|---------|---------|
|
|
324
325
|
| `/help` | Show help |
|
|
325
326
|
| `/quit` | Exit (saves session + extracts learnings) |
|
|
327
|
+
| `/new` | Save session and start a fresh conversation |
|
|
326
328
|
| `/review [base]` | PR review against best practices |
|
|
327
329
|
| `/spawn name role` | Spawn a persistent teammate |
|
|
328
330
|
| `/compact` | Compress conversation context |
|
|
@@ -331,9 +333,13 @@ rubyn-code --help # Show help
|
|
|
331
333
|
| `/budget [amt]` | Show or set session budget |
|
|
332
334
|
| `/skill [name]` | Load or list available skills |
|
|
333
335
|
| `/resume [id]` | Resume or list sessions |
|
|
336
|
+
| `/provider` | Add or list providers |
|
|
337
|
+
| `/model` | Show/switch model and provider |
|
|
334
338
|
|
|
335
339
|
## Authentication
|
|
336
340
|
|
|
341
|
+
### Anthropic (default)
|
|
342
|
+
|
|
337
343
|
| Priority | Source | Setup |
|
|
338
344
|
|----------|--------|-------|
|
|
339
345
|
| 1 | macOS Keychain | Log into Claude Code once: `claude` |
|
|
@@ -342,6 +348,60 @@ rubyn-code --help # Show help
|
|
|
342
348
|
|
|
343
349
|
Works with Claude Pro, Max, Team, and Enterprise. Default model: **Claude Opus 4.6**.
|
|
344
350
|
|
|
351
|
+
### OpenAI
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
export OPENAI_API_KEY=sk-...
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Available models: `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-4o`, `gpt-4o-mini`, `o3`, `o4-mini`
|
|
358
|
+
|
|
359
|
+
### Other Providers (Groq, Together, Ollama, etc.)
|
|
360
|
+
|
|
361
|
+
Add a provider and its API key in one command:
|
|
362
|
+
|
|
363
|
+
```bash
|
|
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
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Then switch with `/model groq:llama-3.3-70b`.
|
|
402
|
+
|
|
403
|
+
Local providers (Ollama, LM Studio) running on `localhost`/`127.0.0.1` don't require an API key.
|
|
404
|
+
|
|
345
405
|
## Architecture
|
|
346
406
|
|
|
347
407
|
16-layer agentic architecture:
|
|
@@ -362,25 +422,111 @@ Works with Claude Pro, Max, Team, and Enterprise. Default model: **Claude Opus 4
|
|
|
362
422
|
│ Layer 5: Skills (112 best practice docs, on-demand loading) │
|
|
363
423
|
│ Layer 4: Context Management (3-layer compression pipeline) │
|
|
364
424
|
│ Layer 3: Permissions (tiered access + deny lists + hooks) │
|
|
365
|
-
│ Layer 2: Tool System (
|
|
425
|
+
│ Layer 2: Tool System (29 tools, dispatch map registry) │
|
|
366
426
|
│ Layer 1: THE AGENT LOOP (while tool_use → execute → repeat) │
|
|
367
427
|
└──────────────────────────────────────────────────────────────┘
|
|
368
428
|
```
|
|
369
429
|
|
|
370
430
|
## Configuration
|
|
371
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
|
+
|
|
372
435
|
```yaml
|
|
373
436
|
# ~/.rubyn-code/config.yml (global)
|
|
374
|
-
|
|
437
|
+
provider: anthropic # default provider on startup
|
|
438
|
+
model: claude-opus-4-6 # default model on startup
|
|
375
439
|
permission_mode: allow_read
|
|
376
440
|
session_budget: 5.00
|
|
377
441
|
daily_budget: 10.00
|
|
378
442
|
|
|
379
443
|
# .rubyn-code/config.yml (project — overrides global)
|
|
380
|
-
|
|
444
|
+
provider: minimax # this project uses MiniMax by default
|
|
445
|
+
model: MiniMax-M2.7-highspeed
|
|
381
446
|
permission_mode: autonomous
|
|
382
447
|
```
|
|
383
448
|
|
|
449
|
+
### Multi-Provider Model Routing
|
|
450
|
+
|
|
451
|
+
Rubyn can automatically route tasks to different AI models based on complexity. Simple tasks (file search, git ops) use cheap, fast models. Complex tasks (architecture, security review) use the most capable model. Configure per-provider model tiers in `config.yml`:
|
|
452
|
+
|
|
453
|
+
```yaml
|
|
454
|
+
# ~/.rubyn-code/config.yml
|
|
455
|
+
provider: anthropic
|
|
456
|
+
model: claude-opus-4-6
|
|
457
|
+
|
|
458
|
+
providers:
|
|
459
|
+
anthropic:
|
|
460
|
+
env_key: ANTHROPIC_API_KEY
|
|
461
|
+
models:
|
|
462
|
+
cheap: claude-haiku-4-5 # file search, git ops, formatting
|
|
463
|
+
mid: claude-sonnet-4-6 # code gen, specs, refactors, reviews
|
|
464
|
+
top: claude-opus-4-6 # architecture, security, complex work
|
|
465
|
+
|
|
466
|
+
openai:
|
|
467
|
+
env_key: OPENAI_API_KEY
|
|
468
|
+
models:
|
|
469
|
+
cheap: gpt-5.4-nano # lightweight tasks
|
|
470
|
+
mid: gpt-5.4-mini # regular coding
|
|
471
|
+
top: gpt-5.4 # complex reasoning
|
|
472
|
+
|
|
473
|
+
groq:
|
|
474
|
+
base_url: https://api.groq.com/openai/v1
|
|
475
|
+
env_key: GROQ_API_KEY
|
|
476
|
+
models:
|
|
477
|
+
cheap: llama-3-8b
|
|
478
|
+
mid: llama-3-70b
|
|
479
|
+
pricing:
|
|
480
|
+
llama-3-8b: [0.05, 0.08] # [input_rate, output_rate] per million tokens
|
|
481
|
+
llama-3-70b: [0.59, 0.79]
|
|
482
|
+
|
|
483
|
+
ollama:
|
|
484
|
+
base_url: http://localhost:11434/v1
|
|
485
|
+
models:
|
|
486
|
+
cheap: llama3
|
|
487
|
+
mid: llama3
|
|
488
|
+
top: llama3
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
**How it works:** When you ask Rubyn to do something, the Model Router detects the task type and picks the right tier. If you've configured model tiers for a provider, those are used first. Otherwise it falls back to the built-in defaults (Anthropic for all tiers).
|
|
492
|
+
|
|
493
|
+
| Tier | Task types | Default model |
|
|
494
|
+
|------|-----------|---------------|
|
|
495
|
+
| **cheap** | File search, git ops, formatting, summaries | `claude-haiku-4-5` |
|
|
496
|
+
| **mid** | Code generation, specs, refactors, code review, bug fixes | `claude-sonnet-4-6` |
|
|
497
|
+
| **top** | Architecture, security review, complex refactors, planning | `claude-opus-4-6` |
|
|
498
|
+
|
|
499
|
+
You can also set custom pricing per model so `/cost` reports accurate spending for third-party providers.
|
|
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
|
+
|
|
384
530
|
## Development
|
|
385
531
|
|
|
386
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
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Agent
|
|
5
|
+
# Manages background job polling, waiting, and notification draining
|
|
6
|
+
# for the agent loop.
|
|
7
|
+
module BackgroundJobHandler
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def wait_for_background_jobs
|
|
11
|
+
max_wait = 300 # 5 minutes max
|
|
12
|
+
poll_interval = 3
|
|
13
|
+
|
|
14
|
+
RubynCode::Debug.agent(
|
|
15
|
+
'Waiting for background jobs to finish ' \
|
|
16
|
+
"(polling every #{poll_interval}s, max #{max_wait}s)"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
elapsed = poll_until_done(max_wait, poll_interval)
|
|
20
|
+
drain_background_notifications
|
|
21
|
+
RubynCode::Debug.agent("Background wait done (#{elapsed}s)")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def poll_until_done(max_wait, poll_interval)
|
|
25
|
+
elapsed = 0
|
|
26
|
+
while elapsed < max_wait && pending_background_jobs?
|
|
27
|
+
sleep poll_interval
|
|
28
|
+
elapsed += poll_interval
|
|
29
|
+
drain_background_notifications
|
|
30
|
+
end
|
|
31
|
+
elapsed
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def drain_background_notifications
|
|
35
|
+
return unless @background_manager
|
|
36
|
+
|
|
37
|
+
notifications = @background_manager.drain_notifications
|
|
38
|
+
return if notifications.nil? || notifications.empty?
|
|
39
|
+
|
|
40
|
+
summary = notifications.map { |n| format_background_notification(n) }.join("\n\n")
|
|
41
|
+
@conversation.add_user_message("[Background job results]\n#{summary}")
|
|
42
|
+
rescue NoMethodError
|
|
43
|
+
# background_manager does not support drain_notifications yet
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def pending_background_jobs?
|
|
47
|
+
return false unless @background_manager
|
|
48
|
+
|
|
49
|
+
@background_manager.active_count.positive?
|
|
50
|
+
rescue NoMethodError
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def format_background_notification(notification)
|
|
55
|
+
return notification.to_s unless notification.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
status = notification[:status] || 'unknown'
|
|
58
|
+
job_id = notification[:job_id]&.[](0..7) || 'unknown'
|
|
59
|
+
duration = format_duration(notification[:duration])
|
|
60
|
+
result = notification[:result] || '(no output)'
|
|
61
|
+
"Job #{job_id} [#{status}] (#{duration}):\n#{result}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_duration(dur)
|
|
65
|
+
return 'unknown' unless dur
|
|
66
|
+
|
|
67
|
+
format('%.1fs', dur)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -126,66 +126,89 @@ 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
|
-
|
|
133
|
-
|
|
132
|
+
orphaned = collect_tool_use_ids(formatted) - collect_tool_result_ids(formatted)
|
|
133
|
+
return formatted if orphaned.empty?
|
|
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|
|
|
146
|
+
{ type: 'tool_result', tool_use_id: id, content: '[interrupted]', is_error: true }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
formatted.insert(insert_idx, { role: 'user', content: results })
|
|
150
|
+
formatted
|
|
151
|
+
end
|
|
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|
|
|
134
157
|
next unless msg[:role] == 'assistant' && msg[:content].is_a?(Array)
|
|
158
|
+
return idx + 1 if assistant_has_orphan?(msg, orphan_set)
|
|
159
|
+
end
|
|
135
160
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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'])
|
|
141
168
|
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def collect_tool_use_ids(formatted)
|
|
172
|
+
collect_block_ids(formatted, role: 'assistant', type: 'tool_use', id_key: :id, id_str_key: 'id')
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def collect_tool_result_ids(formatted)
|
|
176
|
+
collect_block_ids(formatted, role: 'user', type: 'tool_result', id_key: :tool_use_id,
|
|
177
|
+
id_str_key: 'tool_use_id')
|
|
178
|
+
end
|
|
142
179
|
|
|
143
|
-
|
|
144
|
-
|
|
180
|
+
def collect_block_ids(formatted, role:, type:, id_key:, id_str_key:) # rubocop:disable Metrics/CyclomaticComplexity -- iterates blocks with type+role guards
|
|
181
|
+
ids = Set.new
|
|
145
182
|
formatted.each do |msg|
|
|
146
|
-
next unless msg[:role] ==
|
|
183
|
+
next unless msg[:role] == role && msg[:content].is_a?(Array)
|
|
147
184
|
|
|
148
185
|
msg[:content].each do |block|
|
|
149
|
-
|
|
150
|
-
tool_result_ids << (block[:tool_use_id] || block['tool_use_id'])
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
# Find orphans
|
|
156
|
-
orphaned = tool_use_ids - tool_result_ids
|
|
157
|
-
return formatted if orphaned.empty?
|
|
186
|
+
next unless block.is_a?(Hash) && block_matches_type?(block, type)
|
|
158
187
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
{ type: 'tool_result', tool_use_id: id, content: '[interrupted]', is_error: true }
|
|
188
|
+
ids << (block[id_key] || block[id_str_key])
|
|
189
|
+
end
|
|
162
190
|
end
|
|
191
|
+
ids
|
|
192
|
+
end
|
|
163
193
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
formatted
|
|
194
|
+
def block_matches_type?(block, type)
|
|
195
|
+
block[:type] == type || block['type'] == type
|
|
167
196
|
end
|
|
168
197
|
|
|
169
198
|
# Normalize content and tool_calls into a single array of content blocks.
|
|
170
199
|
def normalize_content(content, tool_calls)
|
|
171
|
-
blocks =
|
|
200
|
+
blocks = content_to_blocks(content)
|
|
201
|
+
tool_calls.each { |tc| blocks << block_to_hash(tc) }
|
|
202
|
+
blocks
|
|
203
|
+
end
|
|
172
204
|
|
|
205
|
+
def content_to_blocks(content)
|
|
173
206
|
case content
|
|
174
|
-
when Array
|
|
175
|
-
|
|
176
|
-
when
|
|
177
|
-
|
|
178
|
-
when Hash
|
|
179
|
-
blocks << content
|
|
180
|
-
else
|
|
181
|
-
blocks << block_to_hash(content) if content.respond_to?(:type)
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
tool_calls.each do |tc|
|
|
185
|
-
blocks << block_to_hash(tc)
|
|
207
|
+
when Array then content.map { |b| block_to_hash(b) }
|
|
208
|
+
when String then content.empty? ? [] : [{ type: 'text', text: content }]
|
|
209
|
+
when Hash then [content]
|
|
210
|
+
else content.respond_to?(:type) ? [block_to_hash(content)] : []
|
|
186
211
|
end
|
|
187
|
-
|
|
188
|
-
blocks
|
|
189
212
|
end
|
|
190
213
|
|
|
191
214
|
# Format message content for the API. Converts Data objects to hashes.
|
|
@@ -200,25 +223,30 @@ module RubynCode
|
|
|
200
223
|
|
|
201
224
|
def block_to_hash(block)
|
|
202
225
|
return block if block.is_a?(Hash)
|
|
226
|
+
return block unless block.respond_to?(:type)
|
|
203
227
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
block.respond_to?(:to_h) ? block.to_h : block
|
|
216
|
-
end
|
|
228
|
+
typed_block_to_hash(block)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def typed_block_to_hash(block)
|
|
232
|
+
case block.type.to_s
|
|
233
|
+
when 'text'
|
|
234
|
+
{ type: 'text', text: block.text }
|
|
235
|
+
when 'tool_use'
|
|
236
|
+
{ type: 'tool_use', id: block.id, name: block.name, input: block.input }
|
|
237
|
+
when 'tool_result'
|
|
238
|
+
tool_result_block_to_hash(block)
|
|
217
239
|
else
|
|
218
|
-
block
|
|
240
|
+
block.respond_to?(:to_h) ? block.to_h : block
|
|
219
241
|
end
|
|
220
242
|
end
|
|
221
243
|
|
|
244
|
+
def tool_result_block_to_hash(block)
|
|
245
|
+
h = { type: 'tool_result', tool_use_id: block.tool_use_id, content: block.content.to_s }
|
|
246
|
+
h[:is_error] = true if block.respond_to?(:is_error) && block.is_error
|
|
247
|
+
h
|
|
248
|
+
end
|
|
249
|
+
|
|
222
250
|
# Extract text from content blocks.
|
|
223
251
|
def extract_text(content)
|
|
224
252
|
case content
|