samagotchi 0.21.0 → 0.23.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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/bin/chi +9 -4
  4. data/docs/architecture.md +3 -2
  5. data/docs/configuration.md +15 -4
  6. data/docs/releasing.md +1 -1
  7. data/docs/sessions.md +1 -1
  8. data/docs/sub-agent.md +11 -5
  9. data/docs/testing.md +6 -0
  10. data/lib/samagotchi/answer_command.rb +11 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  12. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  13. data/lib/samagotchi/bootstrap_command.rb +2 -2
  14. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
  17. data/lib/samagotchi/bridge.rb +12 -9
  18. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  19. data/lib/samagotchi/bridge_client.rb +1 -1
  20. data/lib/samagotchi/bundle_command.rb +39 -29
  21. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  23. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
  25. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  26. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  29. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  31. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/cancellation_controller.rb +1 -1
  38. data/lib/samagotchi/cli/command.rb +1 -1
  39. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  40. data/lib/samagotchi/client.rb +3 -5
  41. data/lib/samagotchi/commands/registry.rb +2 -2
  42. data/lib/samagotchi/config.rb +38 -14
  43. data/lib/samagotchi/context_note.rb +1 -1
  44. data/lib/samagotchi/context_status.rb +6 -6
  45. data/lib/samagotchi/continue_offer.rb +44 -12
  46. data/lib/samagotchi/debug_log.rb +1 -1
  47. data/lib/samagotchi/desktop/macos.rb +5 -4
  48. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  49. data/lib/samagotchi/engine.rb +49 -19
  50. data/lib/samagotchi/gem_update.rb +2 -1
  51. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  52. data/lib/samagotchi/guardrails/approval.rb +5 -5
  53. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  54. data/lib/samagotchi/guardrails/gate.rb +2 -2
  55. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  56. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  57. data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
  58. data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
  59. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  60. data/lib/samagotchi/hooks/loader.rb +18 -16
  61. data/lib/samagotchi/hooks/registry.rb +10 -2
  62. data/lib/samagotchi/host_registry.rb +1 -3
  63. data/lib/samagotchi/idle_recap.rb +18 -15
  64. data/lib/samagotchi/idle_reminders.rb +6 -3
  65. data/lib/samagotchi/image_store.rb +5 -2
  66. data/lib/samagotchi/kernel_loop.rb +31 -31
  67. data/lib/samagotchi/llm/chat_loop.rb +9 -6
  68. data/lib/samagotchi/llm/errors.rb +33 -2
  69. data/lib/samagotchi/llm/http.rb +12 -12
  70. data/lib/samagotchi/llm/openai_chat.rb +53 -30
  71. data/lib/samagotchi/llm/prompt_cache.rb +83 -0
  72. data/lib/samagotchi/log.rb +2 -2
  73. data/lib/samagotchi/log_subscriber.rb +2 -1
  74. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  75. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  76. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  77. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  78. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  79. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  80. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  81. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  82. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  83. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  84. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  85. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  86. data/lib/samagotchi/memory_bundle.rb +1 -0
  87. data/lib/samagotchi/model_overlay.rb +3 -5
  88. data/lib/samagotchi/model_profile.rb +12 -8
  89. data/lib/samagotchi/note_command.rb +2 -1
  90. data/lib/samagotchi/output_formatter.rb +18 -17
  91. data/lib/samagotchi/owner_lock.rb +2 -1
  92. data/lib/samagotchi/parent_report.rb +24 -7
  93. data/lib/samagotchi/plugin/api.rb +8 -5
  94. data/lib/samagotchi/plugin/context.rb +1 -3
  95. data/lib/samagotchi/plugin/loader.rb +1 -1
  96. data/lib/samagotchi/plugin/service.rb +1 -1
  97. data/lib/samagotchi/plugin/sessions.rb +1 -0
  98. data/lib/samagotchi/plugin_tasks.rb +1 -1
  99. data/lib/samagotchi/prompt.rb +68 -12
  100. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  101. data/lib/samagotchi/question_desk.rb +7 -13
  102. data/lib/samagotchi/recap_setup.rb +1 -1
  103. data/lib/samagotchi/relay_desk.rb +1 -1
  104. data/lib/samagotchi/reminder_store.rb +2 -1
  105. data/lib/samagotchi/reply_wait.rb +12 -2
  106. data/lib/samagotchi/send_command.rb +4 -2
  107. data/lib/samagotchi/session.rb +2 -2
  108. data/lib/samagotchi/session_archive_command.rb +5 -1
  109. data/lib/samagotchi/session_commands.rb +1 -3
  110. data/lib/samagotchi/session_manager.rb +2 -4
  111. data/lib/samagotchi/session_metrics.rb +10 -10
  112. data/lib/samagotchi/session_observer.rb +3 -2
  113. data/lib/samagotchi/session_retention.rb +2 -2
  114. data/lib/samagotchi/sessions_command.rb +14 -5
  115. data/lib/samagotchi/system_prompt.rb +19 -8
  116. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  117. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  118. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
  119. data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
  120. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  121. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  122. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  123. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  124. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  125. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  126. data/lib/samagotchi/terminal_ui.rb +9 -5
  127. data/lib/samagotchi/text_diff.rb +12 -12
  128. data/lib/samagotchi/thinking.rb +6 -2
  129. data/lib/samagotchi/thought_stream_splitter.rb +6 -3
  130. data/lib/samagotchi/tool_activity.rb +1 -0
  131. data/lib/samagotchi/tool_call_parser.rb +13 -14
  132. data/lib/samagotchi/tool_declarations.rb +36 -35
  133. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  134. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  135. data/lib/samagotchi/tools/delegate.rb +1 -1
  136. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  137. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  138. data/lib/samagotchi/tools/edit.rb +14 -13
  139. data/lib/samagotchi/tools/execute.rb +3 -5
  140. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  141. data/lib/samagotchi/tools/memory.rb +21 -22
  142. data/lib/samagotchi/tools/read.rb +7 -7
  143. data/lib/samagotchi/tools/task_create.rb +1 -1
  144. data/lib/samagotchi/tools/task_get.rb +1 -1
  145. data/lib/samagotchi/tools/task_list.rb +1 -1
  146. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  147. data/lib/samagotchi/tools/task_stop.rb +1 -1
  148. data/lib/samagotchi/tools/task_wait.rb +3 -4
  149. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  150. data/lib/samagotchi/tools/write.rb +1 -1
  151. data/lib/samagotchi/turn_flow.rb +8 -8
  152. data/lib/samagotchi/turn_note.rb +5 -1
  153. data/lib/samagotchi/turn_state.rb +22 -0
  154. data/lib/samagotchi/version.rb +1 -1
  155. data/lib/samagotchi/vision_support.rb +2 -2
  156. data/lib/samagotchi/web/app.rb +13 -6
  157. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  158. data/lib/samagotchi/web/qr.rb +7 -1
  159. data/lib/samagotchi/web/server.rb +6 -2
  160. data/lib/samagotchi/worker.rb +10 -2
  161. metadata +21 -20
