rubino-agent 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +11 -2
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +172 -5
  5. data/CONTRIBUTING.md +10 -1
  6. data/README.md +14 -5
  7. data/Rakefile +31 -0
  8. data/docs/agents.md +42 -23
  9. data/docs/architecture.md +2 -2
  10. data/docs/commands.md +35 -3
  11. data/docs/configuration.md +20 -23
  12. data/docs/getting-started.md +5 -3
  13. data/docs/security.md +16 -5
  14. data/docs/skills.md +31 -0
  15. data/docs/troubleshooting.md +1 -1
  16. data/exe/rubino +16 -2
  17. data/install.sh +721 -59
  18. data/lib/rubino/active_agent.rb +73 -0
  19. data/lib/rubino/agent/action_claim_guard.rb +881 -0
  20. data/lib/rubino/agent/agent_registry.rb +5 -2
  21. data/lib/rubino/agent/definition.rb +1 -9
  22. data/lib/rubino/agent/fallback_chain.rb +0 -6
  23. data/lib/rubino/agent/iteration_budget.rb +109 -3
  24. data/lib/rubino/agent/loop.rb +476 -20
  25. data/lib/rubino/agent/model_call_runner.rb +81 -3
  26. data/lib/rubino/agent/prompts/build.txt +22 -5
  27. data/lib/rubino/agent/response_validator.rb +8 -0
  28. data/lib/rubino/agent/runner.rb +133 -8
  29. data/lib/rubino/agent/tool_executor.rb +166 -14
  30. data/lib/rubino/agent/truncation_continuation.rb +4 -1
  31. data/lib/rubino/api/server.rb +19 -0
  32. data/lib/rubino/attachments/classify.rb +35 -17
  33. data/lib/rubino/boot/config_guard.rb +71 -0
  34. data/lib/rubino/cli/chat/completion_builder.rb +42 -6
  35. data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
  36. data/lib/rubino/cli/chat/session_resolver.rb +87 -21
  37. data/lib/rubino/cli/chat_command.rb +1189 -50
  38. data/lib/rubino/cli/commands.rb +282 -2
  39. data/lib/rubino/cli/config_command.rb +68 -8
  40. data/lib/rubino/cli/doctor_command.rb +204 -12
  41. data/lib/rubino/cli/jobs_command.rb +12 -0
  42. data/lib/rubino/cli/memory_command.rb +53 -20
  43. data/lib/rubino/cli/onboarding_wizard.rb +79 -6
  44. data/lib/rubino/cli/session_command.rb +172 -18
  45. data/lib/rubino/cli/setup_command.rb +131 -8
  46. data/lib/rubino/cli/skills_command.rb +183 -9
  47. data/lib/rubino/cli/trust_gate.rb +16 -7
  48. data/lib/rubino/commands/built_ins.rb +2 -0
  49. data/lib/rubino/commands/command.rb +12 -2
  50. data/lib/rubino/commands/executor.rb +149 -12
  51. data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
  52. data/lib/rubino/commands/handlers/agents.rb +156 -41
  53. data/lib/rubino/commands/handlers/config.rb +4 -1
  54. data/lib/rubino/commands/handlers/help.rb +113 -14
  55. data/lib/rubino/commands/handlers/memory.rb +15 -5
  56. data/lib/rubino/commands/handlers/sessions.rb +26 -3
  57. data/lib/rubino/commands/handlers/status.rb +9 -4
  58. data/lib/rubino/commands/loader.rb +12 -0
  59. data/lib/rubino/config/configuration.rb +86 -24
  60. data/lib/rubino/config/defaults.rb +140 -33
  61. data/lib/rubino/config/loader.rb +62 -12
  62. data/lib/rubino/config/validator.rb +341 -0
  63. data/lib/rubino/config/writer.rb +123 -31
  64. data/lib/rubino/context/compressor.rb +184 -22
  65. data/lib/rubino/context/environment_inspector.rb +2 -2
  66. data/lib/rubino/context/file_discovery.rb +2 -2
  67. data/lib/rubino/context/message_boundary.rb +27 -1
  68. data/lib/rubino/context/project_languages.rb +90 -0
  69. data/lib/rubino/context/prompt_assembler.rb +105 -22
  70. data/lib/rubino/context/summary_builder.rb +45 -4
  71. data/lib/rubino/context/token_budget.rb +36 -11
  72. data/lib/rubino/context/token_estimate.rb +45 -0
  73. data/lib/rubino/context/tool_result_pruner.rb +81 -0
  74. data/lib/rubino/database/connection.rb +154 -3
  75. data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
  76. data/lib/rubino/database/migrator.rb +98 -5
  77. data/lib/rubino/documents/cap_exceeded.rb +13 -0
  78. data/lib/rubino/documents/converters/csv.rb +4 -3
  79. data/lib/rubino/documents/converters/docx.rb +29 -5
  80. data/lib/rubino/documents/converters/html.rb +5 -1
  81. data/lib/rubino/documents/converters/json.rb +2 -1
  82. data/lib/rubino/documents/converters/pdf.rb +11 -2
  83. data/lib/rubino/documents/converters/plain.rb +2 -1
  84. data/lib/rubino/documents/converters/pptx.rb +11 -2
  85. data/lib/rubino/documents/converters/xlsx.rb +35 -4
  86. data/lib/rubino/documents/converters/xml.rb +2 -1
  87. data/lib/rubino/documents/limits.rb +210 -0
  88. data/lib/rubino/documents.rb +10 -3
  89. data/lib/rubino/errors.rb +36 -5
  90. data/lib/rubino/interaction/cancel_token.rb +19 -3
  91. data/lib/rubino/interaction/events.rb +13 -0
  92. data/lib/rubino/interaction/lifecycle.rb +99 -13
  93. data/lib/rubino/interaction/polishing.rb +176 -0
  94. data/lib/rubino/jobs/cron_job_repository.rb +5 -8
  95. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
  96. data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
  97. data/lib/rubino/jobs/queue.rb +63 -8
  98. data/lib/rubino/jobs/runner.rb +24 -6
  99. data/lib/rubino/jobs/worker.rb +0 -4
  100. data/lib/rubino/llm/adapter_response.rb +47 -4
  101. data/lib/rubino/llm/credential_check.rb +15 -16
  102. data/lib/rubino/llm/error_classifier.rb +89 -1
  103. data/lib/rubino/llm/inline_think_filter.rb +69 -12
  104. data/lib/rubino/llm/request.rb +30 -3
  105. data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
  106. data/lib/rubino/llm/tool_bridge.rb +113 -9
  107. data/lib/rubino/mcp/manager.rb +18 -1
  108. data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
  109. data/lib/rubino/memory/aux_retry.rb +107 -0
  110. data/lib/rubino/memory/backends/sqlite.rb +73 -44
  111. data/lib/rubino/memory/backends.rb +23 -7
  112. data/lib/rubino/memory/salience_gate.rb +103 -0
  113. data/lib/rubino/memory/sqlite_extraction.rb +70 -0
  114. data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
  115. data/lib/rubino/memory/store.rb +33 -5
  116. data/lib/rubino/memory/threat_scanner.rb +52 -0
  117. data/lib/rubino/output/cost.rb +52 -0
  118. data/lib/rubino/output/headless_block_latch.rb +53 -0
  119. data/lib/rubino/output/result_serializer.rb +222 -0
  120. data/lib/rubino/output/turn_recorder.rb +77 -0
  121. data/lib/rubino/security/approval_policy.rb +227 -32
  122. data/lib/rubino/security/command_allowlist.rb +79 -4
  123. data/lib/rubino/security/doom_loop_detector.rb +21 -2
  124. data/lib/rubino/security/hardline_guard.rb +189 -16
  125. data/lib/rubino/security/pattern_matcher.rb +28 -5
  126. data/lib/rubino/security/prefix_deriver.rb +25 -6
  127. data/lib/rubino/security/readonly_commands.rb +145 -5
  128. data/lib/rubino/security/secret_path.rb +134 -0
  129. data/lib/rubino/security/url_safety.rb +255 -0
  130. data/lib/rubino/session/repository.rb +212 -11
  131. data/lib/rubino/session/store.rb +139 -14
  132. data/lib/rubino/skills/installer.rb +230 -0
  133. data/lib/rubino/skills/prompt_index.rb +2 -2
  134. data/lib/rubino/skills/registry.rb +52 -1
  135. data/lib/rubino/skills/skill.rb +64 -3
  136. data/lib/rubino/skills/skill_tool.rb +16 -5
  137. data/lib/rubino/tools/background_tasks.rb +157 -13
  138. data/lib/rubino/tools/base.rb +204 -3
  139. data/lib/rubino/tools/edit_tool.rb +73 -18
  140. data/lib/rubino/tools/glob_tool.rb +48 -9
  141. data/lib/rubino/tools/grep_tool.rb +103 -9
  142. data/lib/rubino/tools/multi_edit_tool.rb +64 -9
  143. data/lib/rubino/tools/patch_tool.rb +5 -0
  144. data/lib/rubino/tools/read_attachment_tool.rb +3 -1
  145. data/lib/rubino/tools/read_tool.rb +33 -15
  146. data/lib/rubino/tools/read_tracker.rb +153 -35
  147. data/lib/rubino/tools/registry.rb +113 -12
  148. data/lib/rubino/tools/result.rb +9 -1
  149. data/lib/rubino/tools/ruby_tool.rb +0 -0
  150. data/lib/rubino/tools/shell_registry.rb +70 -0
  151. data/lib/rubino/tools/shell_tool.rb +40 -1
  152. data/lib/rubino/tools/summarize_file_tool.rb +6 -0
  153. data/lib/rubino/tools/task_stop_tool.rb +10 -16
  154. data/lib/rubino/tools/task_tool.rb +36 -8
  155. data/lib/rubino/tools/vision_tool.rb +5 -0
  156. data/lib/rubino/tools/webfetch_tool.rb +39 -7
  157. data/lib/rubino/tools/websearch_tool.rb +92 -30
  158. data/lib/rubino/tools/write_tool.rb +23 -4
  159. data/lib/rubino/ui/api.rb +10 -1
  160. data/lib/rubino/ui/base.rb +11 -0
  161. data/lib/rubino/ui/bottom_composer.rb +382 -74
  162. data/lib/rubino/ui/cli.rb +515 -83
  163. data/lib/rubino/ui/completion_menu.rb +11 -7
  164. data/lib/rubino/ui/headless_trace.rb +63 -0
  165. data/lib/rubino/ui/live_region.rb +70 -7
  166. data/lib/rubino/ui/markdown_renderer.rb +142 -7
  167. data/lib/rubino/ui/notifier.rb +0 -2
  168. data/lib/rubino/ui/null.rb +52 -5
  169. data/lib/rubino/ui/paste_store.rb +16 -2
  170. data/lib/rubino/ui/queued_indicators.rb +6 -1
  171. data/lib/rubino/ui/status_bar.rb +61 -7
  172. data/lib/rubino/ui/streaming_markdown.rb +59 -6
  173. data/lib/rubino/ui/subagent_view.rb +29 -4
  174. data/lib/rubino/ui/tool_label.rb +52 -0
  175. data/lib/rubino/update_check.rb +39 -4
  176. data/lib/rubino/util/atomic_file.rb +117 -0
  177. data/lib/rubino/util/ignore_rules.rb +120 -0
  178. data/lib/rubino/util/output.rb +229 -12
  179. data/lib/rubino/util/secrets_mask.rb +70 -7
  180. data/lib/rubino/util/spill_store.rb +153 -0
  181. data/lib/rubino/version.rb +1 -1
  182. data/lib/rubino/workspace.rb +9 -1
  183. data/lib/rubino.rb +191 -7
  184. data/rubino-agent.gemspec +1 -0
  185. data/skills/ruby-expert/SKILL.md +1 -0
  186. metadata +42 -12
  187. data/lib/rubino/agent/router.rb +0 -65
  188. data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
  189. data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
  190. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
  191. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
  192. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
  193. data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
  194. data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
  195. data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
  196. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
