samagotchi 0.61.0 → 0.63.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +3 -0
  6. data/docs/cli.md +26 -9
  7. data/docs/configuration.md +33 -20
  8. data/docs/context.md +21 -3
  9. data/docs/guardrails.md +2 -1
  10. data/docs/hooks.md +14 -3
  11. data/docs/internals/engine.md +24 -11
  12. data/docs/internals/prompt-caching.md +6 -1
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +131 -5
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +4 -4
  17. data/docs/sub-agent.md +6 -0
  18. data/docs/testing.md +10 -4
  19. data/lib/samagotchi/answer_refs.rb +18 -26
  20. data/lib/samagotchi/bridge.rb +23 -1
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  29. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  31. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  35. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/engine.rb +9 -0
  40. data/lib/samagotchi/engine_peers.rb +1 -0
  41. data/lib/samagotchi/idle_recap.rb +2 -3
  42. data/lib/samagotchi/image_store.rb +8 -0
  43. data/lib/samagotchi/kernel_loop.rb +7 -16
  44. data/lib/samagotchi/llm/chat_loop.rb +23 -15
  45. data/lib/samagotchi/plugin/api.rb +36 -18
  46. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  47. data/lib/samagotchi/reasoning_history.rb +22 -4
  48. data/lib/samagotchi/recap_setup.rb +3 -3
  49. data/lib/samagotchi/row_refs.rb +125 -0
  50. data/lib/samagotchi/session.rb +12 -0
  51. data/lib/samagotchi/session_metrics.rb +24 -2
  52. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
  54. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  55. data/lib/samagotchi/terminal_ui.rb +57 -14
  56. data/lib/samagotchi/tool_activity.rb +8 -4
  57. data/lib/samagotchi/tool_response.rb +5 -4
  58. data/lib/samagotchi/tool_row_fields.rb +4 -2
  59. data/lib/samagotchi/tool_runner.rb +16 -3
  60. data/lib/samagotchi/tools/builtins.rb +9 -2
  61. data/lib/samagotchi/tools/execute.rb +37 -7
  62. data/lib/samagotchi/tools/peers.rb +3 -2
  63. data/lib/samagotchi/tools/registry.rb +5 -3
  64. data/lib/samagotchi/version.rb +1 -1
  65. data/lib/samagotchi/web/app.rb +20 -5
  66. data/lib/samagotchi/web/message_parts.rb +17 -9
  67. data/lib/samagotchi/web/public/activity.js +6 -3
  68. data/lib/samagotchi/web/public/app.js +106 -106
  69. data/lib/samagotchi/web/public/card.js +73 -23
  70. data/lib/samagotchi/web/public/chains.js +24 -24
  71. data/lib/samagotchi/web/public/command_view.js +39 -35
  72. data/lib/samagotchi/web/public/context_chips.js +92 -64
  73. data/lib/samagotchi/web/public/copy.js +7 -15
  74. data/lib/samagotchi/web/public/diff_view.js +20 -23
  75. data/lib/samagotchi/web/public/families_view.js +51 -39
  76. data/lib/samagotchi/web/public/format.js +0 -48
  77. data/lib/samagotchi/web/public/images.js +12 -16
  78. data/lib/samagotchi/web/public/index.html +61 -10
  79. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  80. data/lib/samagotchi/web/public/lists_view.js +13 -13
  81. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  82. data/lib/samagotchi/web/public/question_card.js +11 -0
  83. data/lib/samagotchi/web/public/question_cards.js +63 -7
  84. data/lib/samagotchi/web/public/read_view.js +10 -9
  85. data/lib/samagotchi/web/public/ref_menu.js +18 -5
  86. data/lib/samagotchi/web/public/refs.js +113 -15
  87. data/lib/samagotchi/web/public/scope.js +23 -0
  88. data/lib/samagotchi/web/public/scroll.js +42 -0
  89. data/lib/samagotchi/web/public/stage_model.js +22 -3
  90. data/lib/samagotchi/web/public/stage_view.js +299 -70
  91. data/lib/samagotchi/web/public/timing.js +1 -8
  92. data/lib/samagotchi/web/public/turn_html.js +122 -61
  93. data/lib/samagotchi/web/public/turn_model.js +49 -0
  94. data/lib/samagotchi/web/public/turn_view.js +167 -116
  95. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  96. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  97. data/lib/samagotchi/worker.rb +49 -32
  98. data/lib/samagotchi/worker_inbound.rb +17 -14
  99. metadata +10 -1
