samagotchi 0.24.0 → 0.26.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +16 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/configuration.md +22 -13
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +478 -0
  9. data/docs/internals/prompt-caching.md +120 -0
  10. data/docs/plugins.md +18 -3
  11. data/docs/testing.md +12 -0
  12. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/loop-guard/plugin.rb +59 -11
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/cancellation_controller.rb +8 -0
  16. data/lib/samagotchi/client.rb +53 -3
  17. data/lib/samagotchi/command_steps.rb +233 -0
  18. data/lib/samagotchi/config.rb +14 -4
  19. data/lib/samagotchi/empty_answer_retry.rb +2 -2
  20. data/lib/samagotchi/engine.rb +73 -11
  21. data/lib/samagotchi/guardrails/shell_lex.rb +49 -5
  22. data/lib/samagotchi/idle_client.rb +14 -2
  23. data/lib/samagotchi/kernel_loop.rb +60 -6
  24. data/lib/samagotchi/llm/chat_loop.rb +18 -4
  25. data/lib/samagotchi/llm/errors.rb +26 -1
  26. data/lib/samagotchi/llm/http.rb +5 -4
  27. data/lib/samagotchi/llm/openai_chat.rb +5 -4
  28. data/lib/samagotchi/llm/prompt_cache.rb +34 -9
  29. data/lib/samagotchi/llm/usage.rb +18 -3
  30. data/lib/samagotchi/log_subscriber.rb +2 -1
  31. data/lib/samagotchi/plugin/api.rb +3 -2
  32. data/lib/samagotchi/prompt_warmup.rb +95 -0
  33. data/lib/samagotchi/session_metrics.rb +38 -4
  34. data/lib/samagotchi/system_prompt.rb +30 -9
  35. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -2
  36. data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
  37. data/lib/samagotchi/terminal_ui/event_renderer.rb +14 -2
  38. data/lib/samagotchi/terminal_ui/formatting.rb +31 -9
  39. data/lib/samagotchi/terminal_ui.rb +2 -0
  40. data/lib/samagotchi/thinking_tails.rb +104 -0
  41. data/lib/samagotchi/token_usage.rb +18 -6
  42. data/lib/samagotchi/tool_activity.rb +37 -3
  43. data/lib/samagotchi/tool_declarations.rb +14 -0
  44. data/lib/samagotchi/tool_view.rb +40 -4
  45. data/lib/samagotchi/tools/builtins.rb +4 -1
  46. data/lib/samagotchi/tools/execute.rb +2 -1
  47. data/lib/samagotchi/tools/registry.rb +11 -4
  48. data/lib/samagotchi/tools/shell.rb +32 -0
  49. data/lib/samagotchi/tools/task_runtime.rb +3 -3
  50. data/lib/samagotchi/turn_note.rb +26 -2
  51. data/lib/samagotchi/turn_state.rb +6 -0
  52. data/lib/samagotchi/turn_tally.rb +17 -6
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/public/command_view.js +74 -3
  55. data/lib/samagotchi/web/public/format.js +11 -1
  56. data/lib/samagotchi/web/public/index.html +45 -13
  57. data/lib/samagotchi/web/public/stage_model.js +5 -1
  58. data/lib/samagotchi/web/public/stage_view.js +1 -1
  59. data/lib/samagotchi/web/public/turn_view.js +3 -3
  60. data/lib/samagotchi/worker.rb +3 -0
  61. metadata +7 -1
@@ -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;
@@ -119,10 +139,8 @@ module Samagotchi
119
139
 
120
140
  # A tool call from the snapshot: it has no action text, only the tool.
121
141
  def snapshot_tool_line(part)
122
- params = part[:params].to_s.strip
123
- params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
124
142
  status = part[:status].to_s
125
- "#{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))}" \
126
144
  "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
127
145
  end
128
146
 
@@ -385,6 +403,9 @@ module Samagotchi
385
403
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
386
404
  lines << "cancellations: #{snapshot[:cancellations]}"
387
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}"
388
409
  context = snapshot[:context] || {}
389
410
  if context[:used_tokens]
390
411
  pct = context[:window_tokens].to_i.positive? ? format(" (%.1f%%)", context[:used_tokens] * 100.0 / context[:window_tokens]) : ""
@@ -400,14 +421,15 @@ module Samagotchi
400
421
  lines.join("\n")
