samagotchi 0.23.0 → 0.25.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/docs/architecture.md +16 -9
  4. data/docs/cli.md +10 -4
  5. data/docs/configuration.md +30 -13
  6. data/docs/hooks.md +21 -7
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/prompt-caching.md +94 -0
  9. data/docs/plugins.md +17 -3
  10. data/docs/sub-agent.md +4 -3
  11. data/docs/testing.md +12 -0
  12. data/lib/samagotchi/bridge/card_store.rb +4 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  14. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +7 -0
  16. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/check-in/plugin.rb +2 -1
  18. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
  20. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  21. data/lib/samagotchi/command_steps.rb +233 -0
  22. data/lib/samagotchi/config.rb +9 -2
  23. data/lib/samagotchi/empty_answer_retry.rb +2 -2
  24. data/lib/samagotchi/engine.rb +103 -5
  25. data/lib/samagotchi/generation_phase.rb +83 -0
  26. data/lib/samagotchi/guardrails/shell_lex.rb +158 -6
  27. data/lib/samagotchi/guardrails/shell_paths.rb +1 -1
  28. data/lib/samagotchi/idle_client.rb +14 -2
  29. data/lib/samagotchi/idle_recap.rb +4 -2
  30. data/lib/samagotchi/kernel_loop.rb +40 -12
  31. data/lib/samagotchi/llm/chat_loop.rb +37 -13
  32. data/lib/samagotchi/llm/errors.rb +26 -1
  33. data/lib/samagotchi/llm/openai_chat.rb +8 -4
  34. data/lib/samagotchi/llm/prompt_cache.rb +34 -9
  35. data/lib/samagotchi/llm/usage.rb +18 -3
  36. data/lib/samagotchi/log_subscriber.rb +5 -3
  37. data/lib/samagotchi/plugin/api.rb +3 -2
  38. data/lib/samagotchi/prompt.rb +4 -1
  39. data/lib/samagotchi/session_metrics.rb +38 -4
  40. data/lib/samagotchi/steer.rb +103 -8
  41. data/lib/samagotchi/system_prompt.rb +30 -9
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -3
  43. data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
  44. data/lib/samagotchi/terminal_ui/event_renderer.rb +17 -2
  45. data/lib/samagotchi/terminal_ui/formatting.rb +40 -9
  46. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  47. data/lib/samagotchi/terminal_ui.rb +5 -0
  48. data/lib/samagotchi/thinking_tails.rb +103 -0
  49. data/lib/samagotchi/thought_stream_splitter.rb +9 -3
  50. data/lib/samagotchi/token_usage.rb +18 -6
  51. data/lib/samagotchi/tool_activity.rb +37 -3
  52. data/lib/samagotchi/tool_declarations.rb +14 -0
  53. data/lib/samagotchi/tool_runner.rb +7 -0
  54. data/lib/samagotchi/tool_view.rb +76 -0
  55. data/lib/samagotchi/tools/builtins.rb +4 -1
  56. data/lib/samagotchi/tools/execute.rb +2 -1
  57. data/lib/samagotchi/tools/registry.rb +11 -4
  58. data/lib/samagotchi/tools/shell.rb +32 -0
  59. data/lib/samagotchi/tools/task_runtime.rb +3 -3
  60. data/lib/samagotchi/turn_tally.rb +17 -6
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/waiting_steer.rb +64 -0
  63. data/lib/samagotchi/web/message_parts.rb +5 -0
  64. data/lib/samagotchi/web/public/activity.js +5 -0
  65. data/lib/samagotchi/web/public/app.js +2 -1
  66. data/lib/samagotchi/web/public/command_view.js +96 -0
  67. data/lib/samagotchi/web/public/copy.js +7 -0
  68. data/lib/samagotchi/web/public/format.js +11 -1
  69. data/lib/samagotchi/web/public/index.html +33 -0
  70. data/lib/samagotchi/web/public/stage_model.js +16 -5
  71. data/lib/samagotchi/web/public/stage_view.js +5 -2
  72. data/lib/samagotchi/web/public/turn_events.js +15 -1
  73. data/lib/samagotchi/web/public/turn_view.js +15 -6
  74. data/lib/samagotchi/worker.rb +4 -1
  75. metadata +9 -1
@@ -23,6 +23,7 @@ require_relative "../session_manager"
23
23
  require_relative "../session_metrics"
24
24
  require_relative "../guardrails/parent_approvals"
25
25
  require_relative "../tool_activity"
26
+ require_relative "../tool_view"
26
27
  require_relative "../web/message_parts"