@@ -121,7 +121,7 @@ module Samagotchi
121
121
  when "trash" then trash(@argv[1..])
122
122
  else
123
123
  @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash"
124
- return CLI::Exit::USAGE
124
+ CLI::Exit::USAGE
125
125
  end
126
126
  end
127
127
 
@@ -131,7 +131,8 @@ module Samagotchi
131
131
  parsed = parse_flags(INSTALL_FLAGS, rest, "install", help: INSTALL_HELP)
132
132
  return parsed if parsed.is_a?(Integer)
133
133
 
134
- opts, source = parsed.options, parsed.args.last
134
+ opts = parsed.options
135
+ source = parsed.args.last
135
136
  scope = opts[:scope]
136
137
  force = opts.fetch(:force, false)
137
138
  if source.nil? || source.empty?
@@ -142,6 +143,7 @@ module Samagotchi
142
143
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
143
144
  return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
144
145
  end
146
+
145
147
  bundle_name = bundle_name_or_fail(expanded_source, "Install") or return 1
146
148
  run_installer(installer_for(expanded_source, bundle_name, scope: scope, force: force), failed: "Install") do |manifest|
147
149
  print_hooks_and_plugin(manifest)
@@ -153,7 +155,8 @@ module Samagotchi
153
155
  parsed = parse_flags(UPGRADE_FLAGS, rest, "upgrade", help: UPGRADE_HELP)
154
156
  return parsed if parsed.is_a?(Integer)
155
157
 
156
- opts, source = parsed.options, parsed.args.last
158
+ opts = parsed.options
159
+ source = parsed.args.last
157
160
  scope = opts[:scope]
158
161
  force = opts.fetch(:force, false)
159
162
  dry_run = opts.fetch(:dry_run, false)
@@ -166,6 +169,7 @@ module Samagotchi
166
169
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
167
170
  return install_profile(expanded_source, scope: scope, force: force, dry_run: dry_run, word: "Upgrade")
168
171
  end
172
+
169
173
  bundle_name = bundle_name_or_fail(expanded_source, "Upgrade") or return 1
170
174
  unless Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).installed?
171
175
  @stderr.puts "Bundle '#{bundle_name}' not installed — falling back to install"
@@ -195,8 +199,8 @@ module Samagotchi
195
199
  nil
196
200
  end
197
201
 
198
- def installer_for(source, name, scope:, force:, **options)
199
- Samagotchi::MemoryBundle::Installer.new(source: source, name: name, scope: scope, force: force, strict: true, **options)
202
+ def installer_for(source, name, scope:, force:, **)
203
+ Samagotchi::MemoryBundle::Installer.new(source: source, name: name, scope: scope, force: force, strict: true, **)
200
204
  end
201
205
 
202
206
  # Runs +installer+, prints its summary, then yields the manifest.
@@ -217,7 +221,7 @@ module Samagotchi
217
221
  end
218
222
 
219
223
  def print_hooks_and_plugin(manifest, plugin: true)
220
- @stdout.puts "Hooks: #{manifest.hooks.size} hook(s) (#{manifest.hooks.keys.join(', ')})" if manifest&.hooks&.any?
224
+ @stdout.puts "Hooks: #{manifest.hooks.size} hook(s) (#{manifest.hooks.keys.join(", ")})" if manifest&.hooks&.any?
221
225
  @stdout.puts "Plugin: #{manifest.plugin[:file]} (loads at the next chi start)" if plugin && manifest&.plugin
