samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -12,6 +12,7 @@ require_relative "reline_seam"
12
12
  require_relative "../bridge_client"
13
13
  require_relative "../log"
14
14
  require_relative "../context_note"
15
+ require_relative "../steer"
15
16
  require_relative "../model_profile"
16
17
  require_relative "../output_formatter"
17
18
  require_relative "../session_commands"
@@ -30,15 +31,7 @@ module Samagotchi
30
31
  include Formatting
31
32
  include InputSupport
32
33
 
33
- STATS_COMMAND = "/stats"
34
- RECAP_COMMAND = "/recap"
35
34
  PROMPT = "> "
36
- # Detach and leave the worker up.
37
- DETACH_COMMANDS = %w[/detach].freeze
38
- # Detach and ask the worker to exit (bare `exit` too, as in the REPL).
39
- EXIT_COMMANDS = %w[/exit /quit].freeze
40
- # After an exit command: delete the session once the worker has gone.
41
- DELETE_FLAG = "--delete"
42
35
  # How long /exit --delete waits for the worker to let go.
43
36
  DELETE_WAIT = 10
44
37
  # A second Ctrl-C at an empty idle prompt within this many seconds detaches.
@@ -82,11 +75,15 @@ module Samagotchi
82
75
  # the first read (a new session with no -p, as the REPL)
83
76
  # @param clock [#call] monotonic seconds (the Ctrl-C detach window)
84
77
  # @param delete_session [#call] session id -> deletes it (/exit --delete)
78
+ # @param archive_session [#call] session id -> archives it (/archive);
79
+ # SessionManager.archive_session's result
85
80
  def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
86
81
  default_input: false, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
87
- delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) })
82
+ delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
83
+ archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) })
88
84
  @client = client
89
85
  @delete_session = delete_session
86
+ @archive_session = archive_session
90
87
  @screen = screen
91
88
  @client_id = client_id
92
89
  @view = AttachedView.new(screen)
@@ -198,7 +195,7 @@ module Samagotchi
198
195
  # Another client's anytime command: its line now, before the cards
199
196
  # it shows (its command_ran comes when it's done).
200
197
  when :command_queued
201
- @screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id])
198
+ @screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id]) && !event[:card]
202
199
  when :command_ran
203
200
  command_ran(event)
204
201
  return first_command_ran(event) if @first_command_id && event[:command_id] == @first_command_id
@@ -262,22 +259,26 @@ module Samagotchi
262
259
 
263
260
  text = line.strip
264
261
  return answer_question(text) if @question
265
- # Before the continue offer: neither is an answer to it.
266
- return submit(nil) if DETACH_COMMANDS.include?(text.downcase)
267
- return exit_worker if exit_command?(text)
268
- return exit_and_delete if exit_command?(text.delete_suffix(DELETE_FLAG).rstrip) && text.end_with?(" #{DELETE_FLAG}")
269
- command = text.split(/\s+/, 2).first
262
+
263
+ # The terminal's own commands (SessionCommands registers them, the
264
+ # REPL reads the same words). Before the continue offer: the
265
+ # leaving ones are no answer to it.
266
+ local = command_registry.lookup_local(text)&.id
267
+ return submit(nil) if local == :detach
268
+ return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
269
+ return exit_and_archive if local == :archive
270
+
270
271
  if @continue_offer
271
- return answer_continue(text) unless command_registry.command?(text) || [STATS_COMMAND, RECAP_COMMAND].include?(command)
272
+ return answer_continue(text) unless local || command_registry.command?(text)
272
273
 
273
274
  # Not an answer: the read left no echo, so show what ran.
274
275
  echo_answer(text)
275
276
  end
276
277
  return if text.empty?
277
278
 
278
- if command == STATS_COMMAND
279
+ if local == :stats
279
280
  show_stats
280
- elsif command == RECAP_COMMAND
281
+ elsif local == :recap
281
282
  show_recap
282
283
  elsif command_registry.command?(text)
283
284
  # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
@@ -306,8 +307,6 @@ module Samagotchi
306
307
  detach(exit_failed_line(e.message))
307
308
  end
308
309
 