data/lib/rubino.rb CHANGED
@@ -203,6 +203,53 @@ module Rubino
203
203
  Thread.current[:rubino_current_subagent_id] = prev
204
204
  end
205
205
 
206
+ # The CancelToken governing best-effort AUX work (post-turn polishing:
207
+ # memory-extract / skill-distill / summarize) running on THIS thread, if
208
+ # any. The detached polishing thread (Interaction::Polishing) binds its
209
+ # token here so the aux retry/backoff loop (Memory::AuxRetry) can poll it
210
+ # and abort the moment the user presses Esc — without threading a token
211
+ # through every aux call site. Nil on the foreground turn thread and on the
212
+ # API/server path (no detached polishing), where aux work is uncancellable
213
+ # as before.
214
+ def aux_cancel_token
215
+ Thread.current[:rubino_aux_cancel_token]
216
+ end
217
+
218
+ # Binds +token+ as the aux cancel token for the duration of the block
219
+ # (set by Interaction::Polishing around its detached job drain, exactly
220
+ # like #with_ui binds the run's UI). Thread-local so the aux retry loop
221
+ # reaches it with zero signature churn.
222
+ def with_aux_cancel_token(token)
223
+ prev = Thread.current[:rubino_aux_cancel_token]
224
+ Thread.current[:rubino_aux_cancel_token] = token
225
+ yield
226
+ ensure
227
+ Thread.current[:rubino_aux_cancel_token] = prev
228
+ end
229
+
230
+ # True while a HEADLESS one-shot run (`rubino prompt`/-q) is executing on
231
+ # THIS thread. Bound by ChatCommand#run_oneshot via #with_headless so tools
232
+ # that behave differently with no live REPL can tell — today only TaskTool,
233
+ # which forces `task` subagents to run FOREGROUND in headless mode (#380): in
234
+ # one-shot there is no IdleCardHost to fold a background child's result back
235
+ # in and the process exits the instant the parent's answer is ready, so a
236
+ # background fan-out would be silently dropped. Nil/false on the interactive
237
+ # REPL and the API/server path, where background subagents are surfaced.
238
+ def headless?
239
+ Thread.current[:rubino_headless] || false
240
+ end
241
+
242
+ # Binds the headless one-shot flag for the duration of the block (set by
243
+ # ChatCommand#run_oneshot around the turn, exactly like #with_ui). Thread-
244
+ # local so a tool reaches it with zero signature churn through the loop.
245
+ def with_headless
246
+ prev = Thread.current[:rubino_headless]
247
+ Thread.current[:rubino_headless] = true
248
+ yield
249
+ ensure
250
+ Thread.current[:rubino_headless] = prev
251
+ end
252
+
206
253
  # Returns the current structured logger.
