samagotchi 0.9.0 → 0.10.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +3 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +34 -12
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +29 -29
  11. data/lib/samagotchi/bridge/card_store.rb +4 -4
  12. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  15. data/lib/samagotchi/bridge.rb +59 -112
  16. data/lib/samagotchi/bridge_client.rb +11 -28
  17. data/lib/samagotchi/bundle_command.rb +158 -212
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  20. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  21. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  26. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
  28. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  29. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/cli/command.rb +51 -0
  33. data/lib/samagotchi/cli/flags.rb +116 -0
  34. data/lib/samagotchi/config.rb +70 -55
  35. data/lib/samagotchi/config_text_edit.rb +107 -0
  36. data/lib/samagotchi/desktop_command.rb +24 -18
  37. data/lib/samagotchi/edit_preview.rb +2 -2
  38. data/lib/samagotchi/engine.rb +20 -75
  39. data/lib/samagotchi/events.rb +23 -0
  40. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  41. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  42. data/lib/samagotchi/hooks/loader.rb +23 -33
  43. data/lib/samagotchi/hooks/registry.rb +41 -0
  44. data/lib/samagotchi/hooks.rb +0 -3
  45. data/lib/samagotchi/host_registry.rb +15 -0
  46. data/lib/samagotchi/idle_reminders.rb +0 -7
  47. data/lib/samagotchi/image_store.rb +22 -0
  48. data/lib/samagotchi/kernel_loop.rb +12 -16
  49. data/lib/samagotchi/live_versions.rb +2 -13
  50. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  51. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  52. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  53. data/lib/samagotchi/memory_bundle/installer.rb +1 -27
  54. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  55. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  56. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  57. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  58. data/lib/samagotchi/memory_paths.rb +10 -0
  59. data/lib/samagotchi/note_command.rb +23 -37
  60. data/lib/samagotchi/owner_lock.rb +14 -3
  61. data/lib/samagotchi/plugin/api.rb +11 -17
  62. data/lib/samagotchi/plugin/context.rb +70 -15
  63. data/lib/samagotchi/plugin/loader.rb +2 -3
  64. data/lib/samagotchi/sampling_settings.rb +3 -5
  65. data/lib/samagotchi/self_report.rb +6 -9
  66. data/lib/samagotchi/send_command.rb +33 -52
  67. data/lib/samagotchi/session.rb +88 -92
  68. data/lib/samagotchi/session_archive_command.rb +8 -14
  69. data/lib/samagotchi/session_commands.rb +3 -21
  70. data/lib/samagotchi/session_delete_command.rb +15 -29
  71. data/lib/samagotchi/session_manager.rb +27 -13
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  74. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  75. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  76. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  77. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  78. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  79. data/lib/samagotchi/terminal_ui.rb +156 -234
  80. data/lib/samagotchi/thinking.rb +2 -3
  81. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  82. data/lib/samagotchi/tool_call_parser.rb +56 -292
  83. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/edit.rb +21 -29
  86. data/lib/samagotchi/tools/memory.rb +1 -11
  87. data/lib/samagotchi/tools/send_note.rb +1 -1
  88. data/lib/samagotchi/update_command.rb +32 -22
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/vision_support.rb +4 -5
  91. data/lib/samagotchi/web/app.rb +89 -151
  92. data/lib/samagotchi/web/public/activity.js +1 -11
  93. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  94. data/lib/samagotchi/web/public/app.js +158 -785
  95. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  96. data/lib/samagotchi/web/public/command_list.js +99 -0
  97. data/lib/samagotchi/web/public/data.js +22 -66
  98. data/lib/samagotchi/web/public/format.js +0 -23
  99. data/lib/samagotchi/web/public/index.html +0 -2
  100. data/lib/samagotchi/web/public/model_pick.js +0 -7
  101. data/lib/samagotchi/web/public/notifications.js +169 -0
  102. data/lib/samagotchi/web/public/question_cards.js +352 -0
  103. data/lib/samagotchi/web/public/session_state.js +34 -0
  104. data/lib/samagotchi/web/public/timing.js +1 -1
  105. data/lib/samagotchi/web/public/turn_events.js +11 -2
  106. data/lib/samagotchi/web/public/turn_model.js +0 -14
  107. data/lib/samagotchi/web/session_hub.rb +6 -4
  108. data/lib/samagotchi/web/session_summary.rb +5 -3
  109. data/lib/samagotchi/worker.rb +59 -53
  110. data/lib/samagotchi/worker_sidecar.rb +27 -1
  111. metadata +12 -1