@@ -100,14 +100,18 @@ module Samagotchi
100
100
  # (the -p one too) have had their turns; if one failed, #run says so
101
101
  # @param children_counts [#call] session id -> its delegates'
102
102
  # ChildrenStatus::Counts, the status row's children segment
103
+ # @param state_dir [String] the state dir the worker was found in: its
104
+ # session folder (@path images, saved records) is under it
103
105
  def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
104
106
  default_input: false, wait_at_eof: false, parent_answers: false,
105
107
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
106
108
  delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
107
109
  archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
108
110
  installed_version: -> { InstalledVersions.new.newest },
109
- children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: Session.default_state_dir) })
111
+ state_dir: Session.default_state_dir,
112
+ children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: state_dir) })
110
113
  @client = client
114
+ @state_dir = state_dir
111
115
  @installed_version = installed_version
112
116
  @delete_session = delete_session
113
117
  @archive_session = archive_session
@@ -342,7 +346,7 @@ module Samagotchi
342
346
  id = @client.session_id
343
347
  return :gone if Session.stopped_marker?(id)
344
348
 
345
- BridgeClient.discover(id, session_dir: Session.session_dir(id), host: @client.host)
349
+ BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir), host: @client.host)
346
350
  end
347
351
 
348
352
  # The stream moved to a new worker (a restart): requests go there too.
@@ -683,7 +687,11 @@ module Samagotchi
683
687
  end
684
688
  @continue_answer = nil
685
689
  @screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
686
- sync_prompt
690
+ # Back to the > prompt: with the text a question set aside meanwhile
691
+ # (#close_question), else what the ? read holds.
692
+ set_aside = @set_aside
693
+ @set_aside = nil
694
+ set_aside ? sync_prompt(keep_text: false, prefill: set_aside) : sync_prompt
687
695
  end
688
696
 
689
697
  # The notes slot shows the offer's choices while it waits (a question
@@ -802,7 +810,7 @@ module Samagotchi
802
810
  paths = ImageInput.extract(text)
803
811
  return [] if paths.empty?
804
812
 
805
- session_dir = Session.session_dir(@client.session_id)
813
+ session_dir = Session.session_dir(@client.session_id, state_dir: @state_dir)
806
814
  paths.map do |image|
807
815
  ref = ImageStore.ingest(session_dir, path: image[:path])
808
816
  { file: ref[:file], name: ref[:name] }
@@ -865,7 +873,9 @@ module Samagotchi
865
873
 
866
874
  @view.finish_thinking_spinner
867
875
  # What was typed at the prompt waits for the question to close.
868
- @set_aside = @reader&.typed_text unless @question
876
+ # Not over text already set aside (a question that closed while a
877
+ # continue offer waits): the ? read holds an answer then, if anything.
878
+ @set_aside = @reader&.typed_text.to_s.then { |typed| typed.empty? ? nil : typed } unless @question || @set_aside
869
879
  @question = QuestionPrompt.new(pending)
870
880
  @question_pending = pending
871
881
  # The step-limit question stands for the continue offer's own slot
@@ -987,10 +997,14 @@ module Samagotchi
987
997
  @screen.clear_slot(:notes)
988
998
  @screen.commit(@question.summary(outcome, paint: method(:paint))) if outcome
989
999
  @question = nil
1000
+ # A continue offer still waiting keeps the ? prompt (the step-limit
1001
+ # question closes before its offer resolves): the text set aside
1002
+ # waits for the > prompt (#continue_resolved).
1003
+ return sync_continue_slot if @continue_offer
1004
+
990
1005
  set_aside = @set_aside
991
1006
  @set_aside = nil
992
1007
  sync_prompt(keep_text: false, prefill: set_aside)
993
- sync_continue_slot if @continue_offer
994
1008
  end
995
1009
 
996
1010
  # Restart the open read when its prompt no longer fits (a question or a
@@ -1195,7 +1209,10 @@ module Samagotchi
1195
1209
  return unless last_user
1196
1210
 
1197
1211
  @screen.commit(join_prompt_line(exchange[last_user]))
1198
- Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
1212
+ # An image-only turn's text names its images already.
1213
+ unless ImageRef.placeholder_only?(exchange[last_user][:content])
1214
+ Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
1215
+ end
1199
1216
  from = messages.index { |m| m.equal?(exchange[last_user]) }
1200
1217
  render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
1201
1218
  failed = join_failed_record(exchange[last_user])