309
- def exit_command?(text) = EXIT_COMMANDS.include?(text.downcase) || text.casecmp?("exit")
310
-
311
310
  # /exit --delete: the worker exits as for /exit, then the session is
312
311
  # deleted. When the worker stays up (another UI, queued input, ...),
313
312
  # nothing is deleted.
@@ -329,6 +328,27 @@ module Samagotchi
329
328
  detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): chi sessions delete #{id}")
330
329
  end
331
330
 
331
+ # /archive: the worker is asked to exit as for /exit, then the session
332
+ # is archived. A worker staying up for another UI (or reminders, a
333
+ # queued prompt) is stopped by the archive, as the web's archive
334
+ # stops it; a turn running refuses it. An empty session: the worker
335
+ # deletes it as it leaves.
336
+ def exit_and_archive
337
+ reply = @client.request_exit(client_id: @client_id)
338
+ id = @client.session_id
339
+ return detach("Detached; the session was empty, so it is discarded.") if reply.status == 200 && discards?(reply)
340
+ return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
341
+
342
+ result = @archive_session.call(id)
343
+ return detach("Detached; the session was empty, so it is discarded.") if result[:discarded].include?(id)
344
+
345
+ detach("Detached; archived session #{id}. chi sessions list --archived finds it.")
346
+ rescue SystemCallError, IOError => e
347
+ detach("#{exit_failed_line(e.message)} Not archived.")
348
+ rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError => e
349
+ detach("Detached; session #{id} was not archived (#{e.message}). Re-attach with: chi --attach #{id}")
350
+ end
351
+
332
352
  def exit_line(reply)
333
353
  id = @client.session_id
334
354
  error = reply.json&.fetch("error", nil)
@@ -401,7 +421,7 @@ module Samagotchi
401
421
  end
402
422
 
403
423
  def command_ran(event)
404
- @screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime]
424
+ @screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
405
425
  output = event[:output].to_s
406
426
  if output.empty?
407
427
  nil
@@ -469,12 +489,10 @@ module Samagotchi
469
489
 
470
490
  def send_prompt(text)
471
491
  persist_recent_history(text)
472
- # #memory shorthand becomes words the model reads, as in the REPL.
473
- prompt = normalize_model_input(text)
474
492
  images = attach_images(text)
475
493
  return if images.nil?
476
494
 
477
- options = { prompt: prompt, client_id: @client_id }
495
+ options = { prompt: text, client_id: @client_id }
478
496
  options[:no_interrupt] = true if @no_interrupt
479
497
  options[:images] = images unless images.empty?
480
498
  reply = @client.post_turn(**options)
@@ -566,6 +584,13 @@ module Samagotchi
566
584
  # What was typed at the prompt waits for the question to close.
567
585
  @set_aside = @reader&.typed_text unless @question
568
586
  @question = QuestionPrompt.new(pending)
587
+ # An edit's diff goes above, into the scrollback, once per question
588
+ # (a join gets it from the snapshot and may get the event too).
589
+ unless @previewed_id == @question.id
590
+ @previewed_id = @question.id
591
+ preview = @question.preview_lines(paint: method(:paint))
592
+ @screen.commit(preview.join("\n")) unless preview.empty?
593
+ end
569
594
  # The prompt first, so the choices never show under the typed text.
570
595
  sync_prompt(keep_text: false)
571
596
  @screen.set_slot(:notes, @question.slot(paint: method(:paint)))
@@ -816,12 +841,15 @@ module Samagotchi
816
841
  # The id stays findable: the detach line and chi sessions list show it.
817
842
  Log.session_id = @client.session_id
818
843
  Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size)
819
- last_user = exchange.rindex { |m| m[:role].to_s == "user" }
844
+ last_user = exchange.rindex { |m| Steer.prompt?(m) }
820
845
  render_join_notes(messages)
821
846
  return unless last_user
822
847
 
823
848
  @screen.commit(prompt_line(nil, exchange[last_user][:content]))
824
849
  Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
850
+ exchange[(last_user + 1)..].select { |m| Steer.steer?(m) }.each do |m|
851
+ @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
852
+ end
825
853
  # The saved answer is raw: the latest keeps its thinking, and one may