@@ -10,6 +10,7 @@ require_relative "image_input"
10
10
  require_relative "line_reader"
11
11
  require_relative "question_prompt"
12
12
  require_relative "reline_seam"
13
+ require_relative "../bridge/turn_accumulator"
13
14
  require_relative "../bridge_client"
14
15
  require_relative "../log"
15
16
  require_relative "../context_note"
@@ -50,9 +51,6 @@ module Samagotchi
50
51
  JOIN_ANSWER_LINES = 12
51
52
  JOIN_ANSWER_CHARS = 1200
52
53
 
53
- # Prompt labels by the sender's client_id prefix.
54
- CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate" }.freeze
55
-
56
54
  attr_reader :client_id
57
55
 
58
56
  # @return [String, nil] the model the worker's turns run on, as the
@@ -199,10 +197,7 @@ module Samagotchi
199
197
  end_turn
200
198
  own_turn_ended(event)
201
199
  when :prompt_restored then restore_prompt(event)
202
- when :context_status
203
- @status.update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
204
- when :used_memories_updated
205
- @status.update(used_memories: Array(event[:used_memory_names]))
200
+ when :context_status, :used_memories_updated then @status.take_event(event)
206
201
  # Another client's anytime command: its line now, before the cards
207
202
  # it shows (its command_ran comes when it's done).
208
203
  when :command_queued
@@ -239,7 +234,7 @@ module Samagotchi
239
234
  line = EventRenderer.init_line(event)
240
235
  @screen.commit(line) if line
241
236
  when :generation_completed
242
- take_served_model(event[:served_model], event[:requested_model])
237
+ @status.take_event(event)
243
238
  @renderer.call(event)
244
239
  when :stream_closed
245
240
  @view.finish_thinking_spinner
@@ -968,41 +963,44 @@ module Samagotchi
968
963
  return unless turn
969
964
 
970
965
  continues = turn[:prompt].nil?
971
- @renderer.turn_continues = continues
972
966
  unless continues && reminder_origin?(turn[:origin] || {})
973
967
  @screen.commit(prompt_line(turn.dig(:origin, :client_id), turn[:prompt] || "(continuing)"))
974
968
  end
975
- Array(turn[:images]).each { |ref| @screen.commit(format_image_line(ref)) }
976
969
  tail = nil
977
970
  lane = :writing
978
971
  running_tool = nil
979
- Array(turn[:parts]).each do |part|
980
- case part[:kind]
981
- when "thinking" then tail, lane = part[:text], :thinking
982
- when "text" then tail, lane = part[:text], :writing
983
- when "tool"
984
- if part[:status] == "running"
985
- running_tool = part[:tool]
986
- else
987
- @screen.commit(snapshot_tool_line(part))
988
- end
989
- when "input" then @screen.commit("input> #{part[:text]}")
990
- when "steer" then @screen.commit(format_steer_line(source: part[:source], text: part[:text]))
991
- when "reminder" then @screen.commit(reminder_line(part[:reminders]))
992
- when "notice" then replay_notice(part[:event])
972
+ Bridge::TurnAccumulator.replay_events(turn).each do |event|
973
+ case event[:type]
974
+ when :generation_chunk
975
+ tail, lane = event[:thinking].to_s.empty? ? [event[:text], :writing] : [event[:thinking], :thinking]
976
+ when :tool_call_started
977
+ running_tool = event[:tool]
978
+ @renderer.call(event)
979
+ when :tool_call_completed
980
+ running_tool = nil
981
+ replay_tool_completed(event)
982
+ when :merged_input then @screen.commit("input> #{event[:content]}")
983
+ when :reminder_injected then @screen.commit(reminder_line(event[:reminders]))
984
+ # The turn's start (its images), a plugin's steer, and its rows (a
985
+ # hook's notice, a retry's line), as they were drawn live.
986
+ # Questions are left out: the pending one is asked after the
987
+ # replay, answered ones showed no line.
988
+ when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry then @renderer.call(event)
993
989
  end
994
990
  end
995
991
  @view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
996
992
  ask(turn[:pending_question]) if turn[:pending_question]
997
993
  end
998
994
 