@@ -1244,7 +1261,7 @@ module Samagotchi
1244
1261
  # machine's state dir, as #attach_images): none for a prompt with no
1245
1262
  # turn id (an older session) or a session with no analytics.
1246
1263
  def join_tool_records(prompt)
1247
- SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id), prompt[:turn_id] || prompt["turn_id"])
1264
+ SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id, state_dir: @state_dir), prompt[:turn_id] || prompt["turn_id"])
1248
1265
  rescue Session::InvalidId
1249
1266
  []
1250
1267
  end
@@ -1254,7 +1271,7 @@ module Samagotchi
1254
1271
  # canceled or older turn — or one saved before the failure field —
1255
1272
  # draws its answer as before.
1256
1273
  def join_failed_record(prompt)
1257
- record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id),
1274
+ record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id, state_dir: @state_dir),
1258
1275
  prompt[:turn_id] || prompt["turn_id"])
1259
1276
  return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
1260
1277
 
@@ -363,7 +363,7 @@ module Samagotchi
363
363
 
364
364
  # A cancel's reason as the UIs name it (anything else as it is);
365
365
  # spec/shared/labels_matrix.json pins it for both.
366
- CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
366
+ CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped" }.freeze
367
367
  # A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
368
368
  # not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
369
369
  STOP_MARK = "■"
@@ -121,6 +121,7 @@ module Samagotchi
121
121
 
122
122
  claim_session!(session_id) if session_id
123
123
  @resume_session = session_id ? Session.load(session_id) : nil
124
+ drop_dead_question(@resume_session) if @resume_session
124
125
  if @resume_session
125
126
  # --model overrides resumed session's model (runtime only, default unchanged)
126
127
  if flag_model_name
@@ -186,7 +187,6 @@ module Samagotchi
186
187
  memories: @requested_memories,
187
188
  muted_memories: @muted_memory_names,
188
189
  appended_prompt_files: @appended_prompt_files,
189
- recap: scratch ? nil : recap_config,
190
190
  scratch: scratch,