27
28
 
28
29
  module Samagotchi
@@ -1109,7 +1110,8 @@ module Samagotchi
1109
1110
  output = part[:output]
1110
1111
  status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
1111
1112
  activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
1112
- params: part[:params], status: status }
1113
+ params: part[:params], status: status,
1114
+ description: ToolView.description_title(part[:view] || {}) }
1113
1115
  "#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
1114
1116
  "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
1115
1117
  end
@@ -1189,7 +1191,7 @@ module Samagotchi
1189
1191
  # hook's notice, a retry's line), as they were drawn live.
1190
1192
  # Questions are left out: the pending one is asked after the
1191
1193
  # replay, answered ones showed no line.
1192
- when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry then @renderer.call(event)
1194
+ when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut then @renderer.call(event)
1193
1195
  end
1194
1196
  end
1195
1197
  @view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
@@ -1202,8 +1204,9 @@ module Samagotchi
1202
1204
  def replay_tool_completed(event)
1203
1205
  return @renderer.call({ duration_ms: nil }.merge(event)) if event.dig(:activity, :action)
1204
1206
 
1205
- activity = event[:activity] || {}
1207
+ activity = EventRenderer.described(event) || {}
1206
1208
  @screen.commit(snapshot_tool_line({ tool: event[:tool], params: activity[:params], status: activity[:status],
1209
+ description: activity[:description],
1207
1210
  images: event[:images], diff: event[:diff] }))
1208
1211
  end
1209
1212
 
@@ -4,6 +4,8 @@ require "monitor"
4
4
  require_relative "formatting"
5
5
  require_relative "../output_formatter"
6
6
  require_relative "../turn_tally"
7
+ require_relative "../tool_view"
8
+ require_relative "event_renderer"
7
9
  require_relative "thinking_line"
8
10
 
9
11
  module Samagotchi
@@ -111,7 +113,8 @@ module Samagotchi
111
113
  @retry = nil
112
114
  @waiting_since = nil
113
115
  @tool = "running #{event[:tool]}"
114
- @tally.started(key: tally_key(event), tool: event[:tool], params: event[:params])
116
+ @tally.started(key: tally_key(event), tool: event[:tool], params: event[:params],
117
+ description: ToolView.description_title(event[:view] || {}))
115
118
  redraw_status
116
119
  end
117
120
  end
@@ -119,7 +122,8 @@ module Samagotchi
119
122
  def tool_call_feedback_completed(event)
120
123
  @lock.synchronize do
121
124
  @tally.completed(key: tally_key(event), tool: event[:tool],
122
- status: event.dig(:activity, :status), params: event.dig(:activity, :params))
125
+ status: event.dig(:activity, :status), params: event.dig(:activity, :params),
126
+ description: EventRenderer.described(event)&.dig(:description))
123
127
  end
124
128
  end
125
129
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "formatting"
4
+ require_relative "../tool_view"
4
5
 
5
6
  module Samagotchi
6
7
  class TerminalUI
@@ -70,8 +71,8 @@ module Samagotchi
70
71
  @view.tool_call_feedback_completed(event)
71
72
  # A replayed row (a join) carries its duration (nil: unknown).
72
73
  measured = tool_duration_ms(event)
73
- render_streamed_tool_activity(event[:activity], duration_ms: event.fetch(:duration_ms, measured), images: event[:images],
74
- diff: event[:diff])
74
+ render_streamed_tool_activity(self.class.described(event), duration_ms: event.fetch(:duration_ms, measured),
75
+ images: event[:images], diff: event[:diff])
75
76
  when :generation_completed, :generation_cancelled, :tool_dispatch_started
76
77
  @view.generation_feedback_finished
77
78
  when :pending_input_merged
@@ -79,6 +80,9 @@ module Samagotchi
79
80
  when :empty_answer_retry
80
81
  @view.finish_thinking_spinner
81
82
  @view.print_line(@view.format_empty_retry_line(event))
83
+ when :steer_cut
84
+ @view.finish_thinking_spinner
85
+ @view.print_line(@view.format_steer_cut_line(event))
82
86
  when :turn_completed
83
87
  render_turn_summary(event[:turn_summary]) if event[:turn_summary]
84
88
  when :turn_canceled
@@ -187,6 +191,17 @@ module Samagotchi
187
191
  Array(event[:steers]).each { |steer| @view.print_line(@view.format_steer_line(source: steer[:source], text: steer[:text])) }
188
192
  end
189
193
 
