samagotchi 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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -4,6 +4,7 @@ require "set"
4
4
  require_relative "event_renderer"
5
5
  require_relative "formatting"
6
6
  require_relative "attached_view"
7
+ require_relative "status_row"
7
8
  require_relative "input_support"
8
9
  require_relative "image_input"
9
10
  require_relative "line_reader"
@@ -31,19 +32,9 @@ module Samagotchi
31
32
  include Formatting
32
33
  include InputSupport
33
34
 
34
- STATS_COMMAND = "/stats"
35
- RECAP_COMMAND = "/recap"
36
35
  PROMPT = "> "
37
- # Detach and leave the worker up.
38
- DETACH_COMMANDS = %w[/detach].freeze
39
- # Detach and ask the worker to exit (bare `exit` too, as in the REPL).
40
- EXIT_COMMANDS = %w[/exit /quit].freeze
41
- # After an exit command: delete the session once the worker has gone.
42
- DELETE_FLAG = "--delete"
43
36
  # How long /exit --delete waits for the worker to let go.
44
37
  DELETE_WAIT = 10
45
- # Leave, and archive the session (hidden from the lists, kept for good).
46
- ARCHIVE_COMMAND = "/archive"
47
38
  # A second Ctrl-C at an empty idle prompt within this many seconds detaches.
48
39
  DETACH_WINDOW = 2.0
49
40
  DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
@@ -55,7 +46,6 @@ module Samagotchi
55
46
  "continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
56
47
  "reminders" => "reminders are set", "starting" => "the worker is still starting"
57
48
  }.freeze
58
- ROLLBACK_HINT = "partial progress kept in context; !rollback restores the pre-turn state"
59
49
  # How much of the last answer a join shows.
60
50
  JOIN_ANSWER_LINES = 12
61
51
  JOIN_ANSWER_CHARS = 1200
@@ -67,7 +57,8 @@ module Samagotchi
67
57
 
68
58
  # @return [String, nil] the model the worker's turns run on, as the
69
59
  # last command said
70
- attr_reader :model_name
60
+ # @return [String, nil] the worker's model
61
+ def model_name = @status[:model]
71
62
 
72
63
  # @return [QuestionPrompt, nil] the question waiting for an answer
73
64
  attr_reader :question
@@ -87,8 +78,12 @@ module Samagotchi
87
78
  # @param delete_session [#call] session id -> deletes it (/exit --delete)
88
79
  # @param archive_session [#call] session id -> archives it (/archive);
89
80
  # SessionManager.archive_session's result
81
+ # @param wait_at_eof [Boolean] lines come from a pipe or a file (`chi -p
82
+ # X </dev/null`): at their end, detach only once this run's prompts
83
+ # (the -p one too) have had their turns; if one failed, #run says so
90
84
  def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
91
- default_input: false, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
85
+ default_input: false, wait_at_eof: false,
86
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
92
87
  delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
93
88
  archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) })
94
89
  @client = client
@@ -116,20 +111,18 @@ module Samagotchi
116
111
  # Prompts this run sent, by enqueued_id: only those come back into
117
112
  # the input when their turn fails (a replayed event must not).
118
113
  @sent_ids = Set.new
114
+ @wait_at_eof = wait_at_eof
115
+ # Sent and not ended yet (by enqueued_id), the ones merged into the
116
+ # running turn, whether the input has ended, and whether one of this
117
+ # run's prompts failed (wait_at_eof only).
118
+ @open_ids = Set.new
119
+ @merged_ids = Set.new
120
+ @input_ended = false
121
+ @own_failed = false
119
122
  # A continue offer is pending: the prompt asks for the answer.
120
123
  @continue_offer = nil