999
- # One of the running turn's rows (Bridge::TurnNotice), drawn as it was
1000
- # live: a hook's notice and a retry's line. Questions are left out:
1001
- # the pending one is asked after the replay, answered ones showed no
1002
- # line.
1003
- def replay_notice(event)
1004
- event = EventRenderer.symbolize(event || {})
1005
- @renderer.call(event) if %i[hook_notice empty_answer_retry].include?(event[:type])
995
+ # A finished tool call of the joined turn: the live row (action,
996
+ # duration). A snapshot from an older worker has no action: its row
997
+ # names the tool only.
998
+ def replay_tool_completed(event)
999
+ return @renderer.call({ duration_ms: nil }.merge(event)) if event.dig(:activity, :action)
1000
+
1001
+ activity = event[:activity] || {}
1002
+ @screen.commit(snapshot_tool_line({ tool: event[:tool], params: activity[:params], status: activity[:status],
1003
+ images: event[:images], diff: event[:diff] }))
1006
1004
  end
1007
1005
 
1008
1006
  def show_enqueued(event)
@@ -1051,22 +1049,7 @@ module Samagotchi
1051
1049
  # The joining snapshot's session state: the worker's model and the
1052
1050
  # memories the session used.
1053
1051
  def take_session_state(state)
1054
- fields = { served: nil }
1055
- fields.merge!(model: state[:model_name], default_model: default_model_name) if state[:model_name]
1056
- fields[:served] = [state[:served_model], state[:served_model_for]] if state[:served_model]
1057
- fields[:used_memories] = Array(state[:used_memory_names]) if state.key?(:used_memory_names)
1058
- fields[:preloaded] = Array(state[:preloaded_memory_names]) if state.key?(:preloaded_memory_names)
1059
- fields[:muted] = Array(state[:muted_memory_names]) if state.key?(:muted_memory_names)
1060
- fields[:parent_id] = state[:parent_id] if state.key?(:parent_id)
1061
- # The last turn's ctx, so it shows before this client's first turn.
1062
- fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
1063
- @status.update(**fields)
1064
- end
1065
-
1066
- # What the worker's server said it served for a name (a generation);
1067
- # dropped when /model switches.
1068
- def take_served_model(served, served_for)
1069
- @status.update(served: [served, served_for]) if served
1052
+ @status.take_state(state, default_model: (default_model_name if state[:model_name]))
1070
1053
  end
1071
1054
 
1072
1055
  # The config's default model, as the worker's /model names it.
@@ -1077,39 +1060,6 @@ module Samagotchi
1077
1060
  end
1078
1061
 
1079
1062
  def reminder_origin?(origin) = origin[:client_id].to_s.start_with?("system:")
1080
-
1081
- CONTEXT_NOTE_PREVIEW = 60
1082
-
1083
- # "note from slack: <first line>", cut to one line.
1084
- def context_note_line(label, text)
1085
- lines = text.to_s.strip.split("\n")
1086
- first = lines.first.to_s
1087
- if first.length > CONTEXT_NOTE_PREVIEW
1088
- first = "#{first[0, CONTEXT_NOTE_PREVIEW - 1]}…"
1089
- elsif lines.size > 1
1090
- first += " …"
1091
- end
1092
- paint("note from #{label || "?"}: #{first}", 2)
1093
- end
1094
-
1095
- def reminder_line(reminders)
1096
- names = Array(reminders).filter_map { |r| r.is_a?(Hash) ? r[:name] : r }
1097
- names.empty? ? "reminder" : "reminder: #{names.join(", ")}"
1098
- end
1099
-
1100
- def prompt_line(client_id, prompt)
1101
- label = client_id ? CLIENT_LABELS.fetch(client_id.to_s.split(":", 2).first, "user") : "user"
1102
- "#{paint("#{label}>", 35)} #{prompt}"
1103
- end
1104
-
1105
- # A tool call from the snapshot: it has no action text, only the tool.
1106
- def snapshot_tool_line(part)
1107
- params = part[:params].to_s.strip
1108
- params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
1109
- status = part[:status].to_s
1110
- "#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
1111
- "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
1112
- end
1113
1063
  end
1114
1064
  end
1115
1065
  end
@@ -90,7 +90,7 @@ module Samagotchi
90
90
 
91
91
  def generation_feedback_retrying(event)
92
92
  @lock.synchronize do