194
+ # A tool_call_completed's activity with the command's description:
195
+ # its own, else its view's (a snapshot's replay carries the view, not
196
+ # the description). nil for an event without one.
197
+ def self.described(event)
198
+ activity = event[:activity]
199
+ return activity if activity.nil? || activity[:description] || !event[:view].is_a?(Hash)
200
+
201
+ description = ToolView.description_title(event[:view].transform_keys(&:to_sym))
202
+ description ? activity.merge(description: description) : activity
203
+ end
204
+
190
205
  private
191
206
 
192
207
  def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
@@ -11,11 +11,21 @@ module Samagotchi
11
11
  # $stdout is a colour terminal.
12
12
  module Formatting
13
13
  def format_tool_activity_line(activity, duration_ms: nil)
14
- params = activity[:params].to_s.strip
15
- params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
16
14
  status = activity[:status].to_s
17
15
  elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
18
- "#{paint("tool>", 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color(status))}#{elapsed_suffix}"
16
+ "#{paint("tool>", 36)} #{activity[:action]} (#{activity[:tool]}#{tool_params_suffix(activity)}): " \
17
+ "#{paint(status, status_color(status))}#{elapsed_suffix}"
18
+ end
19
+
20
+ # What a tool line says the call did: the model's description of a
21
+ # command (": List the specs") in place of its cut params, else the
22
+ # params, dim.
23
+ def tool_params_suffix(activity)
24
+ description = activity[:description].to_s.strip
25
+ return ": #{description}" unless description.empty?
26
+
27
+ params = activity[:params].to_s.strip
28
+ params.empty? ? "" : " #{paint(params, 90)}"
19
29
  end
20
30
 
21
31
  # Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
@@ -30,12 +40,22 @@ module Samagotchi
30
40
 
31
41
  STEER_PREVIEW = 80
32
42
 
43
+ # Who a steer names as its sender: a plugin's own label, chi's sender
44
+ # ids in words (the web's format.js steerSender agrees;
45
+ # spec/shared/labels_matrix.json). "": no one named.
46
+ STEER_SENDERS = { "parent_agent" => "parent agent", "chi_send" => "chi send", "plugin_send" => "plugin" }.freeze
47
+
48
+ def steer_sender(source)
49
+ STEER_SENDERS.fetch(source.to_s, source.to_s)
50
+ end
51
+
33
52
  # "check-in> nudged: <text>", dim, one line cut: a plugin's steer
34
53
  # (Steer) in the running turn or the join's last exchange.
35
54
  def format_steer_line(source:, text:)
36
55
  first = text.to_s.strip.split("\n").first.to_s
37
56
  first = "#{first[0, STEER_PREVIEW - 1]}…" if first.length > STEER_PREVIEW || text.to_s.strip.include?("\n")
38
- paint("#{source.to_s.empty? ? "plugin" : source}> nudged: #{first}", 90)
57
+ sender = steer_sender(source)
58
+ paint("#{sender.empty? ? "plugin" : sender}> nudged: #{first}", 90)
39
59
  end
40
60
 
41
61
  # "↻ empty answer, asking again (1/1)": the loop retries an empty answer;
@@ -45,6 +65,15 @@ module Samagotchi
45
65
  paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
46
66
  end
47
67
 
68
+ # "↪ cut in for your message": a message for the running turn cut a
69
+ # generation that was only thinking (Engine#cut_for_steer); an empty
70
+ # or unknown source is the user's.
71
+ STEER_CUT_FOR = { "chi_send" => "a message sent with chi send", "parent_agent" => "the parent agent's message" }.freeze
72
+
73
+ def format_steer_cut_line(event)
74
+ paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
75
+ end
76
+
48
77
  # "no answer: the model returned nothing (after 1 retry)", dim like the
49
78
  # retry row: a turn that ended with no answer (TurnNote.empty_answer_line).
50
79
  def format_empty_answer_line(retries)
@@ -110,10 +139,8 @@ module Samagotchi
110
139
 
111
140
  # A tool call from the snapshot: it has no action text, only the tool.
112
141
  def snapshot_tool_line(part)
113
- params = part[:params].to_s.strip
114
- params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
115
142
  status = part[:status].to_s
116
- "#{paint("tool>", 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
143
+ "#{paint("tool>", 36)} #{part[:tool]}#{tool_params_suffix(part)}: #{paint(status, status_color(status))}" \
117
144
  "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
118
145
  end
119
146
 
@@ -376,6 +403,9 @@ module Samagotchi
376
403
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
377
404
  lines << "cancellations: #{snapshot[:cancellations]}"