121
- @model_name = nil
122
- @turn_continues = false
123
- # The idle status line's data (the REPL's segments).
124
- @memory_names = []
125
- # The session's --memory list (shown with the used memories until the
126
- # first turn records them) and its --mute list, from the join.
127
- @preloaded_names = []
128
- @muted_names = []
129
- # The session that delegated this one, shown as ↳ <short id>.
130
- @parent_id = nil
131
- @context_estimate = nil
132
- @status_rows = nil
124
+ # The status row: the worker's model, ctx, the session's memories.
125
+ @status = StatusRow.new(screen)
133
126
  end
134
127
 
135
128
  def running? = @running
@@ -137,8 +130,10 @@ module Samagotchi
137
130
  # Follow the session and read input until Ctrl-D, /detach, /exit or
138
131
  # the worker goes away. The worker keeps running after a detach; /exit
139
132
  # asks it to exit too, and it does unless something still needs it.
140
- # @return [Symbol] :detached, :closed when the worker went away, or
141
- # :failed when the first command (--model) didn't go through
133
+ # @return [Symbol] :detached, :closed when the worker went away,
134
+ # :failed when the first command (--model) didn't go through, or
135
+ # (wait_at_eof) :turn_failed when one of this run's prompts failed and
136
+ # :unanswered when a question came after the input ended
142
137
  # @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
143
138
  # Interrupt = Ctrl-C); defaults to Reline
144
139
  def run(input: nil)
@@ -158,8 +153,17 @@ module Samagotchi
158
153
  case kind
159
154
  when :event
160
155
  ended = safely_handle(payload)
161
- return ended if ended == :closed || ended == :failed
162
- when :line then return :detached if submit(payload) == :detach
156
+ return ended if %i[closed failed unanswered].include?(ended)
157
+ return end_of_input if @input_ended && !own_turns_pending?
158
+ when :line
159
+ if payload.nil? && @wait_at_eof && own_turns_pending?
160
+ # The pipe's end: this run's turns first. An open question
161
+ # has nothing left to answer it.
162
+ return unanswered_question if @question
163
+
164
+ next @input_ended = true
165
+ end
166
+ return end_of_input if submit(payload) == :detach
163
167
  when :interrupt then return :detached if interrupt(payload) == :detach
164
168
  end
165
169
  end
@@ -186,22 +190,19 @@ module Samagotchi
186
190
  return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
187
191
  when :turn_enqueued then show_enqueued(event)
188
192
  when :turn_started then start_turn(event)
189
- when :turn_completed then complete_turn(event)
190
- when :turn_canceled
191
- continued = @turn_continues
192
- end_turn("turn cancelled (#{event[:cancellation_reason]})")
193
- # A cancelled continue is back where it started; a prompt turn's
194
- # partial progress stays, as in the REPL.
195
- @screen.commit(ROLLBACK_HINT) unless continued
196
- when :turn_failed
197
- end_turn("turn failed: #{event[:summary] || "#{event[:message]} (#{event[:error_class]})"}")
193
+ when :turn_completed
194
+ complete_turn(event)
195
+ own_turn_ended(event)
196
+ # The renderer says how it ended (the REPL's words too).
197
+ when :turn_canceled, :turn_failed
198
+ @renderer.call(event)
199
+ end_turn
200
+ own_turn_ended(event)
198
201
  when :prompt_restored then restore_prompt(event)
199
202
  when :context_status
200
- @context_estimate = { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] }
201
- refresh_status
203
+ @status.update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
202
204
  when :used_memories_updated
203
- @memory_names = Array(event[:used_memory_names])
204
- refresh_status
205
+ @status.update(used_memories: Array(event[:used_memory_names]))
205
206
  # Another client's anytime command: its line now, before the cards
206
207
  # it shows (its command_ran comes when it's done).
207
208
  when :command_queued
@@ -217,8 +218,12 @@ module Samagotchi
217
218
  when :continue_resolved then continue_resolved(event)
218
219
  # The note comes with the kernel's :pending_input_merged (EventRenderer),
219
220
  # after the answer the merge follows.