207
254
  def logger
208
255
  @logger ||= Logger.new
@@ -216,6 +263,14 @@ module Rubino
216
263
  @database ||= Database::Connection.new(configuration.database_path)
217
264
  end
218
265
 
266
+ # Drops the memoized DB connection so the next #database call opens the file
267
+ # afresh. Used by `setup` after quarantining a corrupt DB so it reconnects
268
+ # to the newly-recreated file rather than the closed/renamed handle.
269
+ def reset_database!
270
+ @database&.close
271
+ @database = nil
272
+ end
273
+
219
274
  # First-run guard for any DB-touching entry point. A brand-new RUBINO_HOME
220
275
  # has no schema yet (setup/chat hasn't migrated it), so a read path like
221
276
  # `rubino sessions list` would otherwise hit a raw
@@ -230,16 +285,123 @@ module Rubino
230
285
  def ensure_database_ready!
231
286
  connection = database
232
287
  migrator = Database::Migrator.new(connection)
233
- return true unless connection.healthy? == false || migrator.pending?
288
+
289
+ # FAST PATH (lock-free, race-safe): a side-effect-free read of
290
+ # `schema_info` that does NOT construct a Sequel migrator. The common case
291
+ # — an already-set-up home — returns here without touching the lock. Note
292
+ # we MUST NOT call `migrator.pending?` off the lock: merely constructing
293
+ # Sequel's IntegerMigrator inserts the version-0 row, and two concurrent
294
+ # boots both inserting it is exactly the duplicate-row corruption (#race).
295
+ if connection.healthy? && migrator.up_to_date?
296
+ # A fully-migrated home can still be MOUNTED read-only (F14): the schema
297
+ # reads fine, but the very next write (the session row) would crash with
298
+ # a raw `SQLite3::ReadOnlyException` past this guard. Catch it HERE — a
299
+ # cheap dir-writability probe, no DB write — and raise the accurate
300
+ # "not writable" diagnosis instead, matching the migrate-path branch
301
+ # below. A real (writable) home passes through untouched.
302
+ raise ConfigurationError, "rubino home / database is not writable: #{home_path}" \
303
+ unless home_writable?
304
+
305
+ return true
306
+ end
234
307
 