826
854
  # be only a tool call.
827
855
  answer = exchange[(last_user + 1)..].reverse_each
@@ -834,7 +862,7 @@ module Samagotchi
834
862
  # The context notes that came since the last prompt (all of them in a
835
863
  # session with none), after the exchange the header shows.
836
864
  def render_join_notes(messages, after_exchange: false)
837
- last_user = messages.rindex { |m| m[:role].to_s == "user" }
865
+ last_user = messages.rindex { |m| Steer.prompt?(m) }
838
866
  return if after_exchange != !last_user.nil?
839
867
 
840
868
  messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
@@ -894,6 +922,7 @@ module Samagotchi
894
922
  @screen.commit(snapshot_tool_line(part))
895
923
  end
896
924
  when "input" then @screen.commit("input> #{part[:text]}")
925
+ when "steer" then @screen.commit(format_steer_line(source: part[:source], text: part[:text]))
897
926
  when "reminder" then @screen.commit(reminder_line(part[:reminders]))
898
927
  when "hook_notice" then @screen.commit(EventRenderer.hook_notice_line(part))
899
928
  end
@@ -1029,8 +1058,8 @@ module Samagotchi
1029
1058
  params = part[:params].to_s.strip
1030
1059
  params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
1031
1060
  status = part[:status].to_s
1032
- "#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status == "ok" ? 32 : 31)}" \
1033
- "#{format_tool_image_suffix(part[:images])}"
1061
+ "#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
1062
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
1034
1063
  end
1035
1064
  end
1036
1065
  end
@@ -55,14 +55,20 @@ module Samagotchi
55
55
  when :tool_call_started
56
56
  @tool_started_at[tool_call_key(event)] = @clock.call
57
57
  @view.tool_call_feedback_started(event)
58
+ when :used_memories_updated
59
+ @view.used_memories_updated(event) if @view.respond_to?(:used_memories_updated)
58
60
  when :tool_call_completed
59
61
  @view.clear_generation_retry
60
62
  @view.tool_call_feedback_completed(event)
61
- render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images])
63
+ render_streamed_tool_activity(event[:activity], duration_ms: tool_duration_ms(event), images: event[:images],
64
+ diff: event[:diff])
62
65
  when :generation_completed, :generation_cancelled, :tool_dispatch_started
63
66
  @view.generation_feedback_finished
64
67
  when :pending_input_merged
65
68
  render_merge(event)
69
+ when :empty_answer_retry
70
+ @view.finish_thinking_spinner
71
+ @view.print_line(@view.format_empty_retry_line(event))
66
72
  when :turn_completed
67
73
  render_turn_summary(event[:turn_summary]) if event[:turn_summary]
68
74
  when :guardrail_warning
@@ -101,7 +107,8 @@ module Samagotchi
101
107
  # @param event [Hash] :hook_notice event or a snapshot part with
102
108
  # hook:, text:, level:
103
109
  def self.hook_notice_line(event)
104
- label = event[:hook].to_s[/\(bundle (.+)\)\z/, 1] || "hook"
110
+ # chi's own notices name themselves with a bare word (thinking).
111
+ label = event[:hook].to_s[/\(bundle (.+)\)\z/, 1] || event[:hook].to_s[/\A[a-z][a-z0-9-]*\z/] || "hook"
105
112
  text = event[:text].to_s
106
113
  text = "warning: #{text}" if event[:level].to_s == "warn"
107
114
  "#{label}> #{text}"
@@ -146,23 +153,26 @@ module Samagotchi
146
153
  @view.print_line("iteration limit reached") if summary[:resumable]
147
154
  end
148
155
 