220
- when :input_merged then nil
221
- when :question_requested then ask(event[:pending_question])
221
+ when :input_merged then merged_own_prompts(event)
222
+ when :question_requested
223
+ # Nothing is left to answer it with.
224
+ return unanswered_question if @input_ended
225
+
226
+ ask(event[:pending_question])
222
227
  when :question_answered then question_answered(event)
223
228
  when :question_cancelled
224
229
  close_question("(question cancelled)") if @question
@@ -269,23 +274,26 @@ module Samagotchi
269
274
 
270
275
  text = line.strip
271
276
  return answer_question(text) if @question
272
- # Before the continue offer: neither is an answer to it.
273
- return submit(nil) if DETACH_COMMANDS.include?(text.downcase)
274
- return exit_worker if exit_command?(text)
275
- return exit_and_delete if exit_command?(text.delete_suffix(DELETE_FLAG).rstrip) && text.end_with?(" #{DELETE_FLAG}")
276
- return exit_and_archive if text.casecmp?(ARCHIVE_COMMAND)
277
- command = text.split(/\s+/, 2).first
277
+
278
+ # The terminal's own commands (SessionCommands registers them, the
279
+ # REPL reads the same words). Before the continue offer: the
280
+ # leaving ones are no answer to it.
281
+ local = command_registry.lookup_local(text)&.id
282
+ return submit(nil) if local == :detach
283
+ return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
284
+ return exit_and_archive if local == :archive
285
+
278
286
  if @continue_offer
279
- return answer_continue(text) unless command_registry.command?(text) || [STATS_COMMAND, RECAP_COMMAND].include?(command)
287
+ return answer_continue(text) unless local || command_registry.command?(text)
280
288
 
281
289
  # Not an answer: the read left no echo, so show what ran.
282
290
  echo_answer(text)
283
291
  end
284
292
  return if text.empty?
285
293
 
286
- if command == STATS_COMMAND
294
+ if local == :stats
287
295
  show_stats
288
- elsif command == RECAP_COMMAND
296
+ elsif local == :recap
289
297
  show_recap
290
298
  elsif command_registry.command?(text)
291
299
  # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
@@ -314,8 +322,6 @@ module Samagotchi
314
322
  detach(exit_failed_line(e.message))
315
323
  end
316
324
 
317
- def exit_command?(text) = EXIT_COMMANDS.include?(text.downcase) || text.casecmp?("exit")
318
-
319
325
  # /exit --delete: the worker exits as for /exit, then the session is
320
326
  # deleted. When the worker stays up (another UI, queued input, ...),
321
327
  # nothing is deleted.
@@ -443,9 +449,8 @@ module Samagotchi
443
449
  return unless event[:model_name]
444
450
 
445
451
  # After the output, so the status row changes with the line saying why.
446
- @served_model = nil if @model_name != event[:model_name]
447
- @model_name = event[:model_name]
448
- refresh_status
452
+ served = @status[:model] == event[:model_name] ? @status[:served] : nil
453
+ @status.update(model: event[:model_name], default_model: default_model_name, served: served)
449
454
  end
450
455
 
451
456
  # Our command the worker couldn't run yet goes back into the prompt, for
@@ -498,21 +503,22 @@ module Samagotchi
498
503
 
499
504
  def send_prompt(text)
500
505
  persist_recent_history(text)
501
- # #memory shorthand becomes words the model reads, as in the REPL.
502
- prompt = normalize_model_input(text)
503
506
  images = attach_images(text)
504
- return if images.nil?
507
+ return own_prompt_failed if images.nil?
505
508
 
506
- options = { prompt: prompt, client_id: @client_id }
509
+ options = { prompt: text, client_id: @client_id }
507
510
  options[:no_interrupt] = true if @no_interrupt
508
511
  options[:images] = images unless images.empty?
509
512
  reply = @client.post_turn(**options)
510
513
  if reply.status == 202
511
514
  enqueued_id = reply.json&.fetch("enqueued_id", nil)
512
515
  @sent_ids << enqueued_id if enqueued_id