235
308
  ensure_directories!
236
- migrator.migrate!
309
+ # Serialize the migration across concurrent boots (#race): N fresh
310
+ # `rubino` processes on a brand-new home would otherwise BOTH probe +
311
+ # migrate at once and corrupt the migrator bookkeeping. migrate! takes an
312
+ # exclusive flock and does the `pending?` probe + migrate entirely under
313
+ # it; waiters re-check and no-op. The lockfile lives in the home, which
314
+ # ensure_directories! just created.
315
+ migrator.migrate!(lock_path: migration_lock_path)
237
316
  true
317
+ rescue Database::BusyError, ConfigurationError
318
+ # A sustained concurrent-migration lock that outlived the connection
319
+ # retry budget (#333/#359), or a careless RUBINO_HOME that points at a file
320
+ # / a read-only parent (F13), is NOT an "un-set-up" home — re-raise so the
321
+ # single CLI chokepoint surfaces the clean one-liner instead of this method
322
+ # masking it as `false` → a misleading "run setup" message.
323
+ raise
238
324
  rescue StandardError => e
239
325
  logger.debug(event: "ensure_database_ready_failed", error: "#{e.class}: #{e.message}")
326
+ # A read-only / not-writable home (F14) is NOT an un-set-up install: the
327
+ # files may be perfectly present, the directory is just mounted read-only
328
+ # or owned by another user, so migrate! can't open the lock/journal
329
+ # (Errno::EACCES/EROFS) or SQLite reports "attempt to write a readonly
330
+ # database". Masking that as `false` produced the misleading
331
+ # "isn't set up yet — run `rubino setup`" — doctor already diagnoses it
332
+ # correctly. Raise the ACCURATE diagnosis (matching the F13 home-error
333
+ # phrasing) so the single CLI chokepoint surfaces it instead of "not
334
+ # set up". Everything else still degrades to false.
335
+ if not_writable_error?(e)
336
+ raise ConfigurationError,
337
+ "rubino home / database is not writable: #{home_path} (#{clean_errno_message(e.message)})"
338
+ end
339
+
240
340
  false
241
341
  end
242
342
 
343
+ # Cheap, side-effect-free check that the home directory accepts writes — the
344
+ # F14 read-only-mount guard. Falls back to assuming writable on any probe
345
+ # hiccup (the migrate path will still catch a real failure with the same
346
+ # accurate message), so this never wrongly blocks a usable home.
347
+ def home_writable?
348
+ File.writable?(home_path)
349
+ rescue StandardError
350
+ true
351
+ end
352
+
353
+ # True when +error+ is a write-permission / read-only-filesystem failure
354
+ # (vs. a genuinely un-set-up or transiently-busy home): a directory mounted
355
+ # read-only, owned by another user, or a SQLite "readonly database" report.
356
+ # Used by ensure_database_ready! to give an ACCURATE message instead of the
357
+ # misleading "not set up" (F14).
358
+ def not_writable_error?(error)
359
+ return true if error.is_a?(Errno::EACCES) || error.is_a?(Errno::EROFS) || error.is_a?(Errno::EPERM)
360
+
361
+ error.message.to_s.downcase.include?("readonly") ||
362
+ error.message.to_s.downcase.include?("read-only") ||
363
+ error.message.to_s.downcase.include?("read only")
364
+ end
365
+
366
+ # A clean, user-facing form of an Errno message. Ruby appends an internal
367
+ # ` @ <syscall> - <path>` artifact to SystemCallError messages
368
+ # (e.g. "Operation not permitted @ apply2files - /home/x",
369
+ # "Permission denied @ dir_s_mkdir - /home/x") — the C function name and a
370
+ # path we already name elsewhere in the sentence. Strip that tail so the
371
+ # surfaced message is just the plain reason ("Operation not permitted").
372
+ def clean_errno_message(message)
373
+ message.to_s.sub(/ @ \S+ - .*\z/, "")
374
+ end
375
+
376
+ # Path to the inter-process migration lockfile in the rubino home. A single
377
+ # source of truth so setup and the boot path lock on the SAME file.
378
+ def migration_lock_path
379
+ File.join(home_path, ".migrate.lock")
380
+ end
381
+
382
+ # A clean, actionable message when the on-disk DB is PRESENT but UNUSABLE,
383
+ # else nil. Covers the un-setup-able state a user command must never crash
384
+ # on with a raw backtrace (#333/#359): a corrupt/malformed image →
385
+ # quarantine + recreate via setup. (The concurrent first-boot race that used
386
+ # to leave duplicate `schema_info` rows is now prevented at the source by the
387
+ # flock + side-effect-free `up_to_date?` fast path in the migrator, so there
388
+ # is no post-hoc duplicate-row state left to message about.)
389
+ # Read-only: never creates the file (matches doctor's #68 contract).
390
+ def database_repair_message
391
+ db = database
392
+ return nil if db.memory? || !File.exist?(db.db_path)
393
+
394
+ if db.corrupt?
395
+ "database is corrupt (malformed image): #{db.db_path}\n" \
396
+ "Run `rubino doctor` to diagnose, then `rubino setup` to quarantine it " \
397
+ "and recreate a fresh database."
398
+ end
399
+ rescue StandardError
400
+ # Detection itself must never crash a command; treat an unexpected probe
401
+ # failure as "no clean message available" and let normal flow continue.
402
+ nil
403
+ end
404
+
243
405
  # Returns the event bus instance