191
191
  reminders: {
192
192
  callback: lambda { |due_names|
@@ -249,7 +249,8 @@ module Samagotchi
249
249
  # post-turn conversation.
250
250
  # @return [Symbol, nil] :turn_failed when lines came from a pipe and a
251
251
  # turn failed, :empty_answer when a -p --non-interactive turn ended
252
- # with no answer (bin/chi exits 1 on both), :interrupted when Ctrl-C
252
+ # with no answer, :iteration_limit when the iteration limit cut that
253
+ # turn short (bin/chi exits 1 on all three), :interrupted when Ctrl-C
253
254
  # cancelled that turn (bin/chi exits 130)
254
255
  def run
255
256
  # --non-interactive with no --prompt is a harmless no-op exit: build
@@ -309,12 +310,13 @@ module Samagotchi
309
310
  # Retry lines and hook notices on stderr; stdout gets the answer below.
310
311
  sink = OneShotSink.new
311
312
  save_before_first_turn(session)
313
+ max_iterations = IterationLimit.for(no_interrupt: true)
312
314
  begin
313
315
  result = @engine.run_turn(
314
316
  session,
315
317
  one_shot_prompt,
316
318
  on_event: sink,
317
- max_iterations: IterationLimit.for(no_interrupt: true),
319
+ max_iterations: max_iterations,
318
320
  cancel_controller: nil,
319
321
  images: ImageInput.extract(one_shot_prompt)
320
322
  )
@@ -337,6 +339,11 @@ module Samagotchi
337
339
  session.save
338
340
  # The after_turn hooks' notices, after the answer as the REPL shows them.
339
341
  sink.flush
342
+ # The limit cut the turn short: what came so far is no answer, so say
343
+ # so and fail (bin/chi exits 1), as the REPL's "iteration limit
344
+ # reached" line does there.
345
+ return one_shot_exhausted(session, max_iterations) if result.resumable?
346
+
340
347
  return
341
348
  end
342
349
 
@@ -390,6 +397,16 @@ module Samagotchi
390
397
  :turn_failed
391
398
  end
392
399
 
400
+ # A -p --non-interactive turn the iteration limit cut short: a line on
401
+ # stderr, with how to go on unless the session goes with this run.
402
+ # @return [Symbol] :iteration_limit
403
+ def one_shot_exhausted(session, max_iterations)
404
+ line = "chi: the iteration limit (#{max_iterations}) was reached before the turn finished"
405
+ line += "; continue it with: chi --resume #{session.id}" unless @scratch
406
+ warn line
407
+ :iteration_limit
408
+ end
409
+
393
410
  # `chi -p "/models" --non-interactive`: the command's output; a refused
394
411
  # one fails the run (bin/chi exits 1). The session is saved only when
395
412
  # the command changed it. Nobody is there to answer a confirm: a
@@ -518,6 +535,17 @@ module Samagotchi
518
535
  @pending_card_handle = @engine.subscribe(observer: pending)
519
536
  end
520
537
 
538
+ # A question the session's last owner saved and died with (a worker's,
539
+ # or another REPL's): this TUI owns the session now, so nobody waits on
540
+ # it. Dropped and saved, as the worker does (Worker#drop_dead_question).
541
+ def drop_dead_question(session)
542
+ id = session.drop_dead_question
543
+ return unless id
544
+
545
+ Log.info(:repl, "dead_question_dropped", id: id)
546
+ session.save
547
+ end
548
+
521
549
  # Take the session's OwnerLock for this process's lifetime: the TUI runs
522
550
  # its own Engine, so no worker may run the session meanwhile.
523
551
  # @raise [SessionBusy] when a worker or another TUI owns it
@@ -885,12 +913,35 @@ module Samagotchi
885
913
  end
886
914
 
887
915
  # The REPL keeps the kernel's conversation as-is (no [No response]
888
- # placeholder), so /continue resumes from the tool results.
889
- session.messages = result.conversation
916
+ # placeholder), so /continue resumes from the tool results; the
917
+ # answer keeps what the after_turn hooks stored on it.
918
+ session.messages = with_answer_display(result.conversation, session.messages)
890
919
  @turn_flow.after_turn(result, continue: continue)
891
920
  save_session(session)
892
921
  end
893
922
 
923
+ # The answer's display keys (Engine#store_answer_display put them on the
924
+ # session's last message) carried onto +conversation+'s last message,
925
+ # when that is the same answer; +conversation+ as it is otherwise.
926
+ ANSWER_DISPLAY_KEYS = [AnswerDisplay::KEY, AnswerRefs::KEY].freeze
927
+ private_constant :ANSWER_DISPLAY_KEYS
928
+
929
+ def with_answer_display(conversation, stored)
930
+ answer = Array(conversation).last
931
+ kept = Array(stored).last
932
+ return conversation unless answer.is_a?(Hash) && kept.is_a?(Hash) && message_field(answer, :role).to_s == "model"
933
+ return conversation unless message_field(kept, :role).to_s == "model" &&
934
+ message_field(kept, :content) == message_field(answer, :content)
935
+
936
+ extra = ANSWER_DISPLAY_KEYS.each_with_object({}) do |key, found|
937
+ value = message_field(kept, key)
938
+ found[key] = value unless value.nil?
939
+ end
940
+ extra.empty? ? conversation : conversation[0...-1] + [answer.merge(extra)]
941
+ end
942
+
943
+ def message_field(message, key) = message.key?(key) ? message[key] : message[key.to_s]
944
+
894
945
  def save_session(session)
895
946
  @engine.store_model!(session)
896
947
  session.save
@@ -970,7 +1021,7 @@ module Samagotchi
970
1021
  end
971
1022
  canceled = result&.canceled?
972
1023
  if result && !canceled
973
- session.messages = result.conversation if result.conversation.is_a?(Array)
1024
+ session.messages = with_answer_display(result.conversation, session.messages) if result.conversation.is_a?(Array)
974
1025
  @engine.store_model!(session)
975
1026
  session.save
976
1027
  end
@@ -1336,14 +1387,6 @@ module Samagotchi
1336
1387
  Log.debug(:repl, "children_counts_failed", error: e.class.name)
1337
1388
  end
1338
1389
 
1339
- # Resolve the recap config (on by default). Returns false when explicitly
1340
- # disabled, nil when nothing is configured, or a Hash when enabled so the
1341
- # Engine can build the detector. Single precedence path via the Config
1342
- # registry: CLI > ENV (SAMAGOTCHI_RECAP_*) > file (recap:) > default.
1343
- def recap_config
1344
- ConfigFile.recap_config
1345
- end
1346
-
1347
1390
  # Reset the Engine's shared inactivity clock when a prompt opens (a
1348
1391
  # Reline.pre_input_hook) and on each key typed (RelineSeam.key_handler). This is the
1349
1392
  # single seam the Engine-owned idle recap detector reads, so the idle clock
@@ -211,12 +211,16 @@ module Samagotchi
211
211
  # "stopped" for a
212
212
  # task_wait the user's Stop ended (the task itself runs on), for one
213
213
  # whose task was stopped (the stop-task button, or the model's task_stop),
214
- # and for a command the user's Stop killed or kept from starting: a
215
- # cancel, not a failure (its text still starts "Error:" for the model).
214
+ # and for a command the user's Stop (or a signal to chi) killed or kept
215
+ # from starting: a cancel, not a failure (its text still starts "Error:"
216
+ # for the model).
216
217
  def tool_activity_status(result, tool_name = nil, command: nil)
217
218
  text = result.to_s
218
- return "stopped" if text == Tools::Execute::NOT_RUN_ON_STOP
219
- return "stopped" if tool_name == Tools::Execute::NAME && text.start_with?(Tools::Execute::STOPPED_BY_USER)
219
+ return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, Tools::Execute::NOT_RUN_ON_SIGNAL].include?(text)
220
+ if tool_name == Tools::Execute::NAME &&
221
+ text.start_with?(Tools::Execute::STOPPED_BY_USER, Tools::Execute::STOPPED_BY_SIGNAL)
222
+ return "stopped"
223
+ end
220
224
  return "error" if text.start_with?("Error:")
221
225
  # Still running, as a task: its output tail may hold any "exit: N".
222
226
  return "ok" if handed_over?(tool_name, text)
@@ -9,11 +9,11 @@ module Samagotchi
9
9
  # (max_tool_output_chars).
10
10
  module ToolResponse
11
11
  SEPARATOR = "\n\n---\n\n"
12
- # Saved with a result for the web's reload (a plugin tool's params line
13
- # and label, an edit/write's diff) and for the LLMContextView (each
12
+ # Saved with a result for the web's reload (a plugin tool's params line,
13
+ # label and row refs, an edit/write's diff) and for the LLMContextView (each
14
14
  # run's chi-owned id, ToolIds, and a strategy's edits by id,
15
15
  # LLMContextEdit); the prompt never renders them.
16
- SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_ids edits].freeze
16
+ SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_refs tool_ids edits].freeze
17
17
  # A run's "[name]" lead: the name, then a newline, a space or the end.