93
- @retry = retry_text(event)
93
+ @retry = format_generation_retry_line(event)
94
94
  # The retry streams from the start: wait for its first token again.
95
95
  @waiting_since = @clock.call if @thinking
96
96
  redraw_status
@@ -213,15 +213,6 @@ module Samagotchi
213
213
  @waiting_since = nil
214
214
  end
215
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
-
225
216
  def tally_key(event)
226
217
  [event[:iteration].to_i, event[:call_index].to_i]
227
218
  end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatting"
4
+ require_relative "event_renderer"
5
+
6
+ module Samagotchi
7
+ class TerminalUI
8
+ # What the REPL prints between turns: cards and plugins' notices shown
9
+ # outside a turn, anytime commands' output, plugins' init lines and load
10
+ # warnings, and a recap written while idle.
11
+ #
12
+ # They are announced on other threads (the Engine's observers, the idle
13
+ # scheduler, an anytime command's own), where they are only kept; the
14
+ # main thread prints them at the open prompt (#flush_recap,
15
+ # #flush_cards), after the command that showed them. One that comes
16
+ # while a turn runs prints at once (above the live region) where the
17
+ # turn wouldn't print it itself.
18
+ class BetweenTurns
19
+ include Formatting
20
+
21
+ # A plugin's init task and a load warning: shown between turns too.
22
+ INIT_EVENTS = %i[plugin_init_started plugin_init_finished guardrail_warning].freeze
23
+
24
+ # @param surface [Surface] where lines print (swapped with #surface=)
25
+ # @param view [AttachedView] turns the activity row for init tasks
26
+ # @param renderer [EventRenderer] draws cards
27
+ # @param turn_running [#call] whether a turn runs now
28
+ # @param quiet [Boolean] a --non-interactive run: no init lines
29
+ def initialize(surface:, view:, renderer:, turn_running:, quiet: false)
30
+ @surface = surface
31
+ @view = view
32
+ @renderer = renderer
33
+ @turn_running = turn_running
34
+ @quiet = quiet
35
+ @cards = Queue.new
36
+ @recap = nil
37
+ end
38
+
39
+ attr_writer :surface
40
+
41
+ # The Engine's observer for cards, notices and init events (any
42
+ # thread). A card or a plugin's notice shown outside a turn is kept
43
+ # for the next flush; one shown during a turn is a turn event, the
44
+ # turn's sink prints it where it happens (EventRenderer).
45
+ #
46
+ # An anytime command's (event[:anytime]) print as it shows them: on
47
+ # the main thread (the command runs at the prompt) or beside a running
48
+ # turn, else at the next flush.
49
+ #
50
+ # A plugin's init task (chi.init) turns the activity row while it runs
51
+ # and prints a line when it is done; a load warning announced before
52
+ # the first turn, one. Beside a running turn they print at once.
53
+ def observe(event)
54
+ if INIT_EVENTS.include?(event[:type])
55
+ return if @quiet
56
+ return @view.init_started(event) if event[:type] == :plugin_init_started
57
+
58
+ @view.init_finished(event) if event[:type] == :plugin_init_finished
59
+ return show(event) if turn_running?
60
+
61
+ return @cards << event
62
+ end
63
+ return unless (event[:type] == :card && !event[:in_turn]) || (event[:type] == :hook_notice && event[:between_turns])
64
+ return show(event) if event[:anytime] && (Thread.current == Thread.main || turn_running?)
65
+
66
+ @cards << event
67
+ end
68
+
69
+ # Keep an item for the next flush (any thread).
70
+ def keep(item)
71
+ @cards << item
72
+ end
73
+
74
+ # An anytime command's output: beside a running turn at once, else at
75
+ # the next flush.
76
+ def show_or_keep(item)
77
+ turn_running? ? show(item) : keep(item)
78
+ end
79
+
80
+ # The Engine's observer for :recap_ready (the idle scheduler's thread):
81
+ # kept for #flush_recap. One collected just as a turn started describes
82
+ # the chat before it, and is dropped.
83
+ def take_recap(event)
84
+ return unless event[:type] == :recap_ready
85
+ return if event[:recap].to_s.strip.empty? || turn_running?
86
+
87
+ @recap = event[:recap].to_s
88
+ end
89
+
90
+ # Print a recap written while idle (main thread, at the open prompt).
91
+ def flush_recap
92
+ recap = @recap
93
+ return unless recap
94
+
95
+ @recap = nil
96
+ @surface.commit(recap_block(recap))
97
+ end
98
+
99
+ # Print the cards, notices and anytime commands' output kept since the
100
+ # last flush (main thread). A card replaced later in the same batch
101
+ # (the same id: btw's "thinking…", then its answer) prints once, as its
102
+ # last.
103
+ def flush_cards
104
+ items = []
105
+ loop { items << @cards.pop(true) }
106
+ rescue ThreadError
107
+ items.each_with_index do |item, index|
108
+ replaced = item[:type] == :card && items.drop(index + 1).any? { |later| later[:type] == :card && later[:id] == item[:id] }
109
+ show(item) unless replaced
110
+ end
111
+ nil
112
+ end
113
+
114
+ # A card, a notice, an anytime command's output (:command_output), a
115
+ # plugin init task's done line or a load warning.
116
+ def show(item)
117
+ case item[:type]
118
+ when :card then @renderer.render_card(item)
119
+ when :command_output then @surface.commit(item[:text])
120
+ when :guardrail_warning then @surface.commit(EventRenderer.load_warning_line(item))
121
+ when :plugin_init_finished
122
+ line = EventRenderer.init_line(item)
123
+ @surface.commit(line) if line
124
+ else @surface.commit(EventRenderer.hook_notice_line(item))
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ def turn_running? = @turn_running.call
131
+ end
132
+ end
133
+ end
@@ -68,7 +68,9 @@ module Samagotchi
68
68
  when :tool_call_completed