244
406
  def event_bus
245
407
  @event_bus ||= Interaction::EventBus.new
@@ -295,11 +457,33 @@ module Rubino
295
457
  # (#65): an auto-created home used to be left at the umask's 0755.
296
458
  def ensure_directories!
297
459
  home = home_path
298
- FileUtils.mkdir_p(home)
299
- File.chmod(0o700, home)
300
- %w[memories sessions logs skills commands tools plugins].each do |subdir|
301
- dir = File.join(home, subdir)
302
- FileUtils.mkdir_p(dir) unless File.directory?(dir)
460
+ # A careless RUBINO_HOME (the value points at an EXISTING FILE, or its
461
+ # parent is read-only) made FileUtils.mkdir_p raise a raw Errno::EEXIST /
462
+ # Errno::EACCES backtrace from deep in fileutils.rb masking the actual,
463
+ # trivially-fixable mistake (F13). Normalize both into a clean, actionable
464
+ # domain error AT THE SOURCE so the single CLI chokepoint surfaces one line
465
+ # ("RUBINO_HOME is not a writable directory: <path>") + exit 1, in any
466
+ # output format, with no trace. A directory that already exists is fine.
467
+ if File.exist?(home) && !File.directory?(home)
468
+ raise ConfigurationError, "RUBINO_HOME is not a writable directory: #{home} " \
469
+ "(it points at an existing file — set RUBINO_HOME to a directory path)"
470
+ end
471
+
472
+ begin
473
+ FileUtils.mkdir_p(home)
474
+ # chmod/mkdir on an EXISTING read-only RUBINO_HOME (its parent let
475
+ # mkdir_p no-op, but the dir itself is not owner-writable) raises a raw
476
+ # Errno::EPERM/EACCES from deep in fileutils — the same unguarded
477
+ # backtrace F13 normalized for mkdir. Keep the perm ops inside the
478
+ # rescue so a non-writable home yields the SAME clean one-line domain
479
+ # error + exit 1, no trace.
480
+ File.chmod(0o700, home)
481
+ %w[memories sessions logs skills commands tools plugins].each do |subdir|
482
+ dir = File.join(home, subdir)
483
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
484
+ end
485
+ rescue SystemCallError => e
486
+ raise ConfigurationError, "RUBINO_HOME is not a writable directory: #{home} (#{clean_errno_message(e.message)})"
303
487
  end
304
488
  end
305
489
  end
data/rubino-agent.gemspec CHANGED
@@ -96,6 +96,7 @@ Gem::Specification.new do |spec|
96
96
  spec.add_development_dependency "roo", "~> 2.10"
97
97
 
98
98
  # Development dependencies
99
+ spec.add_development_dependency "parallel_tests", "~> 4.7"
99
100
  spec.add_development_dependency "rack-test", "~> 2.1"
100
101
  spec.add_development_dependency "rspec", "~> 3.12"
101
102
  spec.add_development_dependency "rubocop", "~> 1.60"
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: ruby-expert
3
3
  description: Deep Ruby & Rails expertise — idioms, OO design, metaprogramming, errors/types, concurrency, Rails, testing, performance, security, tooling, gem authoring. Load when writing, reviewing, debugging, or designing Ruby or Rails code, or when a Ruby/Rails decision needs an authoritative answer.
4
+ languages: [ruby]
4
5
  ---
5
6
 
6
7
  # Ruby expert
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubino-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jhon Rojas
@@ -387,6 +387,20 @@ dependencies:
387
387
  - - "~>"
388
388
  - !ruby/object:Gem::Version
389
389
  version: '2.10'
390
+ - !ruby/object:Gem::Dependency
391
+ name: parallel_tests
392
+ requirement: !ruby/object:Gem::Requirement
393
+ requirements:
394
+ - - "~>"
395
+ - !ruby/object:Gem::Version
396
+ version: '4.7'
397
+ type: :development
398
+ prerelease: false
399
+ version_requirements: !ruby/object:Gem::Requirement
400
+ requirements:
401
+ - - "~>"
402
+ - !ruby/object:Gem::Version
403
+ version: '4.7'
390
404
  - !ruby/object:Gem::Dependency