516
+ @open_ids << enqueued_id if enqueued_id && @wait_at_eof
513
517
  return
514
518
  end
515
519
 
520
+ own_prompt_failed
521
+
516
522
  detail = reply.json&.fetch("error", nil)
517
523
  # Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
518
524
  return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
@@ -520,9 +526,51 @@ module Samagotchi
520
526
  explained = reply.json&.fetch("detail", nil) if detail == "images_unsupported"
521
527
  @screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})#{": #{explained}" if explained}")
522
528
  rescue SystemCallError, IOError => e
529
+ own_prompt_failed
523
530
  @screen.commit("could not send the prompt (#{worker_down(e)})")
524
531
  end
525
532
 
533
+ # Whether the input's end must wait: the -p prompt isn't sent yet, or
534
+ # a prompt this run sent hasn't had its turn.
535
+ def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty?
536
+
537
+ # A turn ended: this run's prompt (and ours merged into it) had its turn.
538
+ def own_turn_ended(event)
539
+ return unless @wait_at_eof
540
+
541
+ id = (event[:origin] || {})[:enqueued_id]
542
+ ours = @open_ids.include?(id) || !@merged_ids.empty?
543
+ @own_failed = true if ours && event[:type] == :turn_failed
544
+ @open_ids.delete(id)
545
+ @open_ids.subtract(@merged_ids)
546
+ @merged_ids.clear
547
+ end
548
+
549
+ # Ours merged into the running turn: they end with it.
550
+ def merged_own_prompts(event)
551
+ Array(event[:origins]).each do |origin|
552
+ id = origin[:enqueued_id]
553
+ @merged_ids << id if own?(origin[:client_id]) && @open_ids.include?(id)
554
+ end
555
+ nil
556
+ end
557
+
558
+ def own_prompt_failed
559
+ @own_failed = true if @wait_at_eof
560
+ nil
561
+ end
562
+
563
+ # The input ended and nothing of ours is left to wait for.
564
+ def end_of_input
565
+ detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if @input_ended
566
+ @own_failed ? :turn_failed : :detached
567
+ end
568
+
569
+ def unanswered_question
570
+ detach("A question waits for an answer: chi --attach #{@client.session_id}")
571
+ :unanswered
572
+ end
573
+
526
574
  # The prompt's `@path` images, stored in the session's images/ here
527
575
  # (this machine, the worker's state dir) and sent as refs: the Bridge
528
576
  # never takes a path. nil (after a line saying why) when one can't be.
@@ -595,6 +643,13 @@ module Samagotchi
595
643
  # What was typed at the prompt waits for the question to close.
596
644
  @set_aside = @reader&.typed_text unless @question
597
645
  @question = QuestionPrompt.new(pending)
646
+ # An edit's diff goes above, into the scrollback, once per question
647
+ # (a join gets it from the snapshot and may get the event too).
648
+ unless @previewed_id == @question.id
649
+ @previewed_id = @question.id
650
+ preview = @question.preview_lines(paint: method(:paint))
651
+ @screen.commit(preview.join("\n")) unless preview.empty?
652
+ end
598
653
  # The prompt first, so the choices never show under the typed text.
599
654
  sync_prompt(keep_text: false)
600
655
  @screen.set_slot(:notes, @question.slot(paint: method(:paint)))
@@ -661,11 +716,13 @@ module Samagotchi
661
716
  def restore_prompt(event)
662
717
  origin = event[:origin] || {}
663
718
  return unless own?(origin[:client_id]) && @sent_ids.include?(origin[:enqueued_id])
719
+ # Lines from a pipe: nobody to retry it (the REPL's rule).
720
+ return if @wait_at_eof
664
721
 
665
722
  if @reader&.prefill(event[:prompt].to_s)
666
- @screen.commit("(prompt restored for retry)")
723
+ @screen.commit(turn_end_hint("prompt restored for retry"))
667
724
  else
