samagotchi 0.4.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +13 -2
  4. data/bin/chi +29 -39
  5. data/docs/cli.md +135 -73
  6. data/docs/configuration.md +15 -20
  7. data/docs/hooks.md +1 -1
  8. data/docs/memory.md +40 -0
  9. data/docs/plugins.md +50 -0
  10. data/docs/releasing.md +9 -6
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +16 -11
  16. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  17. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
  19. data/lib/samagotchi/bundles/system/identity.md +5 -0
  20. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  21. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  22. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  23. data/lib/samagotchi/client.rb +16 -20
  24. data/lib/samagotchi/config.rb +40 -100
  25. data/lib/samagotchi/engine.rb +50 -354
  26. data/lib/samagotchi/kernel_loop.rb +33 -44
  27. data/lib/samagotchi/live_versions.rb +7 -1
  28. data/lib/samagotchi/llm/errors.rb +17 -0
  29. data/lib/samagotchi/llm/http.rb +4 -18
  30. data/lib/samagotchi/llm/openai_chat.rb +17 -0
  31. data/lib/samagotchi/model_profile.rb +4 -10
  32. data/lib/samagotchi/note_command.rb +2 -1
  33. data/lib/samagotchi/reply_wait.rb +48 -4
  34. data/lib/samagotchi/self_report.rb +20 -2
  35. data/lib/samagotchi/send_command.rb +84 -6
  36. data/lib/samagotchi/session.rb +4 -2
  37. data/lib/samagotchi/session_manager.rb +18 -37
  38. data/lib/samagotchi/system_prompt.rb +403 -0
  39. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
  41. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
  43. data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
  44. data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
  45. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  46. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  47. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  48. data/lib/samagotchi/terminal_ui.rb +95 -690
  49. data/lib/samagotchi/thinking.rb +11 -0
  50. data/lib/samagotchi/tool_activity.rb +52 -2
  51. data/lib/samagotchi/tool_runner.rb +3 -0
  52. data/lib/samagotchi/tools/execute.rb +3 -3
  53. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  54. data/lib/samagotchi/tools/read.rb +4 -4
  55. data/lib/samagotchi/update_command.rb +2 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +170 -35
  58. data/lib/samagotchi/web/lan.rb +99 -0
  59. data/lib/samagotchi/web/message_parts.rb +15 -11
  60. data/lib/samagotchi/web/public/activity.js +7 -0
  61. data/lib/samagotchi/web/public/app.js +99 -54
  62. data/lib/samagotchi/web/public/chat_view.js +5 -1
  63. data/lib/samagotchi/web/public/index.html +163 -17
  64. data/lib/samagotchi/web/public/model_pick.js +136 -0
  65. data/lib/samagotchi/web/public/model_picker.js +224 -0
  66. data/lib/samagotchi/web/public/notify.js +10 -0
  67. data/lib/samagotchi/web/public/stage_model.js +110 -0
  68. data/lib/samagotchi/web/public/stage_view.js +580 -0
  69. data/lib/samagotchi/web/public/timing.js +6 -2
  70. data/lib/samagotchi/web/public/turn_events.js +9 -5
  71. data/lib/samagotchi/web/public/turn_model.js +11 -3
  72. data/lib/samagotchi/web/public/turn_view.js +74 -19
  73. data/lib/samagotchi/web/qr.rb +40 -0
  74. data/lib/samagotchi/web/server.rb +101 -11
  75. data/lib/samagotchi/web/token.rb +97 -0
  76. metadata +27 -3
  77. 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"
@@ -45,7 +46,6 @@ module Samagotchi
45
46
  "continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
46
47
  "reminders" => "reminders are set", "starting" => "the worker is still starting"
47
48
  }.freeze
48
- ROLLBACK_HINT = "partial progress kept in context; !rollback restores the pre-turn state"
49
49
  # How much of the last answer a join shows.
50
50
  JOIN_ANSWER_LINES = 12
51
51
  JOIN_ANSWER_CHARS = 1200
@@ -57,7 +57,8 @@ module Samagotchi
57
57
 
58
58
  # @return [String, nil] the model the worker's turns run on, as the
59
59
  # last command said
60
- attr_reader :model_name
60
+ # @return [String, nil] the worker's model
61
+ def model_name = @status[:model]
61
62
 
62
63
  # @return [QuestionPrompt, nil] the question waiting for an answer
63
64
  attr_reader :question
@@ -77,8 +78,12 @@ module Samagotchi
77
78
  # @param delete_session [#call] session id -> deletes it (/exit --delete)