18
18
  LEAD = /\A\[([^\]\s]+)\](?:\n| |\z)/
19
19
 
@@ -60,7 +60,7 @@ module Samagotchi
60
60
  entry[:image_counts] = runs.map { |run| Array(run[:images]).size }
61
61
  end
62
62
  saved = { tool_params: runs.map { |run| run[:shown_params] }, tool_labels: runs.map { |run| run[:shown_label] },
63
- tool_diffs: runs.map { |run| run[:diff] } }
63
+ tool_diffs: runs.map { |run| run[:diff] }, tool_refs: runs.map { |run| run[:refs] } }
64
64
  saved.each { |key, values| entry[key] = values if values.any? }
65
65
  entry[:tool_ids] = ids
66
66
  entry
@@ -75,6 +75,7 @@ module Samagotchi
75
75
  entry[:tool_params] = run[:shown_params] if run[:shown_params]
76
76
  entry[:tool_labels] = run[:shown_label] if run[:shown_label]
77
77
  entry[:tool_diffs] = run[:diff] if run[:diff]
78
+ entry[:tool_refs] = run[:refs] if run[:refs]
78
79
  entry[:tool_ids] = ids
79
80
  entry
80
81
  end
@@ -16,13 +16,15 @@ module Samagotchi
16
16
  # tool_call_completed (ToolActivity.tool_activity_event builds that
17
17
  # activity itself, for the TUI and the metrics);
18
18
  # * EVENT_KEYS: top-level on both, so a UI that missed the start (a
19
- # replay gap) builds its row from the completed event.
19
+ # replay gap) builds its row from the completed event. +refs+ (a
20
+ # plugin tool's, RowRefs) is one, but ToolRunner adds it: #for has no
21
+ # plugins, so a reload reads the saved tool_refs instead.
20
22
  #
21
23
  # params and label stay where they are: their sources differ per path
22
24
  # (the registry's preview, a plugin's label, the saved shown params).
23
25
  module ToolRowFields
24
26
  ACTIVITY_KEYS = %i[title called_as].freeze
25
- EVENT_KEYS = %i[view ref].freeze
27
+ EVENT_KEYS = %i[view ref refs].freeze
26
28
  KEYS = (ACTIVITY_KEYS + EVENT_KEYS).freeze
27
29
 