668
- @screen.commit("(the failed prompt is in the input history: ↑)")
725
+ @screen.commit(turn_end_hint("the failed prompt is in the input history (↑)"))
669
726
  end
670
727
  end
671
728
 
@@ -907,8 +964,9 @@ module Samagotchi
907
964
  @joined_mid_turn = @running
908
965
  return unless turn
909
966
 
910
- @turn_continues = turn[:prompt].nil?
911
- unless @turn_continues && reminder_origin?(turn[:origin] || {})
967
+ continues = turn[:prompt].nil?
968
+ @renderer.turn_continues = continues
969
+ unless continues && reminder_origin?(turn[:origin] || {})
912
970
  @screen.commit(prompt_line(turn.dig(:origin, :client_id), turn[:prompt] || "(continuing)"))
913
971
  end
914
972
  Array(turn[:images]).each { |ref| @screen.commit(format_image_line(ref)) }
@@ -945,9 +1003,8 @@ module Samagotchi
945
1003
  def start_turn(event)
946
1004
  @running = true
947
1005
  @joined_mid_turn = false
948
- @turn_continues = event[:prompt].nil?
949
1006
  origin = event[:origin] || {}
950
- if @turn_continues
1007
+ if event[:prompt].nil?
951
1008
  # A continue turn (after the offer's yes) has no prompt to show; a
952
1009
  # reminder turn shows its reminders (:reminder_injected).
953
1010
  @screen.commit(prompt_line(origin[:client_id], "(continuing)")) unless reminder_origin?(origin)
@@ -965,16 +1022,13 @@ module Samagotchi
965
1022
  end
966
1023
  @renderer.call(event)
967
1024
  # The turn's summary carries its last context estimate.
968
- @context_estimate = @view.context_status if @view.context_status
969
- refresh_status
1025
+ @status.update(context: @view.context_status) if @view.context_status
970
1026
  @running = false
971
1027
  @joined_mid_turn = false
972
1028
  close_question("(the turn ended)") if @question
973
1029
  end
974
1030
 
975
- def end_turn(message)
976
- @view.finish_thinking_spinner
977
- @screen.commit(message)
1031
+ def end_turn
978
1032
  @running = false
979
1033
  @joined_mid_turn = false
980
1034
  close_question("(the turn ended)") if @question
@@ -985,43 +1039,22 @@ module Samagotchi
985
1039
  # The joining snapshot's session state: the worker's model and the
986
1040
  # memories the session used.
987
1041
  def take_session_state(state)
988
- @model_name = state[:model_name] if state[:model_name]
989
- @served_model = nil
990
- take_served_model(state[:served_model], state[:served_model_for], refresh: false)
991
- @memory_names = Array(state[:used_memory_names]) if state.key?(:used_memory_names)
992
- @preloaded_names = Array(state[:preloaded_memory_names]) if state.key?(:preloaded_memory_names)
993
- @muted_names = Array(state[:muted_memory_names]) if state.key?(:muted_memory_names)
994
- @parent_id = state[:parent_id] if state.key?(:parent_id)
1042
+ fields = { served: nil }
1043
+ fields.merge!(model: state[:model_name], default_model: default_model_name) if state[:model_name]
1044
+ fields[:served] = [state[:served_model], state[:served_model_for]] if state[:served_model]
1045
+ fields[:used_memories] = Array(state[:used_memory_names]) if state.key?(:used_memory_names)
1046
+ fields[:preloaded] = Array(state[:preloaded_memory_names]) if state.key?(:preloaded_memory_names)
1047
+ fields[:muted] = Array(state[:muted_memory_names]) if state.key?(:muted_memory_names)
1048
+ fields[:parent_id] = state[:parent_id] if state.key?(:parent_id)
995
1049
  # The last turn's ctx, so it shows before this client's first turn.
996
- @context_estimate = state[:context_status] if state[:context_status].is_a?(Hash)
997
- refresh_status
1050
+ fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
1051
+ @status.update(**fields)
998
1052
  end