78
79
  # @param archive_session [#call] session id -> archives it (/archive);
79
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
80
84
  def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
81
- 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) },
82
87
  delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
83
88
  archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) })
84
89
  @client = client
@@ -106,20 +111,18 @@ module Samagotchi
106
111
  # Prompts this run sent, by enqueued_id: only those come back into
107
112
  # the input when their turn fails (a replayed event must not).
108
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
109
122
  # A continue offer is pending: the prompt asks for the answer.
110
123
  @continue_offer = nil
111
- @model_name = nil
112
- @turn_continues = false
113
- # The idle status line's data (the REPL's segments).
114
- @memory_names = []
115
- # The session's --memory list (shown with the used memories until the
116
- # first turn records them) and its --mute list, from the join.
117
- @preloaded_names = []
118
- @muted_names = []
119
- # The session that delegated this one, shown as ↳ <short id>.
120
- @parent_id = nil
121
- @context_estimate = nil
122
- @status_rows = nil
124
+ # The status row: the worker's model, ctx, the session's memories.
125
+ @status = StatusRow.new(screen)
123
126
  end
124
127
 
125
128
  def running? = @running
@@ -127,8 +130,10 @@ module Samagotchi
127
130
  # Follow the session and read input until Ctrl-D, /detach, /exit or
128
131
  # the worker goes away. The worker keeps running after a detach; /exit
129
132
  # asks it to exit too, and it does unless something still needs it.
130
- # @return [Symbol] :detached, :closed when the worker went away, or
131
- # :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
132
137
  # @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
133
138
  # Interrupt = Ctrl-C); defaults to Reline
134
139
  def run(input: nil)
@@ -148,8 +153,17 @@ module Samagotchi
148
153
  case kind
149
154
  when :event
150
155
  ended = safely_handle(payload)
151
- return ended if ended == :closed || ended == :failed
152
- 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
153
167
  when :interrupt then return :detached if interrupt(payload) == :detach
154
168
  end
155
169
  end
@@ -176,22 +190,19 @@ module Samagotchi
176
190
  return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
177
191
  when :turn_enqueued then show_enqueued(event)
178
192
  when :turn_started then start_turn(event)
179
- when :turn_completed then complete_turn(event)
180
- when :turn_canceled
181
- continued = @turn_continues
182
- end_turn("turn cancelled (#{event[:cancellation_reason]})")
183
- # A cancelled continue is back where it started; a prompt turn's
184
- # partial progress stays, as in the REPL.
185
- @screen.commit(ROLLBACK_HINT) unless continued
186
- when :turn_failed
187
- 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)
188
201
  when :prompt_restored then restore_prompt(event)
189
202
  when :context_status
190
- @context_estimate = { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] }
191
- refresh_status
203
+ @status.update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
192
204
  when :used_memories_updated
193
- @memory_names = Array(event[:used_memory_names])
194
- refresh_status
205
+ @status.update(used_memories: Array(event[:used_memory_names]))
195
206
  # Another client's anytime command: its line now, before the cards
196
207
  # it shows (its command_ran comes when it's done).
197
208
  when :command_queued
@@ -207,8 +218,12 @@ module Samagotchi
207
218
  when :continue_resolved then continue_resolved(event)
208
219
  # The note comes with the kernel's :pending_input_merged (EventRenderer),
209
220
  # after the answer the merge follows.
210
- when :input_merged then nil
211
- 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])
212
227
  when :question_answered then question_answered(event)
213
228
  when :question_cancelled
214
229
  close_question("(question cancelled)") if @question
@@ -434,9 +449,8 @@ module Samagotchi
434
449
  return unless event[:model_name]
435
450
 
436
451
  # After the output, so the status row changes with the line saying why.
437
- @served_model = nil if @model_name != event[:model_name]
438
- @model_name = event[:model_name]
439
- 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)
440
454
  end
441
455
 
442
456
  # Our command the worker couldn't run yet goes back into the prompt, for
@@ -490,7 +504,7 @@ module Samagotchi
490
504
  def send_prompt(text)
491
505
  persist_recent_history(text)
492
506
  images = attach_images(text)
493
- return if images.nil?
507
+ return own_prompt_failed if images.nil?
494
508
 
495
509
  options = { prompt: text, client_id: @client_id }
496
510
  options[:no_interrupt] = true if @no_interrupt
@@ -499,9 +513,12 @@ module Samagotchi
499
513
  if reply.status == 202
500
514
  enqueued_id = reply.json&.fetch("enqueued_id", nil)
501
515
  @sent_ids << enqueued_id if enqueued_id