28
30
  module_function
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "tool_activity"
4
4
  require_relative "tool_row_fields"
5
+ require_relative "row_refs"
5
6
  require_relative "guardrails"
6
7
  require_relative "vision_context"
7
8
  require_relative "log"
@@ -41,7 +42,8 @@ module Samagotchi
41
42
  # shown_params: the params line the live row showed, and shown_label:
42
43
  # its label ("chrome: screenshot"), only for a tool that isn't built in
43
44
  # (the loops save them with the result, so a reload without the plugin
44
- # shows the same row), and diff: what an edit/write changed in its
45
+ # shows the same row), refs: its row's refs (a plugin tool's refs:,
46
+ # RowRefs; saved likewise), and diff: what an edit/write changed in its
45
47
  # file (EditPreview.change; also on tool_call_completed, never in the
46
48
  # model's output)
47
49
  def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
@@ -55,9 +57,12 @@ module Samagotchi
55
57
  started = { type: :tool_call_started, iteration: iteration, call_count: call_count, call_index: call_index,
56
58
  tool: call[:name], call: call.dup, params: params }
57
59
  started[:label] = label if label
58
- # Its row fields (ToolRowFields: title, called_as, view, ref); the worker runs in
59
- # its session's working directory.
60
+ # Its row fields (ToolRowFields: title, called_as, view, ref, and a
61
+ # plugin tool's refs); the worker runs in its session's working
62
+ # directory.
60
63
  fields = ToolRowFields.for(call[:name], call, cwd: Dir.pwd)
64
+ refs = RowRefs.for(tools && !call[:name].nil? ? tools[call[:name]]&.refs : nil, call, cwd: session_cwd)
65
+ fields[:refs] = refs unless refs.empty?
61
66
  started.merge!(fields)
62
67
  emit(on_stream_event, started)
63
68
 
@@ -121,6 +126,7 @@ module Samagotchi
121
126
  run[:images] = images if images&.any?
122
127
  run[:shown_params] = params if params && plugin_tool?(call[:name])
123
128
  run[:shown_label] = label if label
129
+ run[:refs] = refs unless refs.empty?
124
130
  run[:diff] = diff if diff
125
131
  run
126
132
  end
@@ -249,6 +255,13 @@ module Samagotchi
249
255
  [result[:output], *notes].join("\n")
250
256
  end
251
257
 
258
+ # The session's folder (the Engine's, KernelLoop#session_cwd), else the
259
+ # process's: a bare kernel (specs) has none.
260
+ def session_cwd
261
+ cwd = @kernel.respond_to?(:session_cwd) && @kernel.session_cwd&.call
262
+ cwd.to_s.empty? ? Dir.pwd : cwd
263
+ end
264
+
252
265
  # The kernel's tools, for the activity line of a tool that isn't built in.
253
266
  def tools = @kernel.tools
254
267
 
@@ -77,7 +77,7 @@ module Samagotchi
77
77
  start_line: call[:start_line], end_line: call[:end_line])
78
78
  end,
79
79
  TaskCreate::NAME => lambda do |call, kctx|
80
- next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
80
+ next Execute.not_run(stop_reason_proc(kctx)) if cancelled_proc(kctx).call
81
81
 
82
82
  TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
83
83
  marker_env: task_marker_env(kctx, call[:env]))
@@ -87,7 +87,8 @@ module Samagotchi
87
87
  done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx))
88
88
  end,
89
89
  Execute::NAME => lambda do |call, kctx|
90
- Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), env: parent_env(kctx))
90
+ Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx), stop_reason: stop_reason_proc(kctx),
91
+ env: parent_env(kctx))
91
92
  end,
92
93
  RegisterReminder::NAME => lambda do |call, kctx|
93
94
  RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
@@ -163,6 +164,12 @@ module Samagotchi
163
164
  -> { !!peers&.cancelled? }
164
165
  end
165
166
 
167
+ # The running turn's stop reason (:signal, :manual, …), nil without one.
168
+ def stop_reason_proc(kctx)
169
+ peers = kctx.peers
170
+ -> { peers.respond_to?(:cancel_reason) ? peers.cancel_reason : nil }
171
+ end
172
+
166
173
  # @param command_description [Boolean] false: execute declares no
167
174
  # description parameter (execute.description off)
168
175
  # @param policy [String, nil] llm_context.policy, the sentence
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require_relative "../config"
5
+ require_relative "../log"
5
6
  require_relative "../paths"
6
7
  require_relative "../process_group"