401
422
  end
402
423
 
403
- # ", cached 4864 (93%), reasoning 212" for the tokens line; the counts a
404
- # 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.
405
426
  def token_breakdown_text(tokens)
406
427
  cached = tokens[:cached_sum].to_i
407
428
  reasoning = tokens[:reasoning_sum].to_i
408
429
  prompt = tokens[:prompt_sum].to_i
409
430
  text = +""
410
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?
411
433
  text << ", reasoning #{reasoning}" if reasoning.positive?
412
434
  text
413
435
  end
@@ -173,6 +173,8 @@ module Samagotchi
173
173
  # -p without --non-interactive drops into the REPL, which can answer
174
174
  # an approval; --non-interactive can't, so an approval there denies.
175
175
  @engine.interface = @non_interactive ? :non_interactive : :repl
176
+ # A --non-interactive run exits after its turn: no turn-end warm-up.
177
+ @engine.next_turn_waiting = -> { true } if @non_interactive
176
178
  @turn_flow = TurnFlow.new(engine: @engine)
177
179
  # A recap written while a continue offer waits says the turn stopped
178
180
  # unfinished. (Answering at the prompt is typing: activity already.)
@@ -0,0 +1,104 @@
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 (the bundle that stopped it,
59
+ # "hook" when unknown); a user's Stop is not kept.
60
+ keep(event, finish_reason: "cancelled", stopped_by: (event[:stopped_by] || "hook").to_s) if event[:reason].to_s == "hook"
61
+ end
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
66
+ private
67
+
68
+ def reset
69
+ @tail = +""
70
+ @chars = 0
71
+ end
72
+
73
+ def add(thinking)
74
+ return if thinking.empty?
75
+
76
+ @chars += thinking.length
77
+ @tail << thinking
78
+ @tail = @tail[-TAIL_CHARS..] if @tail.length > 2 * TAIL_CHARS
79
+ end
80
+
81
+ def completed(event)
82
+ stopped_by = event[:stopped_by]
83
+ return reset unless stopped_by || event[:finish_reason].to_s == "length"
84
+
85
+ keep(event, finish_reason: event[:finish_reason].to_s, stopped_by: stopped_by&.to_s, stop_reason: event[:stop_reason])
86
+ end
87
+
88
+ # Writes the record and forgets the generation (the native loop's cut
89
+ # with no retry left reports :generation_cancelled after it too).
90
+ def keep(event, **fields)
91
+ return reset if @chars.zero?
92
+
93
+ record = { at: @clock.call.utc.iso8601(3), turn_id: @turn_id, iteration: event[:iteration], **fields,
94
+ thinking_chars: @chars, tail: @tail.length > TAIL_CHARS ? @tail[-TAIL_CHARS..] : @tail }.compact
95
+ reset
96
+ dir = @session_dir.call
97
+ return unless dir
98
+
99
+ FileUtils.mkdir_p(dir)
100
+ lines = self.class.read(dir).last(MAX_RECORDS - 1).map { |kept| JSON.generate(kept) }
101
+ AtomicFile.write(File.join(dir, FILE), (lines + [JSON.generate(record)]).join("\n") << "\n")
102
+ end
103
+ end
104
+ end
@@ -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: "What this command does, in a few words (what, not why). Shown to the user as the step's title, so include it on every call."
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,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "command_steps"
3
4
  require_relative "tools/execute"
4
5
  require_relative "tools/task_create"
5
6
 
@@ -12,9 +13,14 @@ module Samagotchi
12
13
  # 80-char cut stay as they are (the guardrails, "ran as:", the TUI).
13
14
  #
14
15
  # +truncated+: the command was longer than COMMAND_LIMIT and is cut to it;
15
- # +chars+ is then its full length.
16
- ToolView = Data.define(:command, :cwd, :truncated, :chars) do
17
- def to_h = super.compact.reject { |_key, value| value == false }
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 }
18
24
  end
19
25
 
20
26
  class ToolView
@@ -23,6 +29,11 @@ module Samagotchi
23
29
 
24
30
  TOOLS = [Tools::Execute::NAME, Tools::TaskCreate::NAME].freeze
25
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
+
26
37
  # @return [ToolView, nil] nil for a tool without a view and for an
27
38
  # empty command