222
226
  end
223
227
 
@@ -245,7 +249,7 @@ module Samagotchi
245
249
  installer.conflicts.each { |k, _| @stdout.puts " conflict: #{k}" }
246
250
  launch = if agent.nil? && @stdin.tty?
247
251
  @stdout.print "Conflicts detected — launch interactive agent to resolve? [y/N] "
248
- ans = begin; @stdin.gets; rescue => _e; nil; end
252
+ ans = begin; @stdin.gets; rescue StandardError => _e; nil; end
249
253
  ans && ans.strip.downcase.start_with?("y")
250
254
  elsif agent.nil?
251
255
  @stdout.puts "Non-interactive terminal: kept your edits in the file(s) above; the rest is upgraded. Re-run with --force to take the bundle's version, or --agent in a TTY to merge."
@@ -279,7 +283,8 @@ module Samagotchi
279
283
  parsed = parse_flags(UNINSTALL_FLAGS, rest, "uninstall", help: UNINSTALL_HELP)
280
284
  return parsed if parsed.is_a?(Integer)
281
285
 
282
- opts, bundle_name = parsed.options, parsed.args.last
286
+ opts = parsed.options
287
+ bundle_name = parsed.args.last
283
288
  scope = opts[:scope]
284
289
  force = opts.fetch(:force, false)
285
290
  if bundle_name.nil? || bundle_name.empty?
@@ -294,13 +299,13 @@ module Samagotchi
294
299
  uninstaller.run
295
300
  @stdout.puts "Uninstalled bundle '#{bundle_name}'"
296
301
  puts_trashed(uninstaller.trashed_files, uninstaller.trash_dir)
297
- @stdout.puts "Removed: #{uninstaller.removed_files.join(', ')}" unless uninstaller.removed_files.empty?
302
+ @stdout.puts "Removed: #{uninstaller.removed_files.join(", ")}" unless uninstaller.removed_files.empty?
298
303
  hook_removed = uninstaller.removed_files.count { |f| f.start_with?("hooks/") }
299
304
  @stdout.puts "Hooks removed: #{hook_removed}" if hook_removed > 0
300
305
  uninstaller.warnings.each { |w| @stdout.puts w }
301
- return 0
306
+ 0
302
307
  rescue Samagotchi::MemoryBundle::Uninstaller::UninstallError => e
303
- @stderr.puts "Uninstall failed: #{e.message}"; return 1
308
+ @stderr.puts "Uninstall failed: #{e.message}"; 1
304
309
  end
305
310
  end
306
311
 
@@ -432,7 +437,7 @@ module Samagotchi
432
437
  @stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
433
438
  end
434
439
  end
435
- return 0
440
+ 0
436
441
  end
437
442
 
438
443
  # "team (unknown)" for a scope this chi can't resolve (Status#bundle_status).
@@ -467,7 +472,7 @@ module Samagotchi
467
472
  @stdout.puts File.exist?(base) ? File.read(base) : "(no base)"
468
473
  @stdout.puts "--- current (on-disk) ---"
469
474
  @stdout.puts File.exist?(plugin_path) ? File.read(plugin_path) : "(missing)"
470
- @stdout.puts "--- metadata: sha256=#{data[:plugin][:sha256]}#{data[:requires_chi] ? " requires_chi=#{data[:requires_chi]}" : ""}"
475
+ @stdout.puts "--- metadata: sha256=#{data[:plugin][:sha256]}#{" requires_chi=#{data[:requires_chi]}" if data[:requires_chi]}"
471
476
  end
472
477
  if file_arg
473
478
  # Try file first, then hook
@@ -491,7 +496,7 @@ module Samagotchi
491
496
  meta = hooks[file_arg.to_sym] || hooks[file_arg]
492
497
  if meta
493
498
  m = meta.transform_keys(&:to_s)
494
- @stdout.puts "--- metadata: event=#{m['event']} on_error=#{m['on_error']} priority=#{m['priority']} sha256=#{m['sha256']}"
499
+ @stdout.puts "--- metadata: event=#{m["event"]} on_error=#{m["on_error"]} priority=#{m["priority"]} sha256=#{m["sha256"]}"
495
500
  end
496
501
  elsif plugin_file && [plugin_file, "plugin/#{plugin_file}"].include?(file_arg)
497
502
  show_plugin.call
@@ -524,7 +529,7 @@ module Samagotchi
524
529
  meta = hooks[k.to_sym] || hooks[k]
525
530
  if meta
526
531
  m = meta.transform_keys(&:to_s)
527
- @stdout.puts "--- metadata: event=#{m['event']} on_error=#{m['on_error']} priority=#{m['priority']} sha256=#{m['sha256']}"
532
+ @stdout.puts "--- metadata: event=#{m["event"]} on_error=#{m["on_error"]} priority=#{m["priority"]} sha256=#{m["sha256"]}"
528
533
  end
529
534
  @stdout.puts ""
530
535
  end