378
405
  lines << "retries: #{snapshot[:retries]}"
406
+ # A looped session: loop-guard's cuts, or thinking that ran to the cap.
407
+ lines << "thinking cuts: #{snapshot[:cuts].to_i}"
408
+ lines << "output cap hits: #{snapshot[:capped].to_i}"
379
409
  context = snapshot[:context] || {}
380
410
  if context[:used_tokens]
381
411
  pct = context[:window_tokens].to_i.positive? ? format(" (%.1f%%)", context[:used_tokens] * 100.0 / context[:window_tokens]) : ""
@@ -391,14 +421,15 @@ module Samagotchi
391
421
  lines.join("\n")
392
422
  end
393
423
 
394
- # ", cached 4864 (93%), reasoning 212" for the tokens line; the counts a
395
- # server didn't report are left out.
424
+ # ", cached 4864 (93%), cache writes 312, reasoning 212" for the tokens
425
+ # line; the counts a server didn't report are left out.
396
426
  def token_breakdown_text(tokens)
397
427
  cached = tokens[:cached_sum].to_i
398
428
  reasoning = tokens[:reasoning_sum].to_i
399
429
  prompt = tokens[:prompt_sum].to_i
400
430
  text = +""
401
431
  text << ", cached #{cached} (#{(cached * 100.0 / prompt).round}%)" if cached.positive? && prompt.positive?
432
+ text << ", cache writes #{tokens[:cache_write_sum].to_i}" if tokens[:cache_write_sum].to_i.positive?
402
433
  text << ", reasoning #{reasoning}" if reasoning.positive?
403
434
  text
404
435
  end
@@ -29,6 +29,7 @@ module Samagotchi
29
29
  def call(event)
30
30
  case event[:type]
31
31
  when :empty_answer_retry then line(format_empty_retry_line(event))
32
+ when :steer_cut then line(format_steer_cut_line(event))
32
33
  when :generation_retrying then line(format_generation_retry_line(event))
33
34
  when :turn_completed, :turn_canceled then @turn_over = true
34
35
  when :hook_notice
@@ -1007,7 +1007,12 @@ module Samagotchi
1007
1007
  # Steering merges text only: a line with images runs as the next turn.
1008
1008
  return image_line_waits if ImageInput.extract(line).any?
1009
1009
 
1010
+ epoch = @engine.input_epoch
1010
1011
  @pending_input_queue.push(line.strip)
1012
+ # The user's line cuts a generation that has streamed only thinking
1013
+ # for long (steer.cut_after), now or once it has: the model starts the
1014
+ # step again with it.
1015
+ @engine.cut_for_steer(nil, epoch: epoch)
1011
1016
  true
1012
1017
  end