999
1053
 
1000
- # The REPL's idle status line (model · ctx · memories) in the status
1001
- # row, redrawn when its text changes.
1002
- def refresh_status
1003
- return unless status_line_enabled?
1004
-
1005
- served, served_for = @served_model
1006
- segments = [@model_name ? status_model_text(@model_name, default_model_name, served: served, served_for: served_for) : "",
1007
- @parent_id ? "↳ #{@parent_id.to_s[0, 8]}" : "",
1008
- status_context_text(estimate: @context_estimate),
1009
- status_memory_text(@memory_names | @preloaded_names, MEMORY_STICKY_PREVIEW_LIMIT),
1010
- status_memory_text(@muted_names, MEMORY_STICKY_PREVIEW_LIMIT, label: "muted")].reject(&:empty?)
1011
- rows = status_rows(segments, @screen.columns - 1)
1012
- return if rows == @status_rows
1013
-
1014
- @status_rows = rows
1015
- rows.empty? ? @screen.clear_slot(:status) : @screen.set_slot(:status, rows)
1016
- end
1017
-
1018
- # What the worker's server said it served for a name (a generation, or
1019
- # the join's session state); dropped when /model switches.
1020
- def take_served_model(served, served_for, refresh: true)
1021
- return unless served
1022
-
1023
- @served_model = [served, served_for]
1024
- refresh_status if refresh
1054
+ # What the worker's server said it served for a name (a generation);
1055
+ # dropped when /model switches.
1056
+ def take_served_model(served, served_for)
1057
+ @status.update(served: [served, served_for]) if served
1025
1058
  end
1026
1059
 
1027
1060
  # The config's default model, as the worker's /model names it.
@@ -1063,7 +1096,7 @@ module Samagotchi
1063
1096
  params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
1064
1097
  status = part[:status].to_s
1065
1098
  "#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
1066
- "#{format_tool_image_suffix(part[:images])}"
1099
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
1067
1100
  end
1068
1101
  end
1069
1102
  end
@@ -8,13 +8,12 @@ require_relative "thinking_line"
8
8
 
9
9
  module Samagotchi
10
10
  class TerminalUI
11
- # EventRenderer's view in attached mode, drawn on a Screen: the
12
- # thinking feedback is the activity slot, one row right above the prompt
13
- # (spinner frame, then the running tool or the newest sentence of the
14
- # model's thinking or text, ThinkingLine),
15
- # and every finished line is committed output. The local REPL's
16
- # multi-line spinner redraws in place, which can't share the terminal
17
- # with an open prompt.
11
+ # EventRenderer's view in both TUIs (attached mode and the REPL), drawn
12
+ # on a Screen: the thinking feedback is the activity slot, one row right
13
+ # above the prompt (spinner frame, then the running tool or the newest
14
+ # sentence of the model's thinking or text, ThinkingLine), and every
15
+ # finished line is committed output. On a PlainSurface the activity
16
+ # slot is dropped.
18
17
  #
19
18
  # The spinner turns with time, not per chunk: while the slot is shown a
20
19
  # ticker thread redraws it every TICK_INTERVAL, so a turn that gets no
@@ -59,6 +58,16 @@ module Samagotchi
59
58
  @screen.commit(text)
60
59
  end
61
60
 
61
+ # Draw on +surface+ from now on (the REPL swaps in its live region
62
+ # after the view is built, and back when it ends): what the slot shows
63
+ # moves there.
64
+ def surface=(surface)
65
+ @lock.synchronize do
66
+ @screen = surface
67
+ redraw_status if status_text
68
+ end
69
+ end
70
+
62
71
  # A new turn: its tally starts over too.
63
72
  def reset_turn_feedback
64
73
  @lock.synchronize do
@@ -81,7 +90,7 @@ module Samagotchi
81
90
 
82
91
  def generation_feedback_retrying(event)
83
92
  @lock.synchronize do