149
- # Steering merged into the running turn: the answer it follows first
150
- # (the turn summary shows only the last one), then the note.
151
- def render_merge(event)
152
- @view.finish_thinking_spinner
153
- @view.print_line(event[:answer]) unless event[:answer].to_s.strip.empty?
154
- count = event[:count].to_i
155
- @view.print_line("(#{count} message#{"s" unless count == 1} merged into the running turn)")
156
- end
156
+ # Steering merged into the running turn: the answer it follows first
157
+ # (the turn summary shows only the last one), then the note, then a
158
+ # line per plugin steer (none for a steer-only merge).
159
+ def render_merge(event)
160
+ @view.finish_thinking_spinner
161
+ @view.print_line(event[:answer]) unless event[:answer].to_s.strip.empty?
162
+ count = event[:count].to_i
163
+ @view.print_line("(#{count} message#{"s" unless count == 1} merged into the running turn)") if count.positive?
164
+ Array(event[:steers]).each { |steer| @view.print_line(@view.format_steer_line(source: steer[:source], text: steer[:text])) }
165
+ end
157
166
 
158
167
  private
159
168
 
160
- def render_streamed_tool_activity(activity, duration_ms:, images: nil)
169
+ def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
161
170
  return if activity.nil?
162
171
 
163
172
  @streamed_tool_activity[tool_activity_key(activity)] += 1
164
173
  line = @view.format_tool_activity_line(activity, duration_ms: duration_ms)
165
174
  line += @view.format_tool_image_suffix(images) if images&.any?
175
+ line += @view.format_tool_diff_suffix(diff) if diff
166
176
  @view.print_line(line)
167
177
  end
168
178
 
@@ -13,9 +13,13 @@ module Samagotchi
13
13
  params = activity[:params].to_s.strip
14
14
  params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
15
15
  status = activity[:status].to_s
16
- status_color = status == "ok" ? 32 : 31
17
16
  elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
18
- "#{paint('tool>', 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color)}#{elapsed_suffix}"
17
+ "#{paint('tool>', 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color(status))}#{elapsed_suffix}"
18
+ end
19
+
20
+ # Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
21
+ def status_color(status)
22
+ { "ok" => 32, "stopped" => 33 }.fetch(status, 31)
19
23
  end
20
24
 
21
25
  # "[image shot.png 1280×800 · ~1.3k tokens]", dim: a turn's image.
@@ -23,6 +27,21 @@ module Samagotchi
23
27
  paint("[image #{ImageRef.label(ImageStore.symbolize(ref))}]", 90)
24
28
  end
25
29
 
30
+ STEER_PREVIEW = 80
31
+
32
+ # "check-in> nudged: <text>", dim, one line cut: a plugin's steer
33
+ # (Steer) in the running turn or the join's last exchange.
34
+ def format_steer_line(source:, text:)
35
+ first = text.to_s.strip.split("\n").first.to_s
36
+ first = "#{first[0, STEER_PREVIEW - 1]}…" if first.length > STEER_PREVIEW || text.to_s.strip.include?("\n")
37
+ paint("#{source.to_s.empty? ? "plugin" : source}> nudged: #{first}", 90)
38
+ end
39
+
40
+ # "↻ empty answer, asking again (1/1)": the loop retries an empty answer.
41
+ def format_empty_retry_line(event)
42
+ paint("↻ empty answer, asking again (#{event[:attempt]}/#{event[:of]})", 90)
43
+ end
44
+
26
45
  # " → image 1280×800" after a tool line whose tool read an image.
27
46
  def format_tool_image_suffix(images)
28
47
  refs = Array(images).map { |ref| ImageStore.symbolize(ref) }
@@ -31,6 +50,15 @@ module Samagotchi
31
50
  " #{paint(refs.map { |ref| "→ image #{ref[:width]}×#{ref[:height]}" }.join(", "), 90)}"
32
51
  end
33
52
 
53
+ # An edit/write row's change, after the row: " +3 −1" (green, red).
54
+ # +diff+ is EditPreview's hash, symbol or string keys (SSE, snapshot).
55
+ def format_tool_diff_suffix(diff)
56
+ return "" unless diff.is_a?(Hash)
57
+
58
+ get = ->(key) { diff[key] || diff[key.to_s] }
59
+ " #{paint("+#{get.(:added).to_i}", 32)} #{paint("\u2212#{get.(:removed).to_i}", 31)}"
60
+ end
61
+
34
62
  # A card (Engine#show_card) as a framed block: the title and its
35
63
  # source, the body as wrapped plain text (no terminal markdown), and