69
69
  @view.clear_generation_retry
70
70
  @view.tool_call_feedback_completed(event)
71
- render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images],
71
+ # A replayed row (a join) carries its duration (nil: unknown).
72
+ measured = tool_duration_ms(event)
73
+ render_streamed_tool_activity(event[:activity], duration_ms: event.fetch(:duration_ms, measured), images: event[:images],
72
74
  diff: event[:diff])
73
75
  when :generation_completed, :generation_cancelled, :tool_dispatch_started
74
76
  @view.generation_feedback_finished
@@ -44,6 +44,16 @@ module Samagotchi
44
44
  paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
45
45
  end
46
46
 
47
+ # "retrying (1/3 in 0.5s): Errno::ECONNREFUSED": the retry of all the
48
+ # retries there will be (as the web counts), the wait before it, and
49
+ # what failed (generation_retrying).
50
+ def format_generation_retry_line(event)
51
+ return "retrying (attempt #{event[:attempt]})" unless event[:max_retries]
52
+
53
+ text = "retrying (#{event[:attempt]}/#{event[:max_retries]} in #{format("%.1f", event[:next_delay].to_f)}s)"
54
+ event[:error_class].to_s.empty? ? text : "#{text}: #{event[:error_class]}"
55
+ end
56
+
47
57
  # " → image 1280×800" after a tool line whose tool read an image.
48
58
  def format_tool_image_suffix(images)
49
59
  refs = Array(images).map { |ref| ImageStore.symbolize(ref) }
@@ -61,6 +71,45 @@ module Samagotchi
61
71
  " #{paint("+#{get.(:added).to_i}", 32)} #{paint("\u2212#{get.(:removed).to_i}", 31)}"
62
72
  end
63
73
 
74
+ # Prompt labels by the sender's client_id prefix (turn_events.js
75
+ # CLIENT_LABELS; spec/shared/labels_matrix.json).
76
+ CLIENT_LABELS = { "web" => "web", "tui" => "tui", "system" => "reminder", "delegate" => "delegate" }.freeze
77
+
78
+ # "web> <prompt>": a prompt, labelled by who sent it.
79
+ def prompt_line(client_id, prompt)
80
+ label = client_id ? CLIENT_LABELS.fetch(client_id.to_s.split(":", 2).first, "user") : "user"
81
+ "#{paint("#{label}>", 35)} #{prompt}"
82
+ end
83
+
84
+ # "reminder: a, b": the reminders a turn runs for (names or hashes).
85
+ def reminder_line(reminders)
86
+ names = Array(reminders).filter_map { |r| r.is_a?(Hash) ? r[:name] : r }
87
+ names.empty? ? "reminder" : "reminder: #{names.join(", ")}"
88
+ end
89
+
90
+ CONTEXT_NOTE_PREVIEW = 60
91
+
92
+ # "note from slack: <first line>", cut to one line.
93
+ def context_note_line(label, text)
94
+ lines = text.to_s.strip.split("\n")
95
+ first = lines.first.to_s
96
+ if first.length > CONTEXT_NOTE_PREVIEW
97
+ first = "#{first[0, CONTEXT_NOTE_PREVIEW - 1]}\u2026"
98
+ elsif lines.size > 1
99
+ first += " \u2026"
100
+ end
101
+ paint("note from #{label || "?"}: #{first}", 2)
102
+ end
103
+
104
+ # A tool call from the snapshot: it has no action text, only the tool.
105
+ def snapshot_tool_line(part)
106
+ params = part[:params].to_s.strip
107
+ params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
108
+ status = part[:status].to_s
109
+ "#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
110
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
111
+ end
112
+
64
113
  # A card (Engine#show_card) as a framed block: the title and its