391
405
  name: rack-test
392
406
  requirement: !ruby/object:Gem::Requirement
@@ -483,7 +497,9 @@ files:
483
497
  - install.sh
484
498
  - lib/rubino-agent.rb
485
499
  - lib/rubino.rb
500
+ - lib/rubino/active_agent.rb
486
501
  - lib/rubino/active_skill.rb
502
+ - lib/rubino/agent/action_claim_guard.rb
487
503
  - lib/rubino/agent/agent_registry.rb
488
504
  - lib/rubino/agent/backoff_policy.rb
489
505
  - lib/rubino/agent/definition.rb
@@ -498,7 +514,6 @@ files:
498
514
  - lib/rubino/agent/prompts/general.txt
499
515
  - lib/rubino/agent/prompts/plan.txt
500
516
  - lib/rubino/agent/response_validator.rb
501
- - lib/rubino/agent/router.rb
502
517
  - lib/rubino/agent/runner.rb
503
518
  - lib/rubino/agent/tool_executor.rb
504
519
  - lib/rubino/agent/truncation_continuation.rb
@@ -560,6 +575,7 @@ files:
560
575
  - lib/rubino/attachments/defang.rb
561
576
  - lib/rubino/attachments/policy.rb
562
577
  - lib/rubino/attachments/preamble.rb
578
+ - lib/rubino/boot/config_guard.rb
563
579
  - lib/rubino/boot/encryption_key.rb
564
580
  - lib/rubino/cli/chat/bang_shell.rb
565
581
  - lib/rubino/cli/chat/completion_builder.rb
@@ -582,6 +598,7 @@ files:
582
598
  - lib/rubino/commands/built_ins.rb
583
599
  - lib/rubino/commands/command.rb
584
600
  - lib/rubino/commands/executor.rb
601
+ - lib/rubino/commands/handlers/agent_switch.rb
585
602
  - lib/rubino/commands/handlers/agents.rb
586
603
  - lib/rubino/commands/handlers/config.rb
587
604
  - lib/rubino/commands/handlers/help.rb
@@ -596,28 +613,24 @@ files:
596
613
  - lib/rubino/config/defaults.rb
597
614
  - lib/rubino/config/loader.rb
598
615
  - lib/rubino/config/reasoning_prefs.rb
616
+ - lib/rubino/config/validator.rb
599
617
  - lib/rubino/config/writer.rb
600
618
  - lib/rubino/context/compressor.rb
601
619
  - lib/rubino/context/environment_inspector.rb
602
620
  - lib/rubino/context/file_discovery.rb
603
621
  - lib/rubino/context/message_boundary.rb
622
+ - lib/rubino/context/project_languages.rb
604
623
  - lib/rubino/context/prompt_assembler.rb
605
624
  - lib/rubino/context/summary_builder.rb
606
625
  - lib/rubino/context/token_budget.rb
626
+ - lib/rubino/context/token_estimate.rb
607
627
  - lib/rubino/context/tool_pair_sanitizer.rb
628
+ - lib/rubino/context/tool_result_pruner.rb
608
629
  - lib/rubino/database/connection.rb
609
630
  - lib/rubino/database/migrations/001_create_initial_schema.rb
610
- - lib/rubino/database/migrations/002_create_runs.rb
611
- - lib/rubino/database/migrations/003_create_skill_states.rb
612
- - lib/rubino/database/migrations/004_create_cron_jobs.rb
613
- - lib/rubino/database/migrations/005_create_oauth_connections.rb
614
- - lib/rubino/database/migrations/006_create_webhook_deliveries.rb
615
- - lib/rubino/database/migrations/007_create_messages_fts.rb
616
- - lib/rubino/database/migrations/008_create_memory_facts.rb
617
- - lib/rubino/database/migrations/009_create_memory_graph.rb
618
- - lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb
619
631
  - lib/rubino/database/migrator.rb
620
632
  - lib/rubino/documents.rb
633
+ - lib/rubino/documents/cap_exceeded.rb
621
634
  - lib/rubino/documents/converters/csv.rb
622
635
  - lib/rubino/documents/converters/docx.rb
623
636
  - lib/rubino/documents/converters/html.rb
@@ -628,6 +641,7 @@ files:
628
641
  - lib/rubino/documents/converters/xlsx.rb
629
642
  - lib/rubino/documents/converters/xml.rb
630
643
  - lib/rubino/documents/html.rb
644
+ - lib/rubino/documents/limits.rb
631
645
  - lib/rubino/documents/registry.rb
632
646
  - lib/rubino/documents/table.rb
633
647
  - lib/rubino/errors.rb
@@ -639,6 +653,7 @@ files:
639
653
  - lib/rubino/interaction/image_input.rb
640
654
  - lib/rubino/interaction/input_queue.rb
641
655
  - lib/rubino/interaction/lifecycle.rb
656
+ - lib/rubino/interaction/polishing.rb
642
657
  - lib/rubino/interaction/probe.rb
643
658
  - lib/rubino/interaction/state.rb
