rubyn-code 0.5.1 → 0.8.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +137 -4
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +100 -4
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +1 -1
  6. data/lib/rubyn_code/agent/llm_caller.rb +7 -3
  7. data/lib/rubyn_code/agent/loop.rb +106 -12
  8. data/lib/rubyn_code/agent/response_parser.rb +8 -0
  9. data/lib/rubyn_code/agent/system_prompt_builder.rb +40 -2
  10. data/lib/rubyn_code/agent/tool_processor.rb +13 -1
  11. data/lib/rubyn_code/auth/oauth.rb +1 -1
  12. data/lib/rubyn_code/auth/token_store.rb +49 -4
  13. data/lib/rubyn_code/autonomous/daemon.rb +1 -1
  14. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  15. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  16. data/lib/rubyn_code/chisel/debt.rb +65 -0
  17. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  18. data/lib/rubyn_code/chisel.rb +127 -0
  19. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  20. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  21. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  23. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  24. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  25. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  26. data/lib/rubyn_code/cli/commands/context.rb +30 -1
  27. data/lib/rubyn_code/cli/commands/custom_command.rb +84 -0
  28. data/lib/rubyn_code/cli/commands/custom_loader.rb +100 -0
  29. data/lib/rubyn_code/cli/commands/effort.rb +47 -0
  30. data/lib/rubyn_code/cli/commands/export.rb +174 -0
  31. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  32. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  33. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  34. data/lib/rubyn_code/cli/commands/mcp.rb +49 -12
  35. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  36. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  37. data/lib/rubyn_code/cli/commands/resume.rb +97 -26
  38. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  39. data/lib/rubyn_code/cli/commands/think.rb +47 -0
  40. data/lib/rubyn_code/cli/first_run.rb +1 -1
  41. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  42. data/lib/rubyn_code/cli/mention_expander.rb +111 -0
  43. data/lib/rubyn_code/cli/renderer.rb +3 -2
  44. data/lib/rubyn_code/cli/repl.rb +68 -15
  45. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  46. data/lib/rubyn_code/cli/repl_setup.rb +17 -7
  47. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  48. data/lib/rubyn_code/cli/version_check.rb +10 -3
  49. data/lib/rubyn_code/config/defaults.rb +14 -1
  50. data/lib/rubyn_code/config/schema.json +9 -0
  51. data/lib/rubyn_code/config/settings.rb +19 -2
  52. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  53. data/lib/rubyn_code/context/manager.rb +29 -12
  54. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  55. data/lib/rubyn_code/debug.rb +11 -5
  56. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  57. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  58. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  59. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  60. data/lib/rubyn_code/hooks/response.rb +83 -0
  61. data/lib/rubyn_code/hooks/runner.rb +61 -3
  62. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  63. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  64. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  65. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  66. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  67. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  69. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  70. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  71. data/lib/rubyn_code/index/codebase_index.rb +103 -4
  72. data/lib/rubyn_code/index/prism_extractor.rb +82 -0
  73. data/lib/rubyn_code/learning/injector.rb +1 -2
  74. data/lib/rubyn_code/learning/porter.rb +129 -0
  75. data/lib/rubyn_code/llm/adapters/anthropic.rb +168 -29
  76. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +13 -0
  77. data/lib/rubyn_code/llm/adapters/base.rb +2 -1
  78. data/lib/rubyn_code/llm/adapters/openai.rb +2 -2
  79. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +21 -0
  80. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  81. data/lib/rubyn_code/llm/client.rb +16 -3
  82. data/lib/rubyn_code/llm/image_reader.rb +60 -0
  83. data/lib/rubyn_code/llm/message_builder.rb +21 -1
  84. data/lib/rubyn_code/llm/model_router.rb +4 -4
  85. data/lib/rubyn_code/mcp/client.rb +59 -0
  86. data/lib/rubyn_code/mcp/discovery.rb +93 -0
  87. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  88. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  90. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  91. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  92. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  93. data/lib/rubyn_code/memory/search.rb +9 -5
  94. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  95. data/lib/rubyn_code/observability/cost_calculator.rb +8 -3
  96. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  97. data/lib/rubyn_code/protocols/RUBYN.md +0 -3
  98. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  99. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  100. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  101. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  102. data/lib/rubyn_code/tasks/models.rb +0 -16
  103. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  104. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  105. data/lib/rubyn_code/teams/manager.rb +83 -5
  106. data/lib/rubyn_code/teams/teammate.rb +3 -14
  107. data/lib/rubyn_code/tools/RUBYN.md +2 -2
  108. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  109. data/lib/rubyn_code/tools/bash.rb +3 -3
  110. data/lib/rubyn_code/tools/code_graph.rb +134 -0
  111. data/lib/rubyn_code/tools/executor.rb +9 -4
  112. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  113. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  114. data/lib/rubyn_code/tools/todo_store.rb +55 -0
  115. data/lib/rubyn_code/tools/todo_write.rb +88 -0
  116. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  117. data/lib/rubyn_code/tools/web_search.rb +4 -1
  118. data/lib/rubyn_code/version.rb +1 -1
  119. data/lib/rubyn_code.rb +57 -9
  120. data/skills/rubyn_self_test.md +462 -14
  121. data/skills/self_test/chisel_smoke.rb +84 -0
  122. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  123. metadata +45 -6
  124. data/lib/rubyn_code/context/context_budget.rb +0 -183
  125. data/lib/rubyn_code/context/schema_filter.rb +0 -64
  126. data/lib/rubyn_code/learning/shortcut.rb +0 -95
  127. data/lib/rubyn_code/llm/streaming.rb +0 -10
  128. data/lib/rubyn_code/protocols/plan_approval.rb +0 -72
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b43dc2a8fab138bcfe303180881aa9fa23878faaa532367f07a0fee7605feb34
4
- data.tar.gz: 12dffaa487a75dfe276be9d20ff8152814bd60c6b107efd9ed10d2993afd57a5
3
+ metadata.gz: 7696c779b900fe4dca806aa73d7fcca79309ebe58a242d16e2d5309b43d44ec4
4
+ data.tar.gz: 2d8d4c8905255ce11e98569f86f4c2d788c620ea30c513fd6771b3c58bb98073
5
5
  SHA512:
6
- metadata.gz: fcbe574a607028345e38587388a6c0bd9cdb8ab134e690d45583a1a27b4c698383bd43088eb8fa537cddba8690e77d15ab155be92fb997117c8fe62a978df211
7
- data.tar.gz: 43e72475ec24edf03f21e4aafa4402de31351728a38de42e55b69db104aea1828b930a1cafb79d3065d07e16f84c8a5a5023311da5d1e40f7a667daa90ddee3c
6
+ metadata.gz: c5d2b5f15b21d7e02b2f6c2252248eb6e933337b3f4c491135f5453c1ddf4ce47c48605aaeb6e26180a38324c8effc7a149e528c794ca9391ec8c43febbc2bdb
7
+ data.tar.gz: 15c9aa06c02dd8c105116b403cba0f36cb5a2b91c8690b66b6320a289011cf6f2986a308fdaacad933fbd221646de84b7f774a47709a7c8409fdc5bddca5b671
data/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  <a href="https://github.com/MatthewSuttles/rubyn-code/actions/workflows/ci.yml"><img src="https://github.com/MatthewSuttles/rubyn-code/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
16
16
  </p>
17
17
 
18
- Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code for anti-patterns, and build entire features — all context-aware with your schema, routes, and specs. Powered by Claude Opus 4.6, running on your existing Claude subscription.
18
+ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code for anti-patterns, and build entire features — all context-aware with your schema, routes, and specs. Powered by Claude Opus 4.8, running on your existing Claude subscription.
19
19
 
20
20
  <img width="1230" height="280" alt="image" src="https://github.com/user-attachments/assets/14e07ce8-def0-4a8f-ac89-46661361a4eb" />
21
21
 
@@ -69,6 +69,22 @@ Refactor controllers, generate idiomatic RSpec, catch N+1 queries, review code f
69
69
  - **IDE-ready** — works in the terminal and inside VS Code with full bidirectional communication
70
70
  - **Extensible** — connect external tool servers via MCP, add custom skills, or wire up your own providers
71
71
 