516
+ @open_ids << enqueued_id if enqueued_id && @wait_at_eof
502
517
  return
503
518
  end
504
519
 
520
+ own_prompt_failed
521
+
505
522
  detail = reply.json&.fetch("error", nil)
506
523
  # Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
507
524
  return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
@@ -509,9 +526,51 @@ module Samagotchi
509
526
  explained = reply.json&.fetch("detail", nil) if detail == "images_unsupported"
510
527
  @screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})#{": #{explained}" if explained}")
511
528
  rescue SystemCallError, IOError => e
529
+ own_prompt_failed
512
530
  @screen.commit("could not send the prompt (#{worker_down(e)})")
513
531
  end
514
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
+
515
574
  # The prompt's `@path` images, stored in the session's images/ here
516
575
  # (this machine, the worker's state dir) and sent as refs: the Bridge
517
576
  # never takes a path. nil (after a line saying why) when one can't be.
@@ -657,11 +716,13 @@ module Samagotchi
657
716
  def restore_prompt(event)
658
717
  origin = event[:origin] || {}
659
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
660
721
 
661
722
  if @reader&.prefill(event[:prompt].to_s)
662
- @screen.commit("(prompt restored for retry)")
723
+ @screen.commit(turn_end_hint("prompt restored for retry"))
663
724
  else
664
- @screen.commit("(the failed prompt is in the input history: ↑)")
725
+ @screen.commit(turn_end_hint("the failed prompt is in the input history (↑)"))
665
726
  end
666
727
  end
667
728
 
@@ -903,8 +964,9 @@ module Samagotchi
903
964
  @joined_mid_turn = @running
904
965
  return unless turn
905
966
 
906
- @turn_continues = turn[:prompt].nil?
907
- unless @turn_continues && reminder_origin?(turn[:origin] || {})
967
+ continues = turn[:prompt].nil?
968
+ @renderer.turn_continues = continues
969
+ unless continues && reminder_origin?(turn[:origin] || {})
908
970
  @screen.commit(prompt_line(turn.dig(:origin, :client_id), turn[:prompt] || "(continuing)"))
909
971
  end
910
972
  Array(turn[:images]).each { |ref| @screen.commit(format_image_line(ref)) }
@@ -941,9 +1003,8 @@ module Samagotchi
941
1003
  def start_turn(event)
942
1004
  @running = true
943
1005
  @joined_mid_turn = false
944
- @turn_continues = event[:prompt].nil?
945
1006
  origin = event[:origin] || {}
946
- if @turn_continues
1007
+ if event[:prompt].nil?
947
1008
  # A continue turn (after the offer's yes) has no prompt to show; a
948
1009
  # reminder turn shows its reminders (:reminder_injected).
949
1010
  @screen.commit(prompt_line(origin[:client_id], "(continuing)")) unless reminder_origin?(origin)
@@ -961,16 +1022,13 @@ module Samagotchi
961
1022
  end
962
1023
  @renderer.call(event)
963
1024
  # The turn's summary carries its last context estimate.
964
- @context_estimate = @view.context_status if @view.context_status
965
- refresh_status
1025
+ @status.update(context: @view.context_status) if @view.context_status
966
1026
  @running = false
967
1027
  @joined_mid_turn = false
968
1028
  close_question("(the turn ended)") if @question
969
1029
  end
970
1030
 
971
- def end_turn(message)
972
- @view.finish_thinking_spinner
973
- @screen.commit(message)
1031
+ def end_turn
974
1032
  @running = false
975
1033
  @joined_mid_turn = false
976
1034
  close_question("(the turn ended)") if @question
@@ -981,43 +1039,22 @@ module Samagotchi
981
1039
  # The joining snapshot's session state: the worker's model and the
982
1040
  # memories the session used.
983
1041
  def take_session_state(state)
984
- @model_name = state[:model_name] if state[:model_name]
985
- @served_model = nil
986
- take_served_model(state[:served_model], state[:served_model_for], refresh: false)
987
- @memory_names = Array(state[:used_memory_names]) if state.key?(:used_memory_names)
988
- @preloaded_names = Array(state[:preloaded_memory_names]) if state.key?(:preloaded_memory_names)
989
- @muted_names = Array(state[:muted_memory_names]) if state.key?(:muted_memory_names)
990
- @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)
991
1049
  # The last turn's ctx, so it shows before this client's first turn.
992
- @context_estimate = state[:context_status] if state[:context_status].is_a?(Hash)
993
- refresh_status
1050
+ fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
1051
+ @status.update(**fields)
994
1052
  end
995
1053
 