28
39
  def self.for(tool_name, call)
@@ -33,8 +44,33 @@ module Samagotchi
33
44
 
34
45
  cwd = call[:cwd].to_s.strip
35
46
  truncated = command.length > COMMAND_LIMIT
47
+ parsed = truncated ? nil : CommandSteps.parse(command)
36
48
  new(command: truncated ? command[0, COMMAND_LIMIT] : command, cwd: cwd.empty? ? nil : cwd,
37
- truncated: truncated, chars: truncated ? command.length : nil)
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/, "")}…"
38
74
  end
39
75
  end
40
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)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require_relative "output_guardrails"
5
+ require_relative "shell"
5
6
 
6
7
  module Samagotchi
7
8
  module Tools
@@ -90,7 +91,7 @@ module Samagotchi
90
91
  started = monotonic_time
91
92
  deadline = started + timeout_sec
92
93
 
93
- Open3.popen3(env || {}, command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
94
+ Open3.popen3(env || {}, *Shell.argv(command), chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
94
95
  stdin.close
95
96
  readers = [OutputReader.new(stdout), OutputReader.new(stderr)]
96
97
 
@@ -3,7 +3,11 @@
3
3
  module Samagotchi
4
4
  module Tools
5
5
  # The tools a session offers the model, in declaration order: the
6
- # built-ins (Tools::Builtins), and later the ones bundles add. The
6
+ # built-ins (Tools::Builtins) as registered, then the ones bundles add,
7
+ # by bundle and name. That order doesn't depend on which plugin
8
+ # finished its init first or re-registered a changed tool, so the
9
+ # tool list (part of the prompt's cached prefix) is the same in every
10
+ # session with the same tools. The
7
11
  # native prompts and the chat path's tools: are rendered from #schemas,
8
12
  # and KernelLoop#dispatch runs a call through its entry's handler.
9
13
  class Registry
@@ -43,13 +47,16 @@ module Samagotchi
43
47
  def key?(name) = @entries.key?(name.to_s)
44
48
 
45
49
  # @return [Array<String>] in declaration order
46
- def names = @entries.keys
50
+ def names = entries.map(&:name)
47
51
 
48
52
  # @return [Array<Entry>] in declaration order
49
- def entries = @entries.values
53
+ def entries
54
+ core, added = @entries.values.partition(&:core?)
55
+ core + added.sort_by { |entry| [entry.source.to_s, entry.name] }
56
+ end
50
57
 
51
58
  # @return [Array<Hash>] the schemas, in declaration order
52
- def schemas = @entries.values.map(&:schema)
59
+ def schemas = entries.map(&:schema)
53
60
 
54
61
  def freeze
55
62
  @entries.freeze
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Samagotchi
6
+ module Tools
7
+ # The shell a model's (or the user's `!`) command runs in: execute,
8
+ # task_create and the shell bang. Non-login (a login shell re-sources
9
+ # profile files, which can rebuild PATH and shadow the inherited toolchain).
10
+ #
11
+ # macOS's /bin/sh is bash 3.2, which can't parse an apostrophe in a
12
+ # heredoc inside "$( )" -- the usual `git commit -m "$(cat <<'EOF' ...`.
13
+ # There, zsh in sh emulation runs instead (it reads no rc files in that
14
+ # mode), with the options that keep bash habits working: `{1..3}` brace
15
+ # expansion, unquoted `( )` in `[[ =~ ]]`, BASH_REMATCH, and echo
16
+ # expanding `\t` as macOS's sh does. Elsewhere (Linux) /bin/sh as before.
17
+ module Shell
18
+ ZSH = "/bin/zsh"
19
+ ZSH_SH_MODE = [ZSH, "--emulate", "sh", "+o", "ignore_braces", "+o", "sh_glob", "-o", "bash_rematch",
20
+ "+o", "bsd_echo"].freeze
21
+ SH = ["/bin/sh"].freeze
22
+
23
+ # @return [Array<String>] the argv that runs +command+
24
+ def self.argv(command) = [*program, "-c", command]
25
+
26
+ # @return [Array<String>] the shell and its options, without -c
27
+ def self.program(host_os: RbConfig::CONFIG["host_os"], zsh: File.executable?(ZSH))
28
+ host_os.to_s.include?("darwin") && zsh ? ZSH_SH_MODE : SH
29
+ end
30
+ end
31
+ end
32
+ end
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "shellwords"
7
7
  require "time"
8
8
  require_relative "../atomic_file"
9
+ require_relative "shell"
9
10
 
10
11
  module Samagotchi
11
12
  module Tools
@@ -49,11 +50,10 @@ module Samagotchi
49
50
  output_io.sync = true
50
51
 
51
52
  wrapped_command = wrapped_shell_command(normalized_command, exit_code_path)
52
- # Non-login shell (matches Execute): a login shell re-sources profile
53
- # files, which can rebuild PATH and shadow the inherited toolchain.
53
+ # Non-login, the shell Execute uses too (Shell).
54
54
  pid = Process.spawn(
55
55
  spawn_env,
56
- "/bin/sh", "-c", wrapped_command,
56
+ *Shell.argv(wrapped_command),
57
57
  chdir: resolved_cwd,
58
58
  out: output_io,
59
59
  err: output_io,
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "model_profile"
4
+
3
5
  module Samagotchi
4
6
  # The one-line system note a turn leaves in the conversation when it ends
5
7
  # without an answer: failed before the model replied, cancelled, or over
@@ -19,6 +21,12 @@ module Samagotchi
19
21
  TASK_COMMAND_CHARS = 60
20
22
  RETRY_NUDGE = :retry_nudge
21
23
  EMPTY_ANSWER = :empty_answer
24
+ # An empty step's thinking kept on the marker: its tail, so a model
25
+ # that thought for minutes doesn't put megabytes in the session file.
26
+ STEP_CHARS = 20_000
27
+ # A step's native thinking starts with one of these; the cut keeps it,
28
+ # so the UIs still find the thinking (MessageParts).
29
+ THINK_OPENS = ["<think>", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN].freeze
22
30
 
23
31
  module_function
24
32
 
@@ -75,16 +83,32 @@ module Samagotchi
75
83
  # UIs, which draw the turn's notice and steps from it after a reload:
76
84
  # +retries+ the empty-answer retries spent, +steps+ the empty
77
85
  # generations as model messages (their thinking; the loops keep them out
78
- # of the conversation, where they'd be empty assistant turns). Never
86
+ # of the conversation, where they'd be empty assistant turns), each
87
+ # text cut to its last STEP_CHARS. Never
79
88
  # sent: the payload builders send a system message's content, and the
80
89
  # copies for hooks, plugins and the recap drop it (AnswerDisplay.strip).
81
90
  def empty(retries: 0, steps: [])
82
91
  marker = { retries: retries.to_i }
83
- marker[:steps] = steps.map { |step| step.slice(:role, :content, :thinking) } unless steps.empty?
92
+ marker[:steps] = steps.map { |step| capped_step(step) } unless steps.empty?
84
93
  message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
85
94
  .merge(EMPTY_ANSWER => marker)
86
95
  end
87
96
 
97
+ def capped_step(step)
98
+ step.slice(:role, :content, :thinking).to_h do |key, value|
99
+ [key, key == :role || !value.is_a?(String) ? value : tail_of(value)]
100
+ end
101
+ end
102
+
103
+ # The last STEP_CHARS of +text+ after a line saying how much was cut
104
+ # (and the thinking's opening tag when it had one); short text as is.
105
+ def tail_of(text)
106
+ return text if text.length <= STEP_CHARS
107
+
108
+ open = THINK_OPENS.find { |tag| text.start_with?(tag) }
109
+ "#{open}[… #{text.length - STEP_CHARS} earlier characters cut]\n#{text[-STEP_CHARS..]}"
110
+ end
111
+
88
112
  # The empty-answer marker of +entry+ (either key type), or nil.
89
113
  def empty_answer(entry)
90
114
  return nil unless note?(entry)
@@ -133,6 +133,12 @@ module Samagotchi
133
133
  true
134
134
  end
135
135
 
136
+ # @return [Boolean] whether #steer_next_turn queued anything a turn has
137
+ # yet to begin with
138
+ def carried_steers?
139
+ @lock.synchronize { !@carried_steers.empty? }
140
+ end
141
+
136
142
  # Drop what #steer_next_turn queued and no turn began with (a no-op
137
143
  # once #begin! took it).
138
144
  # @return [Array<Hash>] the steers dropped ({text:, source:})