@@ -533,7 +538,7 @@ module Samagotchi
533
538
  @stdout.puts ""
534
539
  end
535
540
  end
536
- return 0
541
+ 0
537
542
  end
538
543
 
539
544
  def list
@@ -552,6 +557,7 @@ module Samagotchi
552
557
  @stdout.puts "Installed:"
553
558
  installed.each do |b|
554
559
  next @stdout.puts(" #{b.name.ljust(name_w)} (#{b.error})") if b.error
560
+
555
561
  line = " #{b.name.ljust(name_w)} #{"v#{b.version || "?"}".ljust(ver_w)} scope=#{b.scope || "?"} " \
556
562
  "#{b.includes ? profile_members(b.includes, installed) : "files=#{b.files}"} installed=#{b.installed_at || "?"}"
557
563
  line += " (shipped v#{b.upgrade.version}: chi bundle upgrade #{b.upgrade.source})" if b.upgrade
@@ -567,7 +573,7 @@ module Samagotchi
567
573
  @stdout.puts " #{s.source.ljust(name_w)} #{"v#{s.version}".ljust(ver_w)} #{s.description}#{members}".rstrip
568
574
  end
569
575
  end
570
- return 0
576
+ 0
571
577
  end
572
578
 
573
579
  # An installed profile's list cell: its recorded members still
@@ -575,7 +581,7 @@ module Samagotchi
575
581
  def profile_members(members, installed)
576
582
  names = installed.map(&:name)
577
583
  here, gone = members.partition { |m| names.include?(m) }
578
- "includes=#{here.join(",")}#{gone.empty? ? "" : " left out=#{gone.join(",")}"}"
584
+ "includes=#{here.join(",")}#{" left out=#{gone.join(",")}" unless gone.empty?}"
579
585
  end
580
586
 
581
587
  def build(rest)
@@ -602,16 +608,16 @@ module Samagotchi
602
608
  result = builder.run
603
609
  @stdout.puts "Built #{result[:files].size} file(s) to #{result[:out_path]}"
604
610
  @stdout.puts "Bundle: #{result[:name]} v#{result[:version]} scope=#{result[:scope]}"
605
- @stdout.puts "Files: #{result[:files].join(', ')}" unless result[:files].empty?
611
+ @stdout.puts "Files: #{result[:files].join(", ")}" unless result[:files].empty?
606
612
  builder.warnings.each { |w| @stdout.puts w }
607
613
  unless result[:placeholder_warnings].empty?
608
614
  @stdout.puts "Placeholders:"
609
615
  result[:placeholder_warnings].each { |w| @stdout.puts " #{w}" }
610
616
  end
611
- return 0
617
+ 0
612
618
  rescue Samagotchi::MemoryBundle::Builder::BuildError => e
613
619
  @stderr.puts "Build failed: #{e.message}"
614
- return 1
620
+ 1
615
621
  end
616
622
  end
617
623
 
@@ -655,7 +661,7 @@ module Samagotchi
655
661
  end
656
662
 
657
663
  entries.each do |e|
658
- @stdout.puts " #{e.name} #{e.files} file#{'s' unless e.files == 1} #{format_bytes(e.bytes)} #{format_age(e.time)}"
664
+ @stdout.puts " #{e.name} #{e.files} file#{"s" unless e.files == 1} #{format_bytes(e.bytes)} #{format_age(e.time)}"
659
665
  end
660
666
  @stdout.puts "\nEmpty it with: chi bundle trash --empty [--older-than DAYS]"
661
667
  0
@@ -664,7 +670,7 @@ module Samagotchi
664
670
  def do_empty(older_than_days:, dry_run:)
665
671
  entries = Samagotchi::MemoryBundle::Trash.empty!(
666
672
  older_than_days: older_than_days,
667
- dry_run: dry_run,
673
+ dry_run: dry_run
668
674
  )
669
675
  if entries.empty?
670
676
  @stdout.puts "The bundle trash is empty."
@@ -674,11 +680,11 @@ module Samagotchi
674
680
  total_files = entries.sum(&:files)
675
681
  if dry_run
676
682
  entries.each do |e|
677
- @stdout.puts "Would delete: #{e.name} (#{e.files} file#{'s' unless e.files == 1}, #{format_bytes(e.bytes)})"
683
+ @stdout.puts "Would delete: #{e.name} (#{e.files} file#{"s" unless e.files == 1}, #{format_bytes(e.bytes)})"
678
684
  end
679
- @stdout.puts "\n(dry-run: #{entries.size} folder#{'s' unless entries.size == 1}, #{total_files} file#{'s' unless total_files == 1})"
685
+ @stdout.puts "\n(dry-run: #{entries.size} folder#{"s" unless entries.size == 1}, #{total_files} file#{"s" unless total_files == 1})"
680
686
  else
681
- @stdout.puts "Deleted #{entries.size} folder#{'s' unless entries.size == 1} (#{total_files} file#{'s' unless total_files == 1}) from the trash."
687
+ @stdout.puts "Deleted #{entries.size} folder#{"s" unless entries.size == 1} (#{total_files} file#{"s" unless total_files == 1}) from the trash."
682
688
  end