72
+ ## Claude Code Feature Parity
73
+
74
+ Phase 4 ships nine parity features with Claude Code:
75
+
76
+ - **Extended thinking** — `/think <budget>` toggles per-session reasoning; the Anthropic adapter emits `thinking: {type: 'adaptive'}` on Claude 4.6+ models (`{type: 'enabled', budget_tokens}` on older ones).
77
+ - **Reasoning effort** — `/effort <low|medium|high|xhigh|max>` sets the request-level reasoning depth; the Anthropic adapter emits `output_config: {effort}` on the wire. GA on Claude 4.6+ (Opus 4.6/4.7/4.8, Sonnet 4.6/5, Fable 5); `xhigh` needs Opus 4.7+ / Sonnet 5 / Fable 5. Model default is `high`.
78
+ - **Task budgets (beta)** — the agent loop's remaining token budget is sent as `output_config: {task_budget: {type: 'tokens', total}}` on supported models (Fable 5, Sonnet 5, Opus 4.7/4.8), advisory pacing only — `max_tokens` stays the enforced per-response cap.
79
+ - **Image / vision input** — `@chart.png` (and `.jpg` / `.jpeg` / `.gif` / `.webp`) becomes a real image content block attached to the user turn; Anthropic and OpenAI each emit their native shape.
80
+ - **TodoWrite live checklist** — `TodoWrite` tool; the checklist refreshes above the spinner on every tool result so you see in-turn progress at a glance.
81
+ - **Custom-command frontmatter** — `argument-hint`, `allowed-tools`, and `model:` keys in `~/.rubyn-code/commands/*.md`; the loop honors the per-prompt tool restriction and model override.
82
+ - **`.mcp.json` auto-discovery** — project-root MCP servers flow into the REPL automatically; `/mcp` shows entries prefixed `[project]` or `[user]`. OAuth for URL transports is still deferred.
83
+ - **`/export` transcript** — `/export <path>` writes the conversation as Markdown (default) or `--jsonl` for tooling.
84
+ - **Fable 5 refusal handling** — a declined request (`stop_reason: "refusal"`) surfaces a clear message instead of an empty turn; `claude-fable-5` / `claude-mythos-5` requests opt into a server-side Opus 4.8 fallback by default, so most refusals are transparently re-served in the same call.
85
+
86
+ See `docs/04-feature-parity/` for per-feature design + tasks docs.
87
+
72
88
  ## Install
73
89
 
74
90
  Requires **Ruby 4.0.2+**. Install with your latest Ruby, then pin it so it works in every project:
@@ -138,6 +154,8 @@ rubyn-code -p "Refactor app/controllers/orders_controller.rb into service object
138
154
  rubyn-code --ide
139
155
  ```
140
156
 
157
+ **Tip:** Reference files inline with `@` — `rubyn > explain @lib/foo.rb and @config/routes.rb`. Rubyn inlines their contents so you don't have to wait for it to read them.
158
+
141
159
  ## What Can Rubyn Do?
142
160
 
143
161
  ### Refactor code
@@ -227,7 +245,7 @@ The extension communicates over 19 RPC methods: `initialize`, `prompt`, `cancel`
227
245
 
228
246
  ## MCP — External Tool Servers
229
247
 
230
- 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.
248
+ 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. Servers that expose **resources** and **prompts** are also bridged — Rubyn registers per-server `read_resource` and `get_prompt` tools, and `/mcp` reports the counts.
231
249
 
232
250
  ### Configuration
233
251
 
@@ -349,7 +367,7 @@ Rubyn automatically loads relevant context based on what you're working on:
349
367
  - **Controllers** → includes models, routes, request specs, services
350
368
  - **Models** → includes schema, associations, specs, factories
351
369
  - **Service objects** → includes referenced models and their specs
352
- - **Any file** → checks for `RUBYN.md`, `CLAUDE.md`, or `AGENT.md` instructions
370
+ - **Any file** → checks for `RUBYN.md`, `CLAUDE.md`, `AGENTS.md`, or `AGENT.md` instructions
353
371
 
354
372
  The [codebase index](#codebase-indexing) enhances this with structural awareness — Rubyn knows which files depend on each other before it reads them.
355
373
 
@@ -367,7 +385,7 @@ Drop a `RUBYN.md` in your project root and Rubyn follows your conventions:
367
385
  - Run rubocop before committing
368
386
  ```
369
387
 