644
659
  - lib/rubino/jobs/cron_job_repository.rb
@@ -688,6 +703,7 @@ files:
688
703
  - lib/rubino/mcp.rb
689
704
  - lib/rubino/mcp/manager.rb
690
705
  - lib/rubino/mcp/mcp_tool_wrapper.rb
706
+ - lib/rubino/memory/aux_retry.rb
691
707
  - lib/rubino/memory/backend.rb
692
708
  - lib/rubino/memory/backends.rb
693
709
  - lib/rubino/memory/backends/default.rb
@@ -696,6 +712,8 @@ files:
696
712
  - lib/rubino/memory/extractor.rb
697
713
  - lib/rubino/memory/flusher.rb
698
714
  - lib/rubino/memory/retriever.rb
715
+ - lib/rubino/memory/salience_gate.rb
716
+ - lib/rubino/memory/sqlite_extraction.rb
699
717
  - lib/rubino/memory/sqlite_extraction_prompt.rb
700
718
  - lib/rubino/memory/sqlite_graph.rb
701
719
  - lib/rubino/memory/store.rb
@@ -708,6 +726,10 @@ files:
708
726
  - lib/rubino/oauth/provider/google.rb
709
727
  - lib/rubino/oauth/registry.rb
710
728
  - lib/rubino/oauth/token_encryptor.rb
729
+ - lib/rubino/output/cost.rb
730
+ - lib/rubino/output/headless_block_latch.rb
731
+ - lib/rubino/output/result_serializer.rb
732
+ - lib/rubino/output/turn_recorder.rb
711
733
  - lib/rubino/plugins.rb
712
734
  - lib/rubino/plugins/registry.rb
713
735
  - lib/rubino/run/approval_gate.rb
@@ -728,11 +750,14 @@ files:
728
750
  - lib/rubino/security/pattern_matcher.rb
729
751
  - lib/rubino/security/prefix_deriver.rb
730
752
  - lib/rubino/security/readonly_commands.rb
753
+ - lib/rubino/security/secret_path.rb
754
+ - lib/rubino/security/url_safety.rb
731
755
  - lib/rubino/session/exporter.rb
732
756
  - lib/rubino/session/message.rb
733
757
  - lib/rubino/session/repository.rb
734
758
  - lib/rubino/session/store.rb
735
759
  - lib/rubino/session/summary_store.rb
760
+ - lib/rubino/skills/installer.rb
736
761
  - lib/rubino/skills/prompt_index.rb
737
762
  - lib/rubino/skills/registry.rb
738
763
  - lib/rubino/skills/skill.rb
@@ -790,6 +815,7 @@ files:
790
815
  - lib/rubino/ui/completion_menu.rb
791
816
  - lib/rubino/ui/completion_source.rb
792
817
  - lib/rubino/ui/escape_reader.rb
818
+ - lib/rubino/ui/headless_trace.rb
793
819
  - lib/rubino/ui/indented_io.rb
794
820
  - lib/rubino/ui/input_history.rb
795
821
  - lib/rubino/ui/live_region.rb
@@ -805,11 +831,15 @@ files:
805
831
  - lib/rubino/ui/streaming_markdown.rb
806
832
  - lib/rubino/ui/subagent_cards.rb
807
833
  - lib/rubino/ui/subagent_view.rb
834
+ - lib/rubino/ui/tool_label.rb
808
835
  - lib/rubino/update_check.rb
836
+ - lib/rubino/util/atomic_file.rb
809
837
  - lib/rubino/util/duration.rb
810
838
  - lib/rubino/util/hyperlink.rb
839
+ - lib/rubino/util/ignore_rules.rb
811
840
  - lib/rubino/util/output.rb
812
841
  - lib/rubino/util/secrets_mask.rb
842
+ - lib/rubino/util/spill_store.rb
813
843
  - lib/rubino/version.rb
814
844
  - lib/rubino/workspace.rb
815
845
  - mise.toml
@@ -849,7 +879,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
849
879
  - !ruby/object:Gem::Version
850
880
  version: '0'
851
881
  requirements: []
852
- rubygems_version: 4.0.6
882
+ rubygems_version: 4.0.14
853
883
  specification_version: 4
854
884
  summary: A lightweight Ruby coding and automation agent with persistent memory, sessions,
855
885
  and context compaction