1013
1018
 
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "atomic_file"
7
+
8
+ module Samagotchi
9
+ # The thinking a session would otherwise lose: a SessionObserver
10
+ # subscriber (next to SessionMetrics) that keeps the tail of a generation
11
+ # cut mid-stream (a plugin's stop_generation, loop-guard's cut, a steer's
12
+ # cut), stopped with its turn by a plugin (stop_turn), or ended at the
13
+ # provider's output cap (finish_reason length). A cut generation's
14
+ # thinking is dropped from the conversation, and a looping one is what a
15
+ # fixture needs.
16
+ #
17
+ # The tails go to <session dir>/thinking_tails.jsonl, one JSON record a
18
+ # line, the newest MAX_RECORDS kept: next to the session's analytics.json,
19
+ # so an archived session (and a copied session dir) keeps them. Not in the
20
+ # session's messages (nothing there can reach the model, hooks or the
21
+ # web's reload by accident) and not in the turn records (the live snapshot
22
+ # carries those on every /state frame).
23
+ class ThinkingTails
24
+ FILE = "thinking_tails.jsonl"
25
+ # The last chars of a generation's thinking kept: a loop's cycle and
26
+ # some of its lead-in.
27
+ TAIL_CHARS = 20_000
28
+ MAX_RECORDS = 10
29
+
30
+ # @param session_dir [#call] the current session's directory, or nil
31
+ # (no session yet: nothing is written)
32
+ def initialize(session_dir:, clock: -> { Time.now })
33
+ @session_dir = session_dir
34
+ @clock = clock
35
+ @turn_id = nil
36
+ reset
37
+ end
38
+
39
+ # The saved records of +dir+, oldest first (empty without a file).
40
+ # @return [Array<Hash>] string-keyed
41
+ def self.read(dir)
42
+ File.readlines(File.join(dir, FILE), chomp: true).filter_map do |line|
43
+ JSON.parse(line)
44
+ rescue JSON::ParserError
45
+ nil
46
+ end
47
+ rescue SystemCallError
48
+ []
49
+ end
50
+
51
+ def call(event)
52
+ case event[:type]
53
+ when :turn_started then @turn_id = event[:turn_id]
54
+ when :generation_started, :generation_retrying then reset
55
+ when :generation_chunk then add(event[:thinking].to_s)
56
+ when :generation_completed then completed(event)
57
+ when :generation_cancelled
58
+ # A plugin's stop_turn mid-generation; a user's Stop is not kept.
59
+ keep(event, finish_reason: "cancelled", stopped_by: "hook") if event[:reason].to_s == "hook"
60
+ end
61
+ rescue StandardError
62
+ nil
63
+ end
64
+
65
+ private
66
+
67
+ def reset
68
+ @tail = +""
69
+ @chars = 0
70
+ end
71
+
72
+ def add(thinking)
73
+ return if thinking.empty?
74
+
75
+ @chars += thinking.length
76
+ @tail << thinking
77
+ @tail = @tail[-TAIL_CHARS..] if @tail.length > 2 * TAIL_CHARS
78
+ end
79
+
80
+ def completed(event)
81
+ stopped_by = event[:stopped_by]
82
+ return reset unless stopped_by || event[:finish_reason].to_s == "length"
83
+
84
+ keep(event, finish_reason: event[:finish_reason].to_s, stopped_by: stopped_by&.to_s, stop_reason: event[:stop_reason])
85
+ end
86
+
87
+ # Writes the record and forgets the generation (the native loop's cut
88
+ # with no retry left reports :generation_cancelled after it too).
89
+ def keep(event, **fields)
90
+ return reset if @chars.zero?
91
+
92
+ record = { at: @clock.call.utc.iso8601(3), turn_id: @turn_id, iteration: event[:iteration], **fields,
93
+ thinking_chars: @chars, tail: @tail.length > TAIL_CHARS ? @tail[-TAIL_CHARS..] : @tail }.compact
94
+ reset
95
+ dir = @session_dir.call
96
+ return unless dir
97
+
98
+ FileUtils.mkdir_p(dir)
99
+ lines = self.class.read(dir).last(MAX_RECORDS - 1).map { |kept| JSON.generate(kept) }
100
+ AtomicFile.write(File.join(dir, FILE), (lines + [JSON.generate(record)]).join("\n") << "\n")
101
+ end
102
+ end
103
+ end
@@ -46,12 +46,16 @@ module Samagotchi
46
46
  @active = nil
47
47
  end
48
48
 
49
- # Feed one raw chunk. Returns the newly-routed deltas for this chunk only.
50
- # @return [Hash{ text: String, thinking: String }]
49
+ # Feed one raw chunk. Returns the newly-routed deltas for this chunk
50
+ # only, and +tool+: whether the chunk opened, streamed into or closed a
51
+ # tool_call block (its bytes are dropped, but a steer must not cut a
52
+ # generation that writes a tool call: Engine#cut_for_steer).
53
+ # @return [Hash{ text: String, thinking: String, tool: Boolean }]
51
54
  def feed(chunk)
52
55
  @carry << chunk.to_s
53
56
  text = +""
54
57
  thinking = +""
58
+ tool = false
55
59
  loop do
56
60
  if @state == :normal
57
61
  hit = earliest_open
@@ -64,9 +68,11 @@ module Samagotchi
64
68
  @carry = @carry[hit[:pos]..]
65
69
  @active = hit[:block]
66
70
  @state = :in_block
71
+ tool ||= @active[:lane] == :drop
67
72
  # Drop the open marker itself; its body follows.
68
73
  @carry = @carry[@active[:open].length..].to_s
69
74
  else
75
+ tool ||= @active[:lane] == :drop && !@carry.empty?
70
76
  close_pos = @carry.index(@active[:close])
71
77
  if close_pos.nil?
72
78
  flush_in_block(thinking)
@@ -79,7 +85,7 @@ module Samagotchi
79
85
  @state = :normal
80
86
  end
81
87
  end
82
- { text: text, thinking: thinking }
88
+ { text: text, thinking: thinking, tool: tool }
83
89
  end
84
90
 
85
91
  # Flush any residual state at end of stream (e.g. an unterminated thinking
@@ -12,15 +12,16 @@ module Samagotchi
12
12
  # The last chunk can say more: llama.cpp adds a `timings` block with exact
13
13
  # speeds and `cache_n` (its native `tokens_cached` counts the new tokens too,