370
- Also reads `CLAUDE.md` and `AGENT.md` — no migration needed from other tools.
388
+ Also reads `CLAUDE.md`, `AGENTS.md`, and `AGENT.md` — no migration needed from other tools.
371
389
 
372
390
  | Location | Scope |
373
391
  |----------|-------|
@@ -390,6 +408,65 @@ Focus areas: `all`, `security`, `performance`, `style`, `testing`
390
408
 
391
409
  Severity ratings: **[critical]** **[warning]** **[suggestion]** **[nitpick]**
392
410
 
411
+ ## Chisel — Write the Minimum That Works
412
+
413
+ Chisel is an opt-in mode that makes Rubyn think like the laziest senior dev in
414
+ the room: the best code is the code you never wrote. It's **off by default** and
415
+ only changes the agent's behavior once you turn it on.
416
+
417
+ ```
418
+ rubyn > /chisel # show current intensity
419
+ rubyn > /chisel full # turn it on
420
+ rubyn > /chisel off # back to normal
421
+ ```
422
+
423
+ Intensities: `off` (default) · `lite` · `full` · `ultra`. When on, Rubyn walks a
424
+ decision ladder before writing code — does this need to exist? does stdlib or an
425
+ installed gem already do it? is it one line? — and only then writes the smallest
426
+ change that solves the task. The safety floor (validation, error/data-loss
427
+ handling, security, accessibility) is never on the chopping block.
428
+
429
+ Set it permanently with `chisel_mode: full` in `~/.rubyn-code/config.yml`, or
430
+ per-shell with `RUBYN_CHISEL_MODE=full`.
431
+
432
+ **On-demand audits** (work whether or not the always-on mode is enabled):
433
+
434
+ ```
435
+ rubyn > /chisel-review # over-engineering in your diff vs main
436
+ rubyn > /chisel-review develop # ...vs a different base
437
+ rubyn > /chisel-audit # sweep the whole repo
438
+ rubyn > /chisel-audit app/services # ...scoped to a path
439
+ ```
440
+
441
+ Both return a ranked deletion/simplification list — each item with a location, the
442
+ ladder rung it skipped, and the concrete simpler form — and stay read-only (they
443
+ report, they don't edit).
444
+
445
+ **Debt ledger & status.** Leave a `# chisel: …` comment when you consciously defer a
446
+ simplification, then collect them later:
447
+
448
+ ```
449
+ rubyn > /chisel-debt # list every `# chisel:` deferral, with file:line and note
450
+ rubyn > /chisel-gain # current mode, outstanding debt count, and reference impact
451
+ ```
452
+
453
+ **Verifying Chisel.** A standalone smoke test runs the whole layer against a
454
+ committed, deliberately over-engineered fixture and gives the same result every
455
+ time — no LLM, no network:
456
+
457
+ ```
458
+ $ bundle exec ruby skills/self_test/chisel_smoke.rb
459
+ CHISEL debt: PASS # scans the fixture → its exact planted `# chisel:` markers
460
+ CHISEL engine: PASS # off injects nothing; lite/full/ultra keep the safety floor
461
+ CHISEL inspection: PASS # review/audit prompts assemble; bad scope raises
462
+ CHISEL commands: PASS # all five slash commands register
463
+ CHISEL: PASS
464
+ ```
465
+
466
+ The fixture lives at `skills/self_test/fixtures/chisel_sample.rb`, the same check is
467
+ Section 18 of the `/skill self-test` scorecard, and `spec/rubyn_code/chisel/self_test_fixture_spec.rb`
468
+ guards the fixture's exact scan output so it can't silently drift.
469
+
393
470
  ## Megaplan — Phased Planning
394
471
 
395
472
  For work too big for a single PR — rewrites, migrations, multi-feature initiatives — Rubyn ships a planning workflow that breaks the feature into vertical-slice phases before any code gets written.
@@ -476,6 +553,19 @@ Rubyn gets smarter with every session:
476
553
  3. **On next startup** — injects top instincts into the system prompt
477
554
  4. **Over time** — reinforced instincts strengthen, unused ones decay and get pruned
478
555
 
556
+ ### Take your learnings with you
557
+
558
+ Instincts live in `~/.rubyn-code`. Move them to another machine with `/learning`:
559
+
560
+ ```
561
+ rubyn > /learning # how many instincts you've accumulated
562
+ rubyn > /learning export learnings.json # write them to a portable file
563
+ # ...on the new machine...
564
+ rubyn > /learning import learnings.json --here # load them (--here = apply to this project)
565
+ ```
566
+
567
+ Import regenerates ids and skips duplicates, so it's safe to run repeatedly.
568
+
479
569
  ## Streaming Output
480
570
 
481
571
  Real-time streaming with live syntax highlighting via Rouge/Monokai. Code blocks are buffered and highlighted when complete. No waiting for full responses.
@@ -537,6 +627,8 @@ rubyn-code daemon [OPTIONS] # Run GOLEM autonomous daemon
537
627
  | `/new` | Save session and start a fresh conversation |
538
628
  | `/review [base]` | PR review against best practices |
539
629
  | `/spawn name role` | Spawn a persistent teammate |
630
+ | `/goal <condition>` | Set a goal Rubyn works toward until met (`/goal clear` to cancel) |
631
+ | `/loop [xN] [interval] <prompt-or-/cmd>` | Repeat a prompt or command on an interval (Ctrl-C to stop) |
540
632
  | `/compact` | Compress conversation context |
541
633
  | `/cost` | Show token usage and costs |
542
634
  | `/tasks` | List all tasks |
@@ -547,6 +639,47 @@ rubyn-code daemon [OPTIONS] # Run GOLEM autonomous daemon
547
639
  | `/model` | Show/switch model and provider |
548
640
  | `/doctor` | Run environment health checks |
549
641
  | `/mcp` | MCP server documentation and status |
642
+ | `/agents` | List sub-agent types (built-in + custom) |
643
+ | `/rewind [id] [code\|chat]` | Rewind code and/or conversation to a checkpoint |
644
+
645
+ ### Custom Sub-Agents
646
+
647
+ Beyond the built-in `explore` (read-only) and `worker` (read/write) sub-agents, define your own in `.rubyn-code/agents/<name>.md` (project) or `~/.rubyn-code/agents/<name>.md` (global). `spawn_agent` can then target them by name, and `/agents` lists them.
648
+
649
+ ```markdown
650
+ ---
651
+ description: Reviews a diff for bugs
652
+ tools: read_file, grep, glob, bash # optional — omit for the access default
653
+ access: read # read | write (default: write)
654
+ ---
655
+ You are a meticulous code reviewer. Find correctness bugs only.
656
+ ```
657
+
658
+ ### Custom Slash Commands
659
+
660
+ Drop a markdown file in `.rubyn-code/commands/` (project) or `~/.rubyn-code/commands/` (global) and it becomes a slash command — `deploy.md` → `/deploy`. Project commands override global ones; built-ins always win.
661
+
662
+ ```markdown
663
+ ---
664
+ description: Open a PR for the current branch
665
+ ---
666
+ Open a pull request for the current branch.
667
+ Title: $ARGUMENTS
668
+ Current diff:
669
+ !`git diff main --stat`
670
+ ```
671
+
672
+ Templating in the body:
673
+
674
+ | Token | Expands to |
675
+ |-------|-----------|
676
+ | `$ARGUMENTS` | everything typed after the command |
677
+ | `$1` … `$9` | individual positional arguments |
678
+ | `` !`shell cmd` `` | the command's output, inlined |
679
+
680
+ ### Checkpoints & Rewind
681
+
682
+ Rubyn snapshots a checkpoint at the start of every turn — capturing the conversation and the original contents of any files it changes that turn. `/rewind` lists them; `/rewind <id>` rolls back both code and conversation (or just one with `code`/`chat`). Note: rewind restores files edited via Rubyn's `write_file`/`edit_file`; it does not touch your git history.
550
683
 
551
684
  ## Authentication
552
685
 
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Multi-agent upgrade: adds parent-child tracking to teammates and
4
+ # structured messaging to mailbox_messages.
5
+ #
6
+ # Teammates: adds parent_agent_id column, fixes status CHECK
7
+ # (DB had 'busy' but Ruby code uses 'active').
8
+ # Mailbox: adds correlation_id and data columns for structured messaging.
9
+ #
10
+ # Uses table-rebuild pattern because SQLite cannot ALTER CHECK constraints
11
+ # or ADD COLUMN with constraints reliably.
12
+ module Migration014MultiAgentUpgrade
13
+ module_function
14
+
15
+ def up(db)
16
+ upgrade_teammates(db)
17
+ upgrade_mailbox(db)
18
+ end
19
+
20
+ def upgrade_teammates(db)
21
+ db.execute(<<~SQL)
22
+ CREATE TABLE teammates_new (
23
+ id TEXT PRIMARY KEY,
24
+ name TEXT NOT NULL UNIQUE,
25
+ role TEXT NOT NULL,
26
+ persona TEXT,
27
+ model TEXT NOT NULL DEFAULT 'claude-sonnet-4-20250514',
28
+ status TEXT NOT NULL DEFAULT 'idle' CHECK(status IN ('idle','active','offline')),
29
+ parent_agent_id TEXT,
30
+ metadata TEXT DEFAULT '{}',
31
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
32
+ )
33
+ SQL
34
+
35
+ db.execute(<<~SQL)
36
+ INSERT INTO teammates_new (id, name, role, persona, model, status, metadata, created_at)
37
+ SELECT id, name, role, persona, model,
38
+ CASE WHEN status = 'busy' THEN 'active' ELSE status END,
39
+ metadata, created_at
40
+ FROM teammates
41
+ SQL
42
+
43
+ db.execute('DROP TABLE teammates')
44
+ db.execute('ALTER TABLE teammates_new RENAME TO teammates')
45
+ db.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_teammates_name ON teammates(name)')
46
+ db.execute('CREATE INDEX IF NOT EXISTS idx_teammates_status ON teammates(status)')
47
+ db.execute('CREATE INDEX IF NOT EXISTS idx_teammates_parent ON teammates(parent_agent_id)')
48
+ end
49
+
50
+ def upgrade_mailbox(db)
51
+ db.execute(<<~SQL)
52
+ CREATE TABLE mailbox_messages_new (
53
+ id TEXT PRIMARY KEY,
54
+ sender TEXT NOT NULL,
55
+ recipient TEXT NOT NULL,
56
+ message_type TEXT NOT NULL DEFAULT 'message'
57
+ CHECK(message_type IN ('message','task','result','error','broadcast','shutdown_request','shutdown_response','status_change')),
58
+ payload TEXT NOT NULL,
59
+ correlation_id TEXT,
60
+ data TEXT,
61
+ read INTEGER NOT NULL DEFAULT 0,
62
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
63
+ )
64
+ SQL
65
+
66
+ db.execute(<<~SQL)
67
+ INSERT INTO mailbox_messages_new (id, sender, recipient, message_type, payload, read, created_at)
68
+ SELECT id, sender, recipient, message_type, payload, read, created_at
69
+ FROM mailbox_messages
70
+ SQL
71
+
72
+ db.execute('DROP TABLE mailbox_messages')
73
+ db.execute('ALTER TABLE mailbox_messages_new RENAME TO mailbox_messages')
74
+ db.execute('CREATE INDEX IF NOT EXISTS idx_mailbox_recipient_read ON mailbox_messages(recipient, read)')
75
+ db.execute('CREATE INDEX IF NOT EXISTS idx_mailbox_sender ON mailbox_messages(sender)')
76
+ db.execute('CREATE INDEX IF NOT EXISTS idx_mailbox_created ON mailbox_messages(created_at)')
77
+ db.execute('CREATE INDEX IF NOT EXISTS idx_mailbox_correlation ON mailbox_messages(correlation_id)')
78
+ end
79
+ end
@@ -1,21 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module RubynCode
4
6
  module Agent
5
- class Conversation
7
+ class Conversation # rubocop:disable Metrics/ClassLength -- message log + incremental token/tool-ID bookkeeping
6
8
  attr_reader :messages
7
9
 
8
10
  def initialize
9
11
  @messages = []
12
+ reset_derived_state!
10
13
  end
11
14
 
12
15
  # Append a user turn to the conversation.
13
16
  #
14
- # @param content [String]
17
+ # @param content [String, Array<Hash>] either a plain string or an
18
+ # array of content blocks (text, image, etc.). Strings get wrapped
19
+ # as `[{ type: 'text', text: content }]` only if there's a planned
20
+ # Array; we keep raw String for backward compatibility with the API.
15
21
  # @return [Hash] the appended message
16
22
  def add_user_message(content)
17
23
  message = { role: 'user', content: content }
18
24
  @messages << message
25
+ track_added_message(message)
19
26
  message
20
27
  end
21
28
 
@@ -28,6 +35,7 @@ module RubynCode
28
35
  blocks = normalize_content(content, tool_calls)
29
36
  message = { role: 'assistant', content: blocks }
30
37
  @messages << message
38
+ track_added_message(message)
31
39
  message
32
40
  end
33
41
 
@@ -52,8 +60,11 @@ module RubynCode
52
60
  # tool results for the same assistant turn are batched together.
53
61
  if @messages.last && @messages.last[:role] == 'user' && tool_result_message?(@messages.last)
54
62
  @messages.last[:content] << result_block
63
+ track_appended_block(result_block)
55
64
  else
56
- @messages << { role: 'user', content: [result_block] }
65
+ message = { role: 'user', content: [result_block] }
66
+ @messages << message
67
+ track_added_message(message)
57
68
  end
58
69
 
59
70
  result_block
@@ -79,6 +90,29 @@ module RubynCode
79
90
  # @return [void]
80
91
  def clear!
81
92
  @messages.clear
93
+ reset_derived_state!
94
+ end
95
+
96
+ # Character length of the JSON-serialized messages array, maintained
97
+ # incrementally on append so per-turn token estimation doesn't have to
98
+ # re-serialize the whole history. Matches JSON.generate(messages).length.
99
+ #
100
+ # @return [Integer]
101
+ def estimated_json_chars
102
+ @json_chars ||= @messages.sum { |msg| JSON.generate(msg).length }
103
+ return 2 if @messages.empty?
104
+
105
+ # "[" + per-message JSON joined by "," + "]"
106
+ @json_chars + @messages.length + 1
107
+ end
108
+
109
+ # Drops cached serialization/tool-ID bookkeeping. Must be called after
110
+ # messages are mutated in place from outside this class (e.g. by
111
+ # Context::MicroCompact); the caches rebuild lazily on next access.
112
+ #
113
+ # @return [void]
114
+ def refresh_derived_state!
115
+ reset_derived_state!
82
116
  end
83
117
 
84
118
  # Return the messages array formatted for the Claude API.
@@ -115,21 +149,83 @@ module RubynCode
115
149
  @messages.pop
116
150
  removed += 1
117
151
  end
152
+ reset_derived_state!
118
153
  end
119
154
 
120
155
  # Replace messages with a new array (used after compaction).
121
156
  def replace!(new_messages)
122
157
  @messages.replace(new_messages)
158
+ reset_derived_state!
159
+ end
160
+
161
+ # Alias for `messages`. Common shorthand for callers that want to
162
+ # treat the conversation as an Array of message hashes.
163
+ def to_a
164
+ @messages
123
165
  end
166
+ alias to_ary to_a
124
167
 
125
168
  private
126
169
 
170
+ # Derived bookkeeping kept in sync with @messages so hot paths stay
171
+ # cheap: per-message JSON char total (token estimation) and
172
+ # tool_use/tool_result ID sets (orphan repair). nil means "rebuild
173
+ # lazily from @messages on next access".
174
+ def reset_derived_state!
175
+ @json_chars = nil
176
+ @tool_use_ids = nil
177
+ @tool_result_ids = nil
178
+ end
179
+
180
+ def track_added_message(message)
181
+ register_tool_ids(message)
182
+ return if @json_chars.nil?
183
+
184
+ @json_chars += JSON.generate(message).length
185
+ rescue StandardError
186
+ @json_chars = nil
187
+ end
188
+
189
+ # A tool_result block appended to an existing user message grows that
190
+ # message's JSON by the block plus one separator comma.
191
+ def track_appended_block(result_block)
192
+ @tool_result_ids << result_block[:tool_use_id] if @tool_result_ids
193
+ return if @json_chars.nil?
194
+
195
+ @json_chars += JSON.generate(result_block).length + 1
196
+ rescue StandardError
197
+ @json_chars = nil
198
+ end
199
+
200
+ def register_tool_ids(message)
201
+ return unless @tool_use_ids && message[:content].is_a?(Array)
202
+
203
+ message[:content].each do |block|
204
+ next unless block.is_a?(Hash)
205
+
206
+ if message[:role] == 'assistant' && block_matches_type?(block, 'tool_use')
207
+ @tool_use_ids << (block[:id] || block['id'])
208
+ elsif message[:role] == 'user' && block_matches_type?(block, 'tool_result')
209
+ @tool_result_ids << (block[:tool_use_id] || block['tool_use_id'])
210
+ end
211
+ end
212
+ end
213
+
214
+ def rebuild_tool_id_sets!
215
+ @tool_use_ids = collect_tool_use_ids(@messages)
216
+ @tool_result_ids = collect_tool_result_ids(@messages)
217
+ end
218
+
127
219
  # Ensure every tool_use block has a matching tool_result.
128
220
  # If a tool_use is orphaned (e.g. from Ctrl-C interruption),
129
221
  # inject a synthetic tool_result immediately after the assistant
130
222
  # message that contains the orphaned tool_use.
223
+ #
224
+ # The ID sets are tracked incrementally as messages are appended, so
225
+ # the common no-orphan case skips the full-history scans.
131
226
  def repair_orphaned_tool_uses(formatted)
132
- orphaned = collect_tool_use_ids(formatted) - collect_tool_result_ids(formatted)
227
+ rebuild_tool_id_sets! if @tool_use_ids.nil?
228
+ orphaned = @tool_use_ids - @tool_result_ids
133
229
  return formatted if orphaned.empty?
134
230
 
135
231
  insert_orphan_results(formatted, orphaned)
@@ -8,7 +8,7 @@ module RubynCode
8
8
  # prompt overhead by 30-50%.
9
9
  module DynamicToolSchema
10
10
  BASE_TOOLS = %w[
11
- read_file write_file edit_file glob grep bash
11
+ read_file write_file edit_file glob grep code_graph bash
12
12
  ].freeze
13
13
 
14
14
  TASK_TOOLS = {
@@ -25,7 +25,7 @@ module RubynCode
25
25
  def build_llm_opts
26
26
  opts = {
27
27
  messages: @conversation.to_api_format,
28
- tools: @plan_mode ? read_only_tool_definitions : tool_definitions,
28
+ tools: filtered_tool_definitions,
29
29
  system: build_system_prompt,
30
30
  on_text: @on_text
31
31
  }
@@ -36,6 +36,8 @@ module RubynCode
36
36
  total: UsageTracker::TASK_BUDGET_TOTAL, remaining: @task_budget_remaining
37
37
  }
38
38
  end
39
+ budget = @llm_client.respond_to?(:thinking_budget_tokens) ? @llm_client.thinking_budget_tokens.to_i : 0
40
+ opts[:thinking] = { budget_tokens: budget } if budget.positive?
39
41
  opts
40
42
  end
41
43
 
@@ -45,6 +47,8 @@ module RubynCode
45
47
  # Falls back to nil (use client's default) if routing fails.
46
48
  # -- guard clauses for provider/mode checks
47
49
  def routed_model
50
+ return @model_override if @model_override
51
+
48
52
  return nil if manual_model_mode?
49
53
 
50
54
  last_user = last_user_message_text
@@ -114,7 +118,7 @@ module RubynCode
114
118
 
115
119
  def run_compaction
116
120
  before = @conversation.length
117
- est = @context_manager.estimated_tokens(@conversation.messages)
121
+ est = @context_manager.estimated_tokens(@conversation)
118
122
  RubynCode::Debug.token(
119
123
  "context=#{est} tokens (~#{before} messages, " \
120
124
  "threshold=#{Config::Defaults::CONTEXT_THRESHOLD_TOKENS})"
@@ -130,7 +134,7 @@ module RubynCode
130
134
  after = @conversation.length
131
135
  return unless after < before
132
136
 
133
- new_est = @context_manager.estimated_tokens(@conversation.messages)
137
+ new_est = @context_manager.estimated_tokens(@conversation)
134
138
  RubynCode::Debug.loop_tick(
135
139
  "Compacted: #{before} -> #{after} messages " \
136
140
  "(#{est} -> #{new_est} tokens)"