996
- # The REPL's idle status line (model · ctx · memories) in the status
997
- # row, redrawn when its text changes.
998
- def refresh_status
999
- return unless status_line_enabled?
1000
-
1001
- served, served_for = @served_model
1002
- segments = [@model_name ? status_model_text(@model_name, default_model_name, served: served, served_for: served_for) : "",
1003
- @parent_id ? "↳ #{@parent_id.to_s[0, 8]}" : "",
1004
- status_context_text(estimate: @context_estimate),
1005
- status_memory_text(@memory_names | @preloaded_names, MEMORY_STICKY_PREVIEW_LIMIT),
1006
- status_memory_text(@muted_names, MEMORY_STICKY_PREVIEW_LIMIT, label: "muted")].reject(&:empty?)
1007
- rows = status_rows(segments, @screen.columns - 1)
1008
- return if rows == @status_rows
1009
-
1010
- @status_rows = rows
1011
- rows.empty? ? @screen.clear_slot(:status) : @screen.set_slot(:status, rows)
1012
- end
1013
-
1014
- # What the worker's server said it served for a name (a generation, or
1015
- # the join's session state); dropped when /model switches.
1016
- def take_served_model(served, served_for, refresh: true)
1017
- return unless served
1018
-
1019
- @served_model = [served, served_for]
1020
- 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
1021
1058
  end
1022
1059
 
1023
1060
  # The config's default model, as the worker's /model names it.
@@ -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)
@@ -55,8 +65,6 @@ module Samagotchi
55
65
  when :tool_call_started
56
66
  @tool_started_at[tool_call_key(event)] = @clock.call
57
67
  @view.tool_call_feedback_started(event)
58
- when :used_memories_updated
59
- @view.used_memories_updated(event) if @view.respond_to?(:used_memories_updated)
60
68
  when :tool_call_completed
61
69
  @view.clear_generation_retry
62
70
  @view.tool_call_feedback_completed(event)
@@ -71,6 +79,15 @@ module Samagotchi
71
79
  @view.print_line(@view.format_empty_retry_line(event))
72
80
  when :turn_completed
73
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]))
74
91
  when :guardrail_warning
75
92
  @view.print_line(self.class.load_warning_line(event))
76
93
  when :hook_notice
@@ -138,7 +155,7 @@ module Samagotchi
138
155
  end
139
156
 
140
157
  # Render a finished turn: tool activity the stream did not already show,
141
- # the status lines, the answer, and the iteration-limit notice.
158
+ # the answer, and the iteration-limit notice.
142
159
  # @param summary [Hash] Engine#turn_summary
143
160
  def render_turn_summary(summary)
144
161
  @view.finish_thinking_spinner
@@ -148,7 +165,6 @@ module Samagotchi
148
165
 
149
166
  @view.print_line(@view.format_tool_activity_line(activity))
150
167
  end
151
- @view.emit_active_memories_line
152
168
  @view.print_line(summary[:output])
153
169
  @view.print_line("iteration limit reached") if summary[:resumable]
154
170
  end
@@ -135,6 +135,33 @@ module Samagotchi
135
135
  text.strip.empty? ? [] : lines
136
136
  end
137
137
 
138
+ # ── Turn ends: one set of words for the REPL, attached mode and the
139
+ # web (timing.js cancelLineText, format.js failedTurnText) ──────────
140
+
141
+ # A cancel's reason as the UIs name it (anything else as it is).
142
+ CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
143
+ # Under a canceled prompt turn (a canceled continue is back where it
144
+ # started): its partial progress stays in the conversation.
145
+ ROLLBACK_HINT = "partial progress kept; !rollback restores the pre-turn state"
146
+
147
+ # "✕ turn canceled (Ctrl-C) · 3.1s"
148
+ def turn_canceled_line(reason, duration_ms)
149
+ label = reason.to_s.empty? ? nil : CANCEL_REASONS.fetch(reason.to_s, reason.to_s)
150
+ "#{paint("✕", 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
151
+ end
152
+
153
+ # "✕ turn failed: <summary> · 2.0s"
154
+ def turn_failed_line(detail, duration_ms)
155
+ "#{paint("✕", 31)} turn failed: #{detail}#{turn_duration_suffix(duration_ms)}"
156
+ end
157
+
158
+ # A dim, indented line under a turn's end: what became of its prompt.
159
+ def turn_end_hint(text) = " #{paint(text, 90)}"
160
+
161
+ def turn_duration_suffix(duration_ms)
162
+ duration_ms.nil? ? "" : " · #{format_elapsed_duration(duration_ms)}"
163
+ end
164
+
138
165
  private