14
14
  # so it is not read); OpenRouter adds `usage.cost` (USD) and the
15
- # `prompt_tokens_details.cached_tokens` / `completion_tokens_details.reasoning_tokens`
16
- # breakdowns. Those fields are nil when a payload doesn't carry them.
15
+ # `prompt_tokens_details.cached_tokens` / `cache_write_tokens` /
16
+ # `completion_tokens_details.reasoning_tokens` breakdowns (cache writes:
17
+ # Anthropic models, prompt caching's first request). Those fields are nil when a payload doesn't carry them.
17
18
  #
18
19
  # from_payload returns nil when no server token counts are present, so a
19
20
  # caller can fall back to a chars/4 heuristic. The reported values are
20
21
  # cumulative per request, so callers should take the running MAX across
21
22
  # chunks (see SessionMetrics).
22
- class TokenUsage < Data.define(:prompt_tokens, :completion_tokens, :cached_tokens, :reasoning_tokens, :cost,
23
- :predicted_per_second, :prompt_per_second, :predicted_ms, :source)
23
+ class TokenUsage < Data.define(:prompt_tokens, :completion_tokens, :cached_tokens, :cache_write_tokens,
24
+ :reasoning_tokens, :cost, :predicted_per_second, :prompt_per_second, :predicted_ms, :source)
24
25
  CHARS_PER_TOKEN = 4.0
25
26
 
26
27
  class << self
@@ -41,11 +42,12 @@ module Samagotchi
41
42
  )
42
43
  return nil unless prompt_tokens || completion_tokens
43
44
 
45
+ details = hash_at(usage, :prompt_tokens_details)
44
46
  new(
45
47
  prompt_tokens: prompt_tokens,
46
48
  completion_tokens: completion_tokens,
47
- cached_tokens: first_positive(*both(hash_at(usage, :prompt_tokens_details), :cached_tokens),
48
- *both(timings, :cache_n)),
49
+ cached_tokens: first_positive(*both(details, :cached_tokens), *both(timings, :cache_n)),
50
+ cache_write_tokens: first_positive(*both(details, :cache_write_tokens)),
49
51
  reasoning_tokens: first_positive(*both(hash_at(usage, :completion_tokens_details), :reasoning_tokens)),
50
52
  cost: number(*both(usage, :cost)),
51
53
  predicted_per_second: positive_number(*both(timings, :predicted_per_second)),
@@ -55,6 +57,16 @@ module Samagotchi
55
57
  )
56
58
  end
57
59
 
60
+ # A request's prompt-cache counts as a :generation_completed event
61
+ # carries them (the log line too): the prompt, the part of it the
62
+ # server reused from its cache, and the part it wrote to it (nil when
63
+ # none: only some remote servers report writes).
64
+ # @return [Hash] {prompt_tokens:, cached_tokens:, cache_write_tokens:}
65
+ def cache_fields(prompt:, cached:, cache_write:)
66
+ { prompt_tokens: prompt.to_i, cached_tokens: cached.to_i,
67
+ cache_write_tokens: cache_write.to_i.positive? ? cache_write.to_i : nil }
68
+ end
69
+
58
70
  # Estimate token count from raw text length using the chars/4 heuristic.
59
71
  # @param text [String]
60
72
  # @return [Integer]
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "log"
5
+ require_relative "command_steps"
6
+ require_relative "tool_view"
5
7
  require_relative "tools/execute"
6
8
  require_relative "tools/read"
7
9
  require_relative "tools/write"
@@ -42,15 +44,34 @@ module Samagotchi
42
44
  }
43
45
  title = tool_title(tool_name, call, cwd: cwd)
44
46
  event[:title] = title if title
47
+ # The model's own words for a command, which the TUI's tool line
48
+ # shows in place of the cut params.
49
+ description = command_description(tool_name, call)
50
+ event[:description] = description if description
45
51
  event
46
52
  end
47
53
 
54
+ # An execute's description, cut as a row's title; nil for the rest.
55
+ def command_description(tool_name, call)
56
+ return nil unless call.is_a?(Hash) && ToolView::TOOLS.include?(tool_name)
57
+
58
+ ToolView.description_title(call)
59
+ end
60
+
48
61
  # What the call did, for a web row's one line ("edit lib/a.rb", the
49
62
  # command without its "cd … &&"): a file tool's path relative to +cwd+