36
64
  # one `→ <command>` line per action, the label after it when it says
@@ -229,11 +257,11 @@ module Samagotchi
229
257
  def format_session_metrics(snapshot)
230
258
  return "(no metrics yet)" unless snapshot.is_a?(Hash)
231
259
 
232
- # A symbol locally; a string when the snapshot came over the Bridge.
233
- token_src = snapshot[:token_source]&.to_s
234
- token_src_label = case token_src
260
+ tokens = snapshot[:tokens] || {}
261
+ token_src_label = case tokens[:source]
235
262
  when "server" then "server-reported"
236
263
  when "estimate" then "estimated (chars/4)"
264
+ when "mixed" then "server-reported, some estimated"
237
265
  else "n/a"
238
266
  end
239
267
 
@@ -245,13 +273,17 @@ module Samagotchi
245
273
  lines << " by tool: #{by_tool.map { |k, v| "#{k}=#{v}" }.join(", ")}"
246
274
  end
247
275
  lines << "iterations: #{snapshot[:iterations_total]}"
248
- lines << "tokens in/out: #{snapshot[:tokens_in]}/#{snapshot[:tokens_out]} (total #{snapshot[:tokens_total]}, #{token_src_label})"
276
+ # Summed over every request: each prompt is sent in full again.
277
+ lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})"
249
278
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
250
279
  lines << "cancellations: #{snapshot[:cancellations]}"
251
280
  lines << "retries: #{snapshot[:retries]}"
252
- if snapshot[:context_window_tokens]
253
- lines << "context window: #{snapshot[:context_window_tokens]} tokens (#{snapshot[:context_window_source]})"
281
+ context = snapshot[:context] || {}
282
+ if context[:used_tokens]
283
+ pct = context[:window_tokens].to_i.positive? ? format(" (%.1f%%)", context[:used_tokens] * 100.0 / context[:window_tokens]) : ""
284
+ lines << "context used: #{context[:used_tokens]} tokens#{pct}"
254
285
  end
286
+ lines << "context window: #{context[:window_tokens]} tokens (#{context[:window_source]})" if context[:window_tokens]
255
287
  lines << "prompt profile: #{snapshot[:profile]} (#{snapshot[:profile_source]})" if snapshot[:profile]
256
288
  if snapshot[:served_model]
257
289
  asked = snapshot[:served_model_for]
@@ -11,10 +11,10 @@ require_relative "../session_commands"
11
11
  module Samagotchi
12
12
  class TerminalUI
13
13
  # Input the REPL and the attached TUI share: the persistent prompt
14
- # history, Tab completion (/commands, @path in the cwd, #memory), the
15
- # #memory shorthand rewrite, multiline reads at the main prompt, and a
16
- # one-shot prefill of the next read (the default input, a prompt given
17
- # back after a failed turn).
14
+ # history, Tab completion (/commands, @path in the cwd, #memory, which
15
+ # inserts #name and the model reads it as typed), multiline reads at the
16
+ # main prompt, and a one-shot prefill of the next read (the default
17
+ # input, a prompt given back after a failed turn).
18
18
  #
19
19
  # Included for private use; it keeps state in @next_input_prefill and
20
20
  # reads @no_default_input.
@@ -204,21 +204,6 @@ module Samagotchi
204
204
  }
205
205
  end
206
206
 