7
8
  require_relative "output_guardrails"
@@ -27,6 +28,10 @@ module Samagotchi
27
28
  NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
28
29
  # The first words of a command the user's Stop killed.
29
30
  STOPPED_BY_USER = "Error: command stopped by the user"
31
+ # The same two when a signal to chi (a worker's TERM, HUP or INT)
32
+ # stopped the turn: its cancel reason is :signal.
33
+ NOT_RUN_ON_SIGNAL = "Error: not run, a signal to chi stopped the turn"
34
+ STOPPED_BY_SIGNAL = "Error: command stopped by a signal to chi"
30
35
  # The first words of a command killed at the limit in kill mode (hooks
31
36
  # match on it).
32
37
  TIMEOUT_PREFIX = "Error: command timed out after"
@@ -73,11 +78,13 @@ module Samagotchi
73
78
 
74
79
  # @param cancelled [#call] true once the user stopped the turn: the
75
80
  # command is killed (or not started) and its output so far returned
81
+ # @param stop_reason [#call] the stop's reason (:signal: a signal to
82
+ # chi, which the result then names instead of the user)
76
83
  # @param env [Hash, nil] added to the command's environment
77
84
  # @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
78
85
  # nil reads execute.on_timeout
79
- def self.call(command, cwd: nil, cancelled: -> { false }, env: nil, on_timeout: nil)
80
- return NOT_RUN_ON_STOP if cancelled.call
86
+ def self.call(command, cwd: nil, cancelled: -> { false }, stop_reason: -> {}, env: nil, on_timeout: nil)
87
+ return not_run(stop_reason) if cancelled.call
81
88
 
82
89
  command = command.strip
83
90
  resolved_cwd = resolve_cwd(cwd)
@@ -109,11 +116,17 @@ module Samagotchi
109
116
  # a long command, and return whatever it printed before it was killed.
110
117
  stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
111
118
  rescue CommandCancelled => e
112
- stopped_result("#{STOPPED_BY_USER} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
119
+ first = signal?(stop_reason) ? STOPPED_BY_SIGNAL : STOPPED_BY_USER
120
+ stopped_result("#{first} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
113
121
  rescue StandardError => e
114
122
  "Error: #{e.message}"
115
123
  end
116
124
 
125
+ # What a command the turn's stop kept from starting returns.
126
+ def self.not_run(stop_reason) = signal?(stop_reason) ? NOT_RUN_ON_SIGNAL : NOT_RUN_ON_STOP
127
+
128
+ def self.signal?(stop_reason) = stop_reason.call == :signal
129
+
117
130
  # execute.on_timeout, or the caller's: :task (the default) or :kill.
118
131
  def self.on_timeout_mode(value)
119
132
  value = Samagotchi::Config.get("execute.on_timeout") if value.nil? || value.to_s.strip.empty?
@@ -157,10 +170,14 @@ module Samagotchi
157
170
  exit_code_path = File.join(dir, TaskRuntime::EXIT_CODE_FILENAME)
158
171
  begin
159
172
  pid = spawn_logged(command, env: env, cwd: cwd, logs: logs, exit_code_path: exit_code_path)
160
- rescue StandardError
173
+ rescue StandardError => e
174
+ Log.info(:turn, "execute_spawn_failed", id: id, error: e.class.name)
161
175
  FileUtils.rm_rf(dir)
162
176
  raise
163
177
  end
178
+ # Never the command line or its output: a command may carry a
179
+ # secret, and its output is the tool result's.
180
+ Log.debug(:turn, "execute_started", id: id, pid: pid, timeout_sec: timeout_sec, on_timeout: on_timeout)
164
181
  wait_thr = Process.detach(pid)
165
182
  # A stop is done once the shell is reaped and nothing holds the logs:
166
183
  # a child that ignores TERM keeps them, so the stop goes on to KILL.
@@ -183,6 +200,7 @@ module Samagotchi
183
200
  timeout_sec: timeout_sec, dir: dir, logs: logs, exit_code_path: exit_code_path)
184
201
  if text
185
202
  handed_over = true
203
+ Log.info(:turn, "execute_handed_over", id: id, after_sec: timeout_sec)
186
204
  raise HandedOver, text
187
205
  end
188
206
  # Not adopted because it ended meanwhile: finished too.
@@ -193,10 +211,11 @@ module Samagotchi
193
211
  status = wait_thr.value
194
212
  rescue HandedOver
195
213
  raise
196
- rescue Exception # rubocop:disable Lint/RescueException -- kill the child, then re-raise
214
+ rescue Exception => e # rubocop:disable Lint/RescueException -- kill the child, then re-raise
197
215
  # An Interrupt (or anything else) mid-wait: the child is in its own
198
216
  # process group, so it never saw the SIGINT. Kill it.
199
217
  aborted = true
218
+ Log.info(:turn, "execute_aborted", id: id, error: e.class.name)
200
219
  terminate_process_tree(pid, stopped: stopped) if wait_thr.alive?
201
220
  raise
202
221
  ensure
@@ -218,9 +237,18 @@ module Samagotchi
218
237
  end
219
238
  end
220
239
 
221
- raise CommandTimedOut.new(stdout_text, stderr_text) if timed_out
222
- raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started) if was_cancelled
240
+ elapsed_ms = ((monotonic_time - started) * 1000).round
241
+ if timed_out
242
+ Log.info(:turn, "execute_timed_out", id: id, after_sec: timeout_sec)
243
+ raise CommandTimedOut.new(stdout_text, stderr_text)
244
+ end
245
+ if was_cancelled
246
+ Log.info(:turn, "execute_stopped", id: id, ms: elapsed_ms)
247
+ raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started)
248
+ end
223
249
 