50
- # when it is under it, a command's first line, a memory's name; nil for
51
- # the other tools (a plugin's row keeps its preview, the params).
52
- # +params+ stays the full key=value line (the TUI, the guardrails).
63
+ # when it is under it, a command's description (the model's few words),
64
+ # else its first step and how many more (CommandSteps; its first line
65
+ # when it has no steps), a memory's name;
66
+ # nil for the other tools (a plugin's row keeps its preview, the
67
+ # params). +params+ stays the full key=value line (the TUI, the
68
+ # guardrails).
53
69
  def tool_title(tool_name, call, cwd: nil)
70
+ if [Tools::Execute::NAME, Tools::TaskCreate::NAME].include?(tool_name)
71
+ title = command_description(tool_name, call) || steps_title(call[:content])
72
+ return title if title
73
+ end
74
+
54
75
  text = case tool_name
55
76
  when Tools::Read::NAME, Tools::MemoryRead::NAME then call[:content]
56
77
  when Tools::Write::NAME, Tools::Edit::NAME, Tools::MemoryWrite::NAME then call[:path]
@@ -73,6 +94,19 @@ module Samagotchi
73
94
  # eats the line.
74
95
  LEADING_CD = /\Acd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
75
96
 
97
+ # "<first step> +N" (N the steps after it), cut to fit
98
+ # TOOL_ACTIVITY_PREVIEW_LIMIT; nil when the command has no steps.
99
+ def steps_title(command)
100
+ steps = CommandSteps.parse(command)&.steps
101
+ return nil unless steps
102
+
103
+ first = steps.first.text.gsub(/\s+/, " ")
104
+ more = steps.size > 1 ? " +#{steps.size - 1}" : ""
105
+ room = TOOL_ACTIVITY_PREVIEW_LIMIT - more.length
106
+ first = "#{first[0, room - 1]}…" if first.length > room
107
+ "#{first}#{more}"
108
+ end
109
+
76
110
  def command_title(command)
77
111
  line = command.to_s.lines.map(&:strip).find { |l| !l.empty? }.to_s
78
112
  line.sub(LEADING_CD, "")
@@ -26,6 +26,10 @@ module Samagotchi
26
26
  type: "string",
27
27
  description: "The shell command to run"
28
28
  },