84
- @retry = "retrying (attempt #{event[:attempt]})"
93
+ @retry = retry_text(event)
85
94
  # The retry streams from the start: wait for its first token again.
86
95
  @waiting_since = @clock.call if @thinking
87
96
  redraw_status
@@ -194,9 +203,6 @@ module Samagotchi
194
203
  @context_status = status if status
195
204
  end
196
205
 
197
- # The REPL's sticky memories line has no source in attached mode.
198
- def emit_active_memories_line; end
199
-
200
206
  private
201
207
 
202
208
  def reset_feedback_state
@@ -207,6 +213,15 @@ module Samagotchi
207
213
  @waiting_since = nil
208
214
  end
209
215
 
216
+ # "retrying (1/4 in 0.5s): Errno::ECONNREFUSED": the attempt of all
217
+ # there will be, the wait before it, and what failed.
218
+ def retry_text(event)
219
+ return "retrying (attempt #{event[:attempt]})" unless event[:max_retries]
220
+
221
+ text = "retrying (#{event[:attempt]}/#{event[:max_retries].to_i + 1} in #{format("%.1f", event[:next_delay].to_f)}s)"
222
+ event[:error_class].to_s.empty? ? text : "#{text}: #{event[:error_class]}"
223
+ end
224
+
210
225
  def tally_key(event)
211
226
  [event[:iteration].to_i, event[:call_index].to_i]
212
227
  end
@@ -246,7 +261,7 @@ module Samagotchi
246
261
  def status_text
247
262
  now = @clock.call
248
263
  frame = FRAMES[((now - @origin) / FRAME_INTERVAL).floor % FRAMES.length]
249
- return "#{frame} #{@retry}" if @retry
264
+ return "#{frame} #{paint(@retry, 90)}" if @retry
250
265
  return "#{frame} #{@tool}…" if @tool
251
266
  return "#{frame} #{@init_tasks.values.join(" · ")}…" if !@thinking && @init_tasks.any?
252
267
  return nil unless @thinking
@@ -1,16 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "set"
4
+ require_relative "formatting"
4
5
 
5
6
  module Samagotchi
6
7
  class TerminalUI
7
8
  # Renders one UI's view of Engine turn events: the thinking spinner while
8
9
  # the model generates, a `tool>` line per completed tool call, and the
9
10
  # answer (with any tool activity not already shown) from :turn_completed's
10
- # turn_summary. It is the `on_event:` sink the REPL hands Engine#run_turn.
11
+ # turn_summary, and how a turn ended. It is the `on_event:` sink the REPL
12
+ # hands Engine#run_turn, and renders what attached mode gets over the
13
+ # Bridge.
11
14
  #
12
- # Drawing is delegated to a +view+ (the TerminalUI), so the same dispatch
13
- # can later render events that arrive over the Bridge. The renderer keeps
15
+ # Drawing is delegated to a +view+ (an AttachedView in both TUIs). The
16
+ # renderer keeps
14
17
  # only per-turn bookkeeping that must come from the events themselves:
15
18
  # which tool lines were already streamed, and when each tool call started.
16
19
  # Events that came over the Bridge as JSON (string keys) are symbolized first.
@@ -22,8 +25,14 @@ module Samagotchi
22
25
  @tool_started_at = {}
23
26
  # Card ids shown so far (across turns): one shown again is updated.
24
27
  @card_ids = Set.new
28
+ @turn_continues = false
25
29
  end
26
30
 
31
+ # Whether the running turn continues an earlier one (a continue or a
32
+ # reminder turn): set from :turn_started, or by a UI that joined the
33
+ # turn mid-way. A canceled one gets no rollback hint.
34
+ attr_writer :turn_continues
35
+
27
36
  # Deep-convert a string-keyed event (JSON from the Bridge) to the local
28
37
  # shape: symbol keys and a symbol :type. Other values stay as they are.
29
38
  def self.symbolize(event)
@@ -45,6 +54,7 @@ module Samagotchi
45
54
  case event[:type]