207
- def normalize_model_input(input)
208
- input.to_s.gsub(/(^|[^\w\/])#((?:project|system)\/)?([a-zA-Z0-9][a-zA-Z0-9_-]*)/) do
209
- prefix = Regexp.last_match(1)
210
- scoped = Regexp.last_match(2).to_s
211
- name = Regexp.last_match(3)
212
- scope = scoped.delete_suffix("/")
213
- normalized = if scope.empty?
214
- "memory \"#{name}\""
215
- else
216
- "memory \"#{name}\" in #{scope} scope"
217
- end
218
- "#{prefix}#{normalized}"
219
- end
220
- end
221
-
222
207
  def history_file_path
223
208
  explicit = ENV[PROMPT_HISTORY_ENV].to_s.strip
224
209
  return explicit unless explicit.empty?
@@ -240,7 +225,10 @@ module Samagotchi
240
225
  nil
241
226
  end
242
227
 
228
+ # A scratch session keeps nothing, its typed lines neither.
243
229
  def persist_recent_history(input)
230
+ return if @scratch
231
+
244
232
  entries = normalize_history_entries(load_history_entries_from_disk)
245
233
  entries << input
246
234
  trimmed_entries = entries.last(PROMPT_HISTORY_LIMIT)
@@ -14,6 +14,8 @@ module Samagotchi
14
14
  # an optional "; reason"; a substring would pick the wrong one ("y" is
15
15
  # in "Deny").
16
16
  class QuestionPrompt
17
+ DIFF_CODES = { "+" => 32, "-" => 31, "@" => 2, "\\" => 2 }.freeze
18
+
17
19
  # A parsed answer. +error+ set: re-ask. +note+: print, then accept.
18
20
  Answer = Struct.new(:selected, :freeform, :error, :note, keyword_init: true) do
19
21
  def ok? = error.nil?
@@ -24,6 +26,8 @@ module Samagotchi
24
26
  # @param pending [Hash] Engine#pending_question (symbol or string keys)
25
27
  def initialize(pending)
26
28
  field = ->(key) { pending[key] || pending[key.to_s] }
29
+ approval = field.(:approval)
30
+ @preview = approval.is_a?(Hash) ? (approval[:preview] || approval["preview"]) : nil
27
31
  @id = field.(:id).to_s
28
32
  @question = field.(:question).to_s
29
33
  @options = Array(field.(:options)).map { |v| v.to_s.strip }.reject(&:empty?)
@@ -49,6 +53,34 @@ module Samagotchi
49
53
  hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint)
50
54
  end
51
55
 
56
+ # The most diff lines #preview_lines prints; the web shows them all.
57
+ PREVIEW_LINES = 40
58
+
59
+ # An edit/write approval's dry-run diff (approval.preview, symbol or
60
+ # string keys: nested keys are strings after a reload or SSE), as
61
+ # lines to print above the slot: coloured +/-, dim @@, cut at
62
+ # PREVIEW_LINES. [] for none.
63
+ def preview_lines(paint: ->(text, _code) { text })
64
+ preview = @preview
65
+ return [] unless preview.is_a?(Hash)
66
+
67
+ get = ->(key) { preview[key] || preview[key.to_s] }
68
+ return [paint.("this edit would fail: #{get.(:error)}", 33)] if get.(:error)
69
+ return [paint.("diff not shown: #{get.(:skipped)}", 2)] if get.(:skipped)
70
+
71
+ lines = get.(:text).to_s.split("\n")
72
+ return [] if lines.empty?
73
+
74
+ # TextDiff's own cut note ("… N more lines") counts toward the rest.
75
+ cut = lines.last.to_s[/\A… (\d+) more lines\z/, 1]
76
+ lines.pop if cut
77
+ hidden = [lines.size - PREVIEW_LINES, 0].max + cut.to_i
78
+ shown = lines.first(PREVIEW_LINES).map { |line| paint.(line, diff_code(line)) }
79
+ shown.unshift(paint.("new file", 2)) if get.(:new_file)
80
+ shown << paint.("… #{hidden} more lines (full diff on the web)", 2) if hidden.positive?
81
+ shown
82
+ end
83
+
52
84
  # The one line that stays in the scrollback once the question closes:
53
85
  # the question and what became of it.
54
86
  # @param outcome [String] the answer (#answer_text) or what closed it
@@ -148,6 +180,9 @@ module Samagotchi
148
180
  found = options.find { |o| o.downcase == tok.downcase || o.downcase.include?(tok.downcase) }
149
181
  found ? [found, nil] : [nil, "Unknown option '#{tok}'. Use numbers 1-#{options.size} or exact labels."]
150
182
  end
183
+
184
+ # "+" green, "-" red, "@@" and "\ No newline" dim, context plain.
185
+ def diff_code(line) = DIFF_CODES.fetch(line[0], 0)
151
186
  end
152
187
  end
153
188
  end