683
689
  0
684
690
  end
@@ -686,6 +692,7 @@ module Samagotchi
686
692
  # Format bytes as human-readable (B, KB, MB with one decimal)
687
693
  def format_bytes(bytes)
688
694
  return "0 B" if bytes == 0
695
+
689
696
  if bytes < 1024
690
697
  "#{bytes} B"
691
698
  elsif bytes < 1_048_576
@@ -731,6 +738,7 @@ module Samagotchi
731
738
  # helpers for bundle_name derivation
732
739
  def expand_source(src)
733
740
  return src if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(src)
741
+
734
742
  Samagotchi::MemoryBundle::SourceNormalizer.shipped_bundle_dir(src) || File.expand_path(src)
735
743
  end
736
744
 
@@ -749,13 +757,13 @@ module Samagotchi
749
757
  begin
750
758
  nd, owned = Samagotchi::MemoryBundle::SourceNormalizer.normalize(expanded_src)
751
759
  m = Samagotchi::MemoryBundle::Manifest.read(dir: nd)
752
- return m.name
760
+ m.name
753
761
  rescue Samagotchi::MemoryBundle::Manifest::ValidationError
754
762
  # Fallback to source basename without archive extensions
755
763
  src = expanded_src
756
764
  name = File.basename(src)
757
765
  name = name.sub(/\.tar\.gz\z/i, "").sub(/\.tgz\z/i, "").sub(/\.zip\z/i, "").sub(/\.tar\z/i, "")
758
- return name.empty? ? File.basename(nd || src) : name
766
+ name.empty? ? File.basename(nd || src) : name
759
767
  ensure
760
768
  Samagotchi::MemoryBundle::SourceNormalizer.cleanup(nd) if owned
761
769
  end
@@ -785,6 +793,7 @@ module Samagotchi
785
793
  preview = []
786
794
  max.times do |i|
787
795
  break if preview.size >= 30
796
+
788
797
  c = cur_lines[i]
789
798
  n = inc_lines[i]
790
799
  if c != n
@@ -795,7 +804,8 @@ module Samagotchi
795
804
  lines.concat(preview)
796
805
  lines << " --- end preview ---"
797
806
  end
798
- rescue => _e
807
+ rescue StandardError
808
+ # No preview for a file that can't be read: the diff lines stand alone.
799
809
  end
800
810
  end
801
811
  lines << ""
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: btw
3
- version: 0.1.1
3
+ version: 0.1.2
4
4
  scope: system
5
5
  description: "/btw <question>: a side answer about the conversation, as a card in every UI, even mid-turn; Keep as session forks it into a child session"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:d12a970e7e5ada617fc7d55ee1de80b576835092d268af620f3f7241188e3297
9
+ sha256: sha256:226a251865695a86cdb05d6b6318fc062e0ab54518bb25733699db964cbed80b
10
10
  requires_chi: ">= 0.1.30"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # The btw bundle (docs/plugins.md, The btw bundle): /btw <question> asks the
2
4
  # session's model a side question about the conversation. The answer shows
3
5
  # as a card in every UI and goes nowhere else: the conversation, the saved
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: check-in
3
- version: 0.2.1
3
+ version: 0.2.2
4
4
  scope: system
5
5
  description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:afbd0ecf5a41eefe79786e089aa918728ebb0fed0e3b75f84d754a47ec6ade18
9
+ sha256: sha256:52fcb0a3beccfee1e70e7d73162eace603da11bee43e535b1bc17d55eae98334
10
10
  requires_chi: ">= 0.3.0"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # The check-in bundle (docs/plugins.md, The check-in bundle): a turn that
2
4
  # has made many tool calls without answering gets checked on. After `after`
3
5
  # tool calls (then every `every` more) it asks the user with a card (Nudge /
@@ -24,11 +24,11 @@ class KnownNames
24
24
  # Splits a command or path into tokens; each token's [-_.] parts are
25
25
  # candidates too. "~" splits: in "~name" (that user's home) the name
26
26
  # after it is what is checked.