139
166
 
140
167
  # The recap shown on return: dim, one "recap>" block, noting how many
@@ -174,9 +201,11 @@ module Samagotchi
174
201
 
175
202
  # ── The status line (the REPL's, and attached mode's idle one) ────────
176
203
 
177
- # SAMAGOTCHI_STATUS_LINE=off hides it.
204
+ # status.line: off hides it.
205
+ STATUS_LINE_OFF = "off"
206
+
178
207
  def status_line_enabled?
179
- value = ENV.fetch(STATUS_LINE_ENV, STATUS_LINE_ON).to_s.strip.downcase
208
+ value = Samagotchi::Config.get("status.line").to_s.strip.downcase
180
209
  !(value.empty? || value == STATUS_LINE_OFF || value == "0" || value == "false")
181
210
  end
182
211
 
@@ -204,10 +233,8 @@ module Samagotchi
204
233
  "model=#{model} (default: #{default_model})"
205
234
  end
206
235
 
207
- # @param server [Hash, nil] the server's own numbers ({ctx_pct:, prompt_tokens:, ...})
208
236
  # @param estimate [Hash, nil] the kernel's estimate ({est_pct:, bucket:})
209
- def status_context_text(server: nil, estimate: nil)
210
- return format_server_context_segment(server) if server.is_a?(Hash)
237
+ def status_context_text(estimate: nil)
211
238
  return "" unless estimate.is_a?(Hash)
212
239
 
213
240
  pct = format("%.1f", estimate[:est_pct].to_f)
@@ -217,23 +244,6 @@ module Samagotchi
217
244
  "ctx=#{pct}% (#{bucket})"
218
245
  end
219
246
 
220
- def format_server_context_segment(status)
221
- pct = status[:ctx_pct]
222
- base = pct ? "ctx=#{format('%.1f', pct)}%" : "ctx=srv"
223
-
224
- tokens = []
225
- prompt_tokens = status[:prompt_tokens]
226
- completion_tokens = status[:completion_tokens]
227
- total_tokens = status[:total_tokens]
228
- tokens << "p=#{prompt_tokens}" if prompt_tokens
229
- tokens << "c=#{completion_tokens}" if completion_tokens
230
- tokens << "t=#{total_tokens}" if total_tokens
231
-
232
- return base if tokens.empty?
233
-
234
- "#{base} (#{tokens.join(' ')})"
235
- end
236
-
237
247
  # @param label [String] "mem" for the used memories, "muted" for the
238
248
  # session's --mute list
239
249
  def status_memory_text(names, limit, label: "mem")
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require "reline"
6
6
 
7
+ require_relative "../config"
7
8
  require_relative "../memory_paths"
8
9
  require_relative "../tools/memory"
9
10
  require_relative "../session_commands"
@@ -19,12 +20,10 @@ module Samagotchi
19
20
  # Included for private use; it keeps state in @next_input_prefill and
20
21
  # reads @no_default_input.
21
22
  module InputSupport
22
- PROMPT_HISTORY_ENV = "SAMAGOTCHI_HISTORY_FILE"
23
23
  XDG_STATE_HOME_ENV = "XDG_STATE_HOME"
24
24
  PROMPT_HISTORY_FILE = "history.json"
25
25
  PROMPT_HISTORY_STATE_DIR = "samagotchi"
26
26
  PROMPT_HISTORY_LIMIT = 20
27
- DEFAULT_INPUT_ENV = "SAMAGOTCHI_DEFAULT_INPUT"
28
27
  AT_PATH_COMPLETION_PREFIX = "@"
29
28
  MEMORY_COMPLETION_PREFIX = "#"
30
29
  AT_PATH_COMPLETION_MAX_CANDIDATES = 200
@@ -205,7 +204,7 @@ module Samagotchi
205
204
  end
206
205
 
207
206
  def history_file_path
208
- explicit = ENV[PROMPT_HISTORY_ENV].to_s.strip
207
+ explicit = Samagotchi::Config.get("history.file").to_s.strip
209
208
  return explicit unless explicit.empty?
210
209
 
211
210
  File.join(xdg_state_home, PROMPT_HISTORY_STATE_DIR, PROMPT_HISTORY_FILE)
@@ -273,7 +272,7 @@ module Samagotchi
273
272
  def default_input_text
274
273
  return nil unless default_input_wanted?
275
274
 
276
- default = ENV.fetch(DEFAULT_INPUT_ENV, nil)
275
+ default = Samagotchi::Config.get("default.input")
277
276
  return nil if default.nil? || default.strip.empty?
278
277
 
279
278
  default