@@ -1,65 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Rubino
4
- module Agent
5
- # Routes user input to the appropriate agent.
6
- # Handles @mention syntax for subagent invocation and agent switching.
7
- class Router
8
- MENTION_REGEX = /\A@(\w+)\s+(.+)/m
9
-
10
- def initialize(registry:, ui:)
11
- @registry = registry
12
- @ui = ui
13
- @current_agent = registry.default
14
- end
15
-
16
- attr_reader :current_agent
17
-
18
- # Switches to a different primary agent
19
- def switch_to(agent_name)
20
- agent = @registry.find(agent_name)
21
- unless agent
22
- @ui.error("unknown agent: #{agent_name}")
23
- return false
24
- end
25
-
26
- unless agent.primary?
27
- @ui.error("cannot switch to subagent '#{agent_name}'. Use @#{agent_name} to invoke it.")
28
- return false
29
- end
30
-
31
- @current_agent = agent
32
- @ui.info("Switched to agent: #{agent.name}")
33
- true
34
- end
35
-
36
- # Routes input, returning [agent_definition, cleaned_input]
37
- def route(input)
38
- # Check for @mention
39
- if input.match?(MENTION_REGEX)
40
- match = input.match(MENTION_REGEX)
41
- agent_name = match[1]
42
- actual_input = match[2]
43
-
44
- agent = @registry.find(agent_name)
45
- return [agent, actual_input] if agent && (agent.subagent? || agent.primary?)
46
-
47
- @ui.warning("Unknown agent '#{agent_name}', using current agent")
48
-
49
- end
50
-
51
- [@current_agent, input]
52
- end
53
-
54
- # Returns available agent names for autocomplete
55
- def available_mentions
56
- @registry.subagents.map { |a| "@#{a.name}" }
57
- end
58
-
59
- # Returns primary agent names for switching
60
- def switchable_agents
61
- @registry.primary_agents.map(&:name)
62
- end
63
- end
64
- end
65
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Sequel.migration do
4
- up do
5
- create_table(:runs) do
6
- String :id, primary_key: true
7
- String :session_id, null: false
8
- String :status, null: false, default: "queued" # queued|running|completed|failed|stopped
9
- Text :input_text
10
- Text :attachments_json
11
- Text :skills_json
12
- String :model
13
- String :provider
14
- Integer :tokens_input, default: 0
15
- Integer :tokens_output, default: 0
16
- Text :error
17
- Boolean :stop_requested, null: false, default: false
18
- String :started_at
19
- String :finished_at
20
- String :created_at, null: false
21
- String :updated_at, null: false
22
-
23
- foreign_key [:session_id], :sessions, key: :id
24
- end
25
-
26
- add_index :runs, :session_id
27
- add_index :runs, :status
28
-
29
- alter_table(:events) do
30
- add_column :run_id, String
31
- add_column :seq, Integer # per-session monotonic seq for SSE Last-Event-ID
32
- end
33
-
34
- add_index :events, :run_id
35
- add_index :events, %i[session_id seq]
36
- end
37
-
38
- down do
39
- alter_table(:events) do
40
- drop_column :seq
41
- drop_column :run_id
42
- end
43
- drop_table(:runs)
44
- end
45
- end
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Sequel.migration do
4
- up do
5
- create_table(:skill_states) do
6
- String :name, primary_key: true
7
- Boolean :enabled, null: false, default: true
8
- String :updated_at, null: false
9
- end
10
- end
11
-
12
- down do
13
- drop_table(:skill_states)
14
- end
15
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Sequel.migration do
4
- up do
5
- create_table(:cron_jobs) do
6
- String :id, primary_key: true
7
- String :name, null: false
8
- String :schedule, null: false # cron expression
9
- Text :prompt, null: false
10
- Text :skills_json
11
- String :model
12
- String :provider
13
- String :deliver, null: false, default: "local" # local|webhook
14
- Boolean :enabled, null: false, default: true
15
- String :last_run_at
16
- String :last_run_id
17
- String :created_at, null: false
18
- String :updated_at, null: false
19
- end
20
-
21
- add_index :cron_jobs, :enabled
22
- add_index :cron_jobs, :name
23
-
24
- alter_table(:runs) do
25
- add_column :cron_job_id, String
26
- end
27
- add_index :runs, :cron_job_id
28
- end
29
-
30
- down do
31
- alter_table(:runs) do
32
- drop_column :cron_job_id
33
- end
34
- drop_table(:cron_jobs)
35
- end
36
- end
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Sequel.migration do
4
- up do
5
- create_table(:oauth_connections) do
6
- String :id, primary_key: true
7
- String :provider, null: false
8
- String :account_id, null: false
9
- String :account_email
10
- Text :access_token, null: false # encrypted
11
- Text :refresh_token # encrypted
12
- String :expires_at
13
- Text :scopes_json, null: false
14
- Text :metadata_json
15
- String :created_at, null: false
16
- String :updated_at, null: false
17
-
18
- unique %i[provider account_id]
19
- end
20
-
21
- add_index :oauth_connections, :provider
22
- end
23
-
24
- down do
25
- drop_table(:oauth_connections)
26
- end
27
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Sequel.migration do
4
- up do
5
- create_table(:webhook_deliveries) do
6
- String :id, primary_key: true
7
- String :job_id
8
- String :run_id
9
- String :target_url, null: false
10
- # request_id (X-Rubino-Delivery-Id) is unique across delivery rows so
11
- # a crash-then-restart cannot create two pending rows for the same logical
12
- # attempt; the resume hook keys off this column.
13
- String :request_id, null: false, unique: true
14
- String :payload_sha256, null: false
15
- Integer :attempt_count, null: false, default: 0
16
- String :status, null: false, default: "pending" # pending|delivered|failed|dead
17
- Text :last_error
18
- Text :payload_json, null: false
19
- String :scheduled_at, null: false
20
- String :delivered_at
21
- String :created_at, null: false
22
- String :updated_at, null: false
23
- end
24
-
25
- add_index :webhook_deliveries, :status
26
- add_index :webhook_deliveries, :scheduled_at
27
- add_index :webhook_deliveries, :job_id
28
- add_index :webhook_deliveries, :run_id
29
- end
30
-
31
- down do
32
- drop_table(:webhook_deliveries)
33
- end
34
- end