27
- TOKEN_SPLIT = %r{[/\s"'=:,;|&()<>@~]+}.freeze
28
- PART_SPLIT = /[-_.]+/.freeze
27
+ TOKEN_SPLIT = %r{[/\s"'=:,;|&()<>@~]+}
28
+ PART_SPLIT = /[-_.]+/
29
29
  # A candidate with a shell glob character is not checked: `workato*`
30
30
  # is a pattern over the name, not a misspelling of it.
31
- GLOB = /[*?\[\]{}]/.freeze
31
+ GLOB = /[*?\[\]{}]/
32
32
  ASK_OPTIONS = ["Correct it and run", "Run as is", "Deny"].freeze
33
33
 
34
34
  def initialize(settings = {})
@@ -134,6 +134,7 @@ class KnownNames
134
134
  names = @names + derived_names(event)
135
135
  names.each_with_object({}) do |name, acc|
136
136
  next if name.length < @min_length
137
+
137
138
  down = name.downcase
138
139
  next if @ignore.include?(down) || acc.key?(down)
139
140
 
@@ -149,7 +150,7 @@ class KnownNames
149
150
  root = context[:repo_root].to_s
150
151
  names << File.basename(root) if @derive.include?("repo") && !root.empty?
151
152
  names.concat(git_names(root)) if @derive.include?("git") && !root.empty?
152
- names.map(&:to_s).map(&:strip).reject(&:empty?)
153
+ names.map { |name| name.to_s.strip }.reject(&:empty?)
153
154
  rescue StandardError
154
155
  names
155
156
  end
@@ -216,6 +217,6 @@ class KnownNames
216
217
  end
217
218
 
218
219
  def list(value)
219
- Array(value).map(&:to_s).map(&:strip).reject(&:empty?)
220
+ Array(value).map { |name| name.to_s.strip }.reject(&:empty?)
220
221
  end
221
222
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: known-names
3
- version: 0.1.4
3
+ version: 0.1.5
4
4
  scope: system
5
5
  description: Catches a near-miss of a protected name (home folder, login, git name, repo name, names from config) in a tool call and rejects it with the right spelling; mode ask / correct optional
6
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  known_names.md: sha256:d5348b95b6c655017b541910b36e384220da280818160201f6ec9a50e1e4287c
9
9
  hooks:
10
10
  known_names.rb:
11
- sha256: sha256:c0845c9b6d04ec884c4641441d8a0ba3482283f4ee3e00c8b3bf99efb0c635b1
11
+ sha256: sha256:acff04fbe284b44644ca7140d574c1c0ebd2d999ede1243cd3fe0f432bfbb6d2
12
12
  event: before_tool_call
13
13
  on_error: log
14
14
  priority: 50
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: loop-guard
3
- version: 0.3.1
3
+ version: 0.3.2
4
4
  scope: system
5
5
  description: "Breaks loops: the same tool call with the same result again and again in one turn is denied with advice (after a few denies the turn stops with a card), and thinking that repeats itself while it streams is cut and the model asked again"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:32d04fa3f4b3394e80160dd780615a4a1ddeb3be0f2e327a6497c7766dbd9f11
9
+ sha256: sha256:27b135c462d8462313edaa1faa90f3104a1bff3c421d6965e9e71955f347d55b
10
10
  requires_chi: ">= 0.6.0"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # The loop-guard bundle (docs/plugins.md, The loop-guard bundle): a model
2
4
  # that runs the same tool call again and again in one turn, getting the
3
5
  # same result each time, is told to stop (a guardrail deny with advice),
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.1
3
+ version: 0.4.2
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:7c87e14dbdb20d32c37ef2a83f8d56448807ad26564a4034db4f6afbcf8a0323
10
+ sha256: sha256:d0491b038e7f111f9f8c629729a2b0525d573fd0b17d48ea8ec59c4a2b8b821b
11
11
  requires_chi: ">= 0.8.0"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # The mcp bundle (docs/plugins.md, The mcp bundle): tools from MCP servers.
2
4
  # Each server in config.yml runs as a child process (stdio only in v1) for
3
5
  # the session's life; its tools are the model's as mcp_<server>_<tool>.
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: skills
3
- version: 0.1.5
3
+ version: 0.1.6
4
4
  scope: system
5
5
  description: "Skills (skill_<name> memories, the steps of a task done together): /skill save, list, show and diff; older versions kept with a short diff line on every update; a nudge when a followed skill's step fails"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:9bd637f8fa933b03434e4ccdcbf2547aa011993b8044f2739ee7d50f995d2882
9
+ sha256: sha256:16aefdff7d7702a5feb9b2bafb9d975ceaa47f2864ad40f6df3624f4ba714136
10
10
  requires_chi: ">= 0.11.0"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # The skills bundle (docs/plugins.md, The skills bundle): a skill is a memory
2
4
  # named skill_<name> that holds the steps of a task done with the user
3
5
  # (docs/memory.md, Skills). The system bundle's identity already tells the
@@ -128,7 +130,7 @@ class Plugin
128
130
  removed = ops.count { |op, _| op == :del }
129
131
  first = ops.find { |op, _| op == :add } || ops.find { |op, _| op == :del }
130
132
  line = first ? cut(first.last.strip) : ""
131
- "skill #{name} updated (+#{added} −#{removed})#{line.empty? ? "" : ": #{line}"} · /skill diff #{name}"
133
+ "skill #{name} updated (+#{added} −#{removed})#{": #{line}" unless line.empty?} · /skill diff #{name}"
132
134
  end
133
135
 
134
136
  # --- the nudge ------------------------------------------------------------
@@ -289,7 +291,7 @@ class Plugin
289
291
  next unless skill_name(name) == name
290
292
 
291
293
  date, description = index.fetch("skill_#{name}", [nil, nil])
292
- line = +"#{name} · #{scope}"
294
+ line = "#{name} · #{scope}"
293
295
  line << " · #{date}" if date
294
296
  line << " — #{description}" if description
295
297
  line
@@ -338,7 +340,7 @@ class Plugin
338
340
  return "" if changed.empty?
339
341
 
340
342
  # Group changes whose context would touch into one hunk.
341
- groups = changed.slice_when { |x, y| y - x > 2 * CONTEXT + 1 }.to_a
343
+ groups = changed.slice_when { |x, y| y - x > (2 * CONTEXT) + 1 }.to_a
342
344
  old_at = new_at = 0
343
345
  positions = ops.map do |op, _|
344
346
  at = [old_at, new_at]
@@ -60,7 +60,7 @@ class SourceLinks
60
60
  REGEX_TIMEOUT = 0.5
61
61
  # A bare URL (scheme-anchored, so it can't start inside a markdown label),
62
62
  # and a markdown link (label in group 1, target in group 2).
63
- URL_SPAN = %r{[a-z][a-z0-9+.\-]*://\S+}i
63
+ URL_SPAN = %r{[a-z][a-z0-9+.-]*://\S+}i
64
64
  MARKDOWN_LINK = /\[([^\]]*)\]\(([^)]*)\)/
65
65
  # A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
66
66
  FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
@@ -84,7 +84,7 @@ class SourceLinks
84
84
  return nil unless match
85
85
 
86
86
  host = match[1]
87
- repo = match[2].to_s.sub(%r{\A/+}, "").sub(%r{/+\z}, "").sub(/\.git\z/, "").sub(%r{/+\z}, "")
87
+ repo = match[2].to_s.sub(%r{\A/+}, "").sub(%r{/+\z}, "").delete_suffix(".git").sub(%r{/+\z}, "")
88
88
  segments = repo.split("/", -1)
89
89
  return nil if host.empty? || segments.empty? || segments.any? { |segment| ["", ".", ".."].include?(segment) }
90
90
 
@@ -322,7 +322,7 @@ class SourceLinks
322
322
  in_target = start >= link[:target][0] && finish <= link[:target][1]
323
323
  in_label = start >= link[:label][0] && finish <= link[:label][1]
324
324
  in_target || (in_label && target_names_ref?(link[:target_text], text[start...finish],
325
- case_insensitive: match.regexp.casefold?))
325
+ case_insensitive: match.regexp.casefold?))
326
326
  end
327
327
  end
328
328
 
@@ -354,7 +354,7 @@ class SourceLinks
354
354
 
355
355
  def line(found)
356
356
  shown = found.first(@max)
357
- text = "sources: #{shown.map { |name, ref, url| "#{name} #{ref} → #{url}" }.join(', ')}"
357
+ text = "sources: #{shown.map { |name, ref, url| "#{name} #{ref} → #{url}" }.join(", ")}"
358
358
  extra = found.size - shown.size
359
359
  text += ", … +#{extra} more" if extra.positive?
360
360
  text
@@ -411,7 +411,7 @@ class SourceLinks
411
411
  groups = group_count(regex)
412
412
  known, unknown = used.partition do |word|
413
413
  if word.match?(/\A\d+\z/)
414
- groups.nil? || (word.to_i.between?(1, groups))
414
+ groups.nil? || word.to_i.between?(1, groups)
415
415
  else
416
416
  BUILTIN_PLACEHOLDERS.include?(word) || regex.names.include?(word)
417
417
  end
@@ -430,7 +430,7 @@ class SourceLinks
430
430
  def group_count(regex)
431
431
  probe = Regexp.new("(?:#{regex.source}\n)|", regex.options, timeout: REGEX_TIMEOUT)
432
432
  probe.match("").size - 1
433
- rescue RegexpError, Regexp::TimeoutError
433
+ rescue RegexpError # Regexp::TimeoutError is one
434
434
  nil
435
435
  end
436
436
 
@@ -528,6 +528,6 @@ class SourceLinks
528
528
 
529
529
  # A capture group is free-form, so escape what goes into the URL path.
530
530
  def escape_url(value)
531
- value.to_s.gsub(%r{[^A-Za-z0-9\-._~]}) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
531
+ value.to_s.gsub(/[^A-Za-z0-9\-._~]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
532
532
  end
533
533
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.3.2
3
+ version: 0.3.3
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
9
  hooks:
10
10
  source_links.rb:
11
- sha256: sha256:5a996b718b1db729305b893ed583b9e600e813570861d47e0b65a24fec36a35d
11
+ sha256: sha256:454d3512a4aa078b288ff6d0ed25867222a56ead1a57ef1eaa14918e631fec23
12
12
  event: after_turn
13
13
  on_error: log
14
14
  priority: 90
@@ -18,7 +18,7 @@ Single registry `Samagotchi::Config` (`Config::ENTRIES` in `lib/samagotchi/confi
18
18
 
19
19
  Sections forbid `_`/`-` (`SECTION_RE` `/\A[a-z0-9]+\z/`); leaves keep `snake_case` in YAML (`base_url`) and become kebab in CLI (`base-url`) via registry derivation — no generic string split, registry lookup avoids flat vs nested collision.
20
20
 
21
- **Universal entries** (`expose: [:env,:config,:cli]`): `default.model`, `server.host/port/transport/open_timeout/read_timeout`, `server.first_token_timeout` (env and config only), `recap.model/base_url/host_ref/inactivity/timeout/min_user_turns/sentences`, `session.retention_days/max_count/keep_status/sweep_interval_hours/idle_exit_minutes`, `session.shared/keep_empty/max_children` (env and config only), `log.file/disable`, `status.line`, `context.status/window_tokens/chars_per_token/status_thresholds/status_cadence`, `thinking.turn_preamble`, `default.n_predict`, `max_tool_output_chars`, `retry.max/base_delay/max_delay`, `read.*`, `execute.*`, `web.port/host`, `no_interrupt`, `no_default_input` etc. (`Config::ENTRIES`; `expose` says which of env/config/cli each takes). Precedence is `CLI > ENV > file > default`.
21
+ **Universal entries** (`expose: [:env,:config,:cli]`): `default.model`, `server.host/port/transport/open_timeout/read_timeout`, `server.first_token_timeout` (env and config only), `recap.model/base_url/host_ref/inactivity/timeout/min_user_turns/sentences`, `session.retention_days/max_count/keep_status/sweep_interval_hours/idle_exit_minutes`, `session.shared/keep_empty/max_children` (env and config only), `log.file/disable`, `status.line`, `context.status/window_tokens/chars_per_token/status_thresholds/status_cadence`, `thinking.turn_preamble`, `default.max_tokens`, `max_tool_output_chars`, `retry.max/base_delay/max_delay`, `read.*`, `execute.*`, `web.port/host`, `no_interrupt`, `no_default_input` etc. (`Config::ENTRIES`; `expose` says which of env/config/cli each takes). Precedence is `CLI > ENV > file > default`.
22
22
 
23
23
  Example `config.yml` (new nested form, preferred):
24
24
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.21.0
3
+ version: 0.23.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:69bc0ec091fdaeedf6e3aa7365275461fe3eba32b2dedc9cf45044176c356af5
9
- config_modification_protocol.md: sha256:8d8921854e616d840b393cdda6fd911b7d5e8172501bb8293c01f734121ccc21
9
+ config_modification_protocol.md: sha256:ec0b0c7023dbf03b93879e51cd5cdc38b22d43a2e5984ee11e8542ace077c3be
10
10
  memory_guide.md: sha256:17126f5adb9ef7b33c40e920ef339388c8c8c045b38a7de825b08760604db4e4
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -97,7 +97,7 @@ module Samagotchi
97
97
  end
98
98
 
99
99
  if immediate_reason
100
- block.call(immediate_reason)
100
+ yield(immediate_reason)
101
101
  nil
102
102
  else
103
103
  listener_id
@@ -29,7 +29,7 @@ module Samagotchi
29
29
  # @return [Integer] the usage exit status
30
30
  def usage_error(message)
31
31
  usage = usage_on_error
32
- error_line("#{command_name}: #{message}#{usage ? "" : " (see #{command_name} --help)"}")
32
+ error_line("#{command_name}: #{message}#{" (see #{command_name} --help)" unless usage}")
33
33
  @stderr.puts(usage) if usage
34
34
  Exit::USAGE
35
35
  end
@@ -64,10 +64,11 @@ module Samagotchi
64
64
  result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
65
65
  @reported = true
66
66
  ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
67
- json: @json, timeout: timeout)
67
+ json: @json, timeout: timeout, extra: report_extra)
68
68
  rescue Interrupt
69
69
  if @json
70
- @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id))
70
+ @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id,
71
+ extra: report_extra))
71
72
  @stdout.flush
72
73
  @reported = true
73
74
  end
@@ -77,6 +78,12 @@ module Samagotchi
77
78
  error_line("#{command_name}: the session is gone (deleted while waiting)")
78
79
  Exit::FAILED
79
80
  end
81
+
82
+ # More keys for the JSON object: answered_here: false when chi
83
+ # answer's answer didn't go in (the question was no longer open).
84
+ def report_extra
85
+ @answered_here == false ? { answered_here: false } : {}
86
+ end
80
87
  end
81
88
  end
82
89
  end
@@ -224,9 +224,7 @@ module Samagotchi
224
224
  attr_reader :host_name
225
225
 
226
226
  # The wire-format strategy for this client's transport.
227
- def transport
228
- @transport
229
- end
227
+ attr_reader :transport
230
228
 
231
229
  # Send a raw prompt and return the model's completion text.
232
230
  #
@@ -246,7 +244,7 @@ module Samagotchi
246
244
  # @param sampling [Hash] request fields to add (SamplingSettings):
247
245
  # temperature, penalties, …; they can't replace the fields above
248
246
  # @return [String] the generated text
249
- def complete(prompt, stop: ["<end_of_turn>", "<|tool_response>"], n_predict: nil, model: nil, on_chunk: nil, cancel_controller: nil, on_retry: nil,
247
+ def complete(prompt, stop: [], n_predict: nil, model: nil, on_chunk: nil, cancel_controller: nil, on_retry: nil,
250
248
  images: [], sampling: {})
251
249
  images = Array(images)
252
250
  request = { stop: stop, n_predict: n_predict, model: model, sampling: sampling }
@@ -449,7 +447,7 @@ module Samagotchi
449
447
  when :omlx
450
448
  ->(model) { resolve_omlx_model(model) }
451
449
  when :mlx
452
- ->(_model) { nil }
450
+ ->(_model) {}
453
451
  end
454
452
  Transport.new(name, model_resolver: resolver)
455
453
  end