250
+ Log.debug(:turn, "execute_finished", id: id, exit: status&.exitstatus, ms: elapsed_ms,
251
+ background: held_open || nil)
224
252
  [stdout_text, stderr_text, status, held_open]
225
253
  end
226
254
  private_class_method :run_command
@@ -336,6 +364,8 @@ module Samagotchi
336
364
  Thread.current.report_on_exception = false
337
365
  code = waiter.value&.exitstatus
338
366
  File.write(path, code.to_s) if code && !File.exist?(path) && Dir.exist?(File.dirname(path))
367
+ rescue StandardError => e
368
+ Log.debug(:turn, "execute_exit_code_failed", error: e.class.name, msg: e.message)
339
369
  end
340
370
  end
341
371
  private_class_method :write_missing_exit_code
@@ -10,11 +10,12 @@ module Samagotchi
10
10
  # can't host one), and the model the session runs on (model_ref: the
11
11
  # resolved ref, live after /model), and the session's own thinking level
12
12
  # (thinking: nil without one) and reasoning_history level
13
- # (reasoning_history: nil without one). Engine hands KernelLoop one that follows its current
13
+ # (reasoning_history: nil without one), and why the turn was canceled
14
+ # (cancel_reason: :signal, :manual, …; nil while it runs). Engine hands KernelLoop one that follows its current
14
15
  # session (Engine::PeerView); this plain one is for specs and a kernel
15
16
  # without an Engine.
16
17
  Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, :reasoning_history,
17
- keyword_init: true) do
18
+ :cancel_reason, keyword_init: true) do
18
19
  # @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
19
20
  def cancelled?
20
21
  cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
@@ -20,11 +20,13 @@ module Samagotchi
20
20
  # tool" when nil); ToolActivity knows the built-ins' own
21
21
  # @!attribute preview [#call, nil] call → the activity line's params
22
22
  # @!attribute targets [#call, nil] call → what guardrail rules match
23
+ # @!attribute refs [#call, nil] call → the row's refs (a plugin's
24
+ # refs:, RowRefs checks what it gives)
23
25
  # @!attribute source [String] "core", or the bundle that added it
24
26
  # @!attribute layer [Symbol, nil] the LLM context layer the tool
25
27
  # belongs to (LLMContextStrategy): offered only in a turn running
26
28
  # under it (forget_outputs, :forget); nil: always
27
- Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :source, :layer, keyword_init: true) do
29
+ Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :refs, :source, :layer, keyword_init: true) do
28
30
  def core? = source == "core"
29
31
 
30
32
  # Offered in a turn under +layers+ (the turn's LLM context layers).
@@ -36,11 +38,11 @@ module Samagotchi
36
38
  end
37
39
 
38
40
  # @return [Entry]
39
- def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, source: "core", layer: nil)
41
+ def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil)
40
42
  raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
41
43
 
42
44
  @entries[name] = Entry.new(name: name, schema: schema, handler: handler, label: label, preview: preview,
43
- targets: targets, source: source, layer: layer)
45
+ targets: targets, refs: refs, source: source, layer: layer)
44
46
  end
45
47
 
46
48
  # Remove a tool (a plugin's tool set changed after load).
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.61.0"
4
+ VERSION = "0.63.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end