29
+ description: {
30
+ type: "string",
31
+ description: "Optional: what this command does, in a few words (what, not why)"
32
+ },
29
33
  cwd: {
30
34
  type: "string",
31
35
  description: "Optional working directory to run in (defaults to the project root). Relative paths are resolved against the project root."
@@ -425,6 +429,16 @@ module Samagotchi
425
429
 
426
430
  module_function
427
431
 
432
+ # +schema+ without execute's description property: what a session
433
+ # with execute.description off declares (the parameter isn't offered
434
+ # at all). Any other schema as given.
435
+ def without_command_description(schema)
436
+ return schema unless schema[:name] == "execute"
437
+
438
+ parameters = schema[:parameters]
439
+ schema.merge(parameters: parameters.merge(properties: parameters[:properties].except(:description)))
440
+ end
441
+
428
442
  # Gemma 4 <|tool>declaration:NAME{…}<tool|> blocks for every tool, back
429
443
  # to back, as Gemma 4's chat template declares tools.
430
444
  # @param schemas [Array<Hash>] a Tools::Registry's schemas
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "tool_activity"
4
+ require_relative "tool_view"
4
5
  require_relative "guardrails"
5
6
  require_relative "vision_context"
6
7
  require_relative "log"
@@ -55,6 +56,9 @@ module Samagotchi
55
56
  # The worker runs in its session's working directory.
56
57
  title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
57
58
  started[:title] = title if title
59
+ # The full command for a richer UI (params stays cut at 80).
60
+ view = ToolView.for(call[:name], call)&.to_h
61
+ started[:view] = view if view
58
62
  emit(on_stream_event, started)
59
63
 
60
64
  # The ask comes after tool_call_started: the UI shows the tool line,
@@ -95,6 +99,9 @@ module Samagotchi
95
99
  completed[:images] = images if images&.any?
96
100
  completed[:diff] = diff if diff
97
101
  completed[:waited_ms] = waited_ms if waited_ms
102
+ # Also here: a UI that missed the start (a replay gap) builds its row
103
+ # from this event.
104
+ completed[:view] = view if view
98
105
  emit(on_stream_event, completed)
99
106
  # After the completed event: a hook's card (check-in, skills) prints
100
107
  # under the call's tool row, not before it.
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_steps"
4
+ require_relative "tools/execute"
5
+ require_relative "tools/task_create"
6
+
7
+ module Samagotchi
8
+ # What a richer UI shows of a tool call beyond its one-line params: the
9
+ # full command of an execute / task_create (whitespace kept), and the
10
+ # call's own cwd: argument. Built by the server on every path (the live
11
+ # tool_call_started, the bridge's snapshot and replay, a reload from the
12
+ # saved call), so the UIs never parse the call themselves. params and its
13
+ # 80-char cut stay as they are (the guardrails, "ran as:", the TUI).
14
+ #
15
+ # +truncated+: the command was longer than COMMAND_LIMIT and is cut to it;
16
+ # +chars+ is then its full length. +cd+ and +steps+: the command as
17
+ # CommandSteps reads it (its leading cd, its steps), both nil when it
18
+ # can't (the fallback: the UI shows the command itself) or the command
19
+ # is cut. +description+: what the model said the command does (execute's
20
+ # optional description), whitespace collapsed, a trailing period gone;
21
+ # nil when it gave none.
22
+ ToolView = Data.define(:command, :cwd, :truncated, :chars, :cd, :steps, :description) do
23
+ def to_h = super.merge(steps: steps&.map(&:to_h)).compact.reject { |_key, value| value == false }
24
+ end
25
+
26
+ class ToolView
27
+ # The longest command a view carries (the spike's longest was 3,799).
28
+ COMMAND_LIMIT = 8_000
29
+
30
+ TOOLS = [Tools::Execute::NAME, Tools::TaskCreate::NAME].freeze
31
+
32
+ # The longest description a view carries; a row's title shows the
33
+ # first TITLE_LIMIT characters of it.
34
+ DESCRIPTION_LIMIT = 300
35
+ TITLE_LIMIT = 60
36
+
37
+ # @return [ToolView, nil] nil for a tool without a view and for an
38
+ # empty command
39
+ def self.for(tool_name, call)
40
+ return nil unless TOOLS.include?(tool_name) && call.is_a?(Hash)
41
+
42
+ command = call[:content].to_s
43
+ return nil if command.strip.empty?
44
+
45
+ cwd = call[:cwd].to_s.strip
46
+ truncated = command.length > COMMAND_LIMIT
47
+ parsed = truncated ? nil : CommandSteps.parse(command)
48
+ new(command: truncated ? command[0, COMMAND_LIMIT] : command, cwd: cwd.empty? ? nil : cwd,
49
+ truncated: truncated, chars: truncated ? command.length : nil, cd: parsed&.cd, steps: parsed&.steps,
50
+ description: description(call))
51
+ end
52
+
53
+ # The call's description as a view shows it, nil for none.
54
+ def self.description(call)
55
+ text = call[:description]
56
+ return nil unless text.is_a?(String)
57
+
58
+ text = text.gsub(/\s+/, " ").strip.sub(/(?<!\.)\.\z/, "")
59
+ return nil if text.empty?
60
+
61
+ text.length > DESCRIPTION_LIMIT ? "#{text[0, DESCRIPTION_LIMIT - 1]}…" : text
62
+ end
63
+
64
+ # A row's title from the call's description: cut to TITLE_LIMIT, at a
65
+ # word when one ends in its second half. nil for none.
66
+ def self.description_title(call)
67
+ text = description(call)
68
+ return text if text.nil? || text.length <= TITLE_LIMIT
69
+
70
+ cut = text[0, TITLE_LIMIT - 1]
71
+ space = cut.rindex(" ")
72
+ cut = cut[0, space] if space && space >= TITLE_LIMIT / 2
73
+ "#{cut.sub(/[\s,;:.-]+\z/, "")}…"
74
+ end
75
+ end
76
+ end
@@ -142,11 +142,14 @@ module Samagotchi
142
142
  -> { !!peers&.cancelled? }
143
143
  end
144
144
 
145
+ # @param command_description [Boolean] false: execute declares no
146
+ # description parameter (execute.description off)
145
147
  # @return [Registry] a new registry with the built-ins, in
146
148
  # TOOL_SCHEMAS order (an Engine's own, which bundles add to)
147
- def registry
149
+ def registry(command_description: true)
148
150
  by_name = CLASSES.to_h { |klass| [klass::NAME, klass] }
149
151
  ToolDeclarations::TOOL_SCHEMAS.each_with_object(Registry.new) do |schema, registry|
152
+ schema = ToolDeclarations.without_command_description(schema) unless command_description
150
153
  klass = by_name.fetch(schema[:name])
151
154
  handler = HANDLERS.fetch(klass::NAME) { ->(call, _kctx) { klass.call(call[:content]) } }
152
155
  registry.register(klass::NAME, schema: schema, handler: handler)