46
55
  when :turn_started
47
56
  begin_turn
57
+ @turn_continues = event[:continue] ? true : event[:prompt].nil?
48
58
  Array(event[:images]).each { |ref| @view.print_line(@view.format_image_line(ref)) }
49
59
  when :generation_started
50
60
  @view.generation_feedback_started(event)
@@ -58,7 +68,8 @@ module Samagotchi
58
68
  when :tool_call_completed
59
69
  @view.clear_generation_retry
60
70
  @view.tool_call_feedback_completed(event)
61
- render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images])
71
+ render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images],
72
+ diff: event[:diff])
62
73
  when :generation_completed, :generation_cancelled, :tool_dispatch_started
63
74
  @view.generation_feedback_finished
64
75
  when :pending_input_merged
@@ -68,6 +79,15 @@ module Samagotchi
68
79
  @view.print_line(@view.format_empty_retry_line(event))
69
80
  when :turn_completed
70
81
  render_turn_summary(event[:turn_summary]) if event[:turn_summary]
82
+ when :turn_canceled
83
+ @view.finish_thinking_spinner
84
+ @view.print_line(@view.turn_canceled_line(event[:cancellation_reason], event[:duration_ms]))
85
+ @view.print_line(@view.turn_end_hint(Formatting::ROLLBACK_HINT)) unless @turn_continues
86
+ when :turn_failed
87
+ # A provider error's one-line summary, else the message (an image
88
+ # that couldn't be used).
89
+ @view.finish_thinking_spinner
90
+ @view.print_line(@view.turn_failed_line(event[:summary] || event[:message], event[:duration_ms]))
71
91
  when :guardrail_warning
72
92
  @view.print_line(self.class.load_warning_line(event))
73
93
  when :hook_notice
@@ -104,7 +124,8 @@ module Samagotchi
104
124
  # @param event [Hash] :hook_notice event or a snapshot part with
105
125
  # hook:, text:, level:
106
126
  def self.hook_notice_line(event)
107
- label = event[:hook].to_s[/\(bundle (.+)\)\z/, 1] || "hook"
127
+ # chi's own notices name themselves with a bare word (thinking).
128
+ label = event[:hook].to_s[/\(bundle (.+)\)\z/, 1] || event[:hook].to_s[/\A[a-z][a-z0-9-]*\z/] || "hook"
108
129
  text = event[:text].to_s
109
130
  text = "warning: #{text}" if event[:level].to_s == "warn"
110
131
  "#{label}> #{text}"
@@ -134,7 +155,7 @@ module Samagotchi
134
155
  end
135
156
 
136
157
  # Render a finished turn: tool activity the stream did not already show,
137
- # the status lines, the answer, and the iteration-limit notice.
158
+ # the answer, and the iteration-limit notice.
138
159
  # @param summary [Hash] Engine#turn_summary
139
160
  def render_turn_summary(summary)
140
161
  @view.finish_thinking_spinner
@@ -144,7 +165,6 @@ module Samagotchi
144
165
 
145
166
  @view.print_line(@view.format_tool_activity_line(activity))
146
167
  end
147
- @view.emit_active_memories_line
148
168
  @view.print_line(summary[:output])
149
169
  @view.print_line("iteration limit reached") if summary[:resumable]
150
170
  end
@@ -162,12 +182,13 @@ module Samagotchi
162
182
 
163
183
  private
164
184
 
165
- def render_streamed_tool_activity(activity, duration_ms:, images: nil)
185
+ def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
166
186
  return if activity.nil?
167
187
 
168
188
  @streamed_tool_activity[tool_activity_key(activity)] += 1
169
189
  line = @view.format_tool_activity_line(activity, duration_ms: duration_ms)
170
190
  line += @view.format_tool_image_suffix(images) if images&.any?
191
+ line += @view.format_tool_diff_suffix(diff) if diff
171
192
  @view.print_line(line)
172
193
  end
173
194