65
114
  # source, the body as wrapped plain text (no terminal markdown), and
66
115
  # one `→ <command>` line per action, the label after it when it says
@@ -140,7 +189,8 @@ module Samagotchi
140
189
  # ── Turn ends: one set of words for the REPL, attached mode and the
141
190
  # web (timing.js cancelLineText, format.js failedTurnText) ──────────
142
191
 
143
- # A cancel's reason as the UIs name it (anything else as it is).
192
+ # A cancel's reason as the UIs name it (anything else as it is);
193
+ # spec/shared/labels_matrix.json pins it for both.
144
194
  CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook" }.freeze
145
195
  # Under a canceled prompt turn (a canceled continue is back where it
146
196
  # started): its partial progress stays in the conversation.
@@ -52,6 +52,38 @@ module Samagotchi
52
52
  refresh
53
53
  end
54
54
 
55
+ # Take a session's state: the REPL's Engine#session_state_snapshot, or
56
+ # the one a worker sends an attached TUI when it joins. A key the state
57
+ # lacks (an older worker's) leaves its field as it was; the served pair
58
+ # is cleared without one, and the model is kept without a name.
59
+ # @param default_model [String, nil] the config's default, named beside
60
+ # another model
61
+ def take_state(state, default_model: nil)
62
+ fields = { served: state[:served_model] ? [state[:served_model], state[:served_model_for]] : nil }
63
+ fields.merge!(model: state[:model_name], default_model: default_model) if state[:model_name]
64
+ STATE_LISTS.each { |field, key| fields[field] = Array(state[key]) if state.key?(key) }
65
+ fields[:parent_id] = state[:parent_id] if state.key?(:parent_id)
66
+ # The last turn's ctx, so it shows before the next turn's.
67
+ fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
68
+ update(**fields)
69
+ end
70
+
71
+ # The memory lists' fields and the state keys they come from.
72
+ STATE_LISTS = { used_memories: :used_memory_names, preloaded: :preloaded_memory_names,
73
+ muted: :muted_memory_names }.freeze
74
+
75
+ # Take what a turn event says about the row (the Engine's events in
76
+ # the REPL, the Bridge's in attached mode): ctx, the memories used,
77
+ # the model the server served. Other events change nothing.
78
+ def take_event(event)
79
+ case event[:type]
80
+ when :context_status then update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
81
+ when :used_memories_updated then update(used_memories: Array(event[:used_memory_names]))
82
+ when :generation_completed
83
+ update(served: [event[:served_model], event[:requested_model]]) if event[:served_model]
84
+ end
85
+ end
86
+
55
87
  # Draw the row unless it shows that already (status.line: off draws
56
88
  # nothing).
57
89
  def refresh
@@ -11,9 +11,9 @@ module Samagotchi
11
11
  # ThinkingTicker (so it changes at most once per dwell). Both TUIs use it
12
12
  # in place of the raw tail of the stream.
13
13
  #
14
- # A chunk carries :text (and :thinking) when the profile splits its stream
15
- # (Qwen, every chat host); otherwise the raw :content, markup stripped,
16
- # stands for thinking. A switch of lane starts the next sentence from the new
14
+ # A chunk carries :text (and :thinking): both loops split their stream,
15
+ # for every profile. A chunk without them (an older worker) has its raw
16
+ # :content, markup stripped, stand for thinking. A switch of lane starts the next sentence from the new
17
17
  # lane's text; the line shown keeps the label of the lane it came from.
18
18
  class ThinkingLine
19
19
  include Formatting