samagotchi 0.24.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/docs/architecture.md +16 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/configuration.md +21 -13
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/prompt-caching.md +94 -0
  9. data/docs/plugins.md +16 -2
  10. data/docs/testing.md +12 -0
  11. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/loop-guard/plugin.rb +43 -7
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/command_steps.rb +233 -0
  15. data/lib/samagotchi/config.rb +5 -2
  16. data/lib/samagotchi/empty_answer_retry.rb +2 -2
  17. data/lib/samagotchi/engine.rb +23 -4
  18. data/lib/samagotchi/guardrails/shell_lex.rb +49 -5
  19. data/lib/samagotchi/idle_client.rb +14 -2
  20. data/lib/samagotchi/kernel_loop.rb +21 -3
  21. data/lib/samagotchi/llm/chat_loop.rb +17 -3
  22. data/lib/samagotchi/llm/errors.rb +26 -1
  23. data/lib/samagotchi/llm/openai_chat.rb +5 -4
  24. data/lib/samagotchi/llm/prompt_cache.rb +34 -9
  25. data/lib/samagotchi/llm/usage.rb +18 -3
  26. data/lib/samagotchi/log_subscriber.rb +2 -1
  27. data/lib/samagotchi/plugin/api.rb +3 -2
  28. data/lib/samagotchi/session_metrics.rb +38 -4
  29. data/lib/samagotchi/system_prompt.rb +30 -9
  30. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -2
  31. data/lib/samagotchi/terminal_ui/attached_view.rb +6 -2
  32. data/lib/samagotchi/terminal_ui/event_renderer.rb +14 -2
  33. data/lib/samagotchi/terminal_ui/formatting.rb +31 -9
  34. data/lib/samagotchi/thinking_tails.rb +103 -0
  35. data/lib/samagotchi/token_usage.rb +18 -6
  36. data/lib/samagotchi/tool_activity.rb +37 -3
  37. data/lib/samagotchi/tool_declarations.rb +14 -0
  38. data/lib/samagotchi/tool_view.rb +40 -4
  39. data/lib/samagotchi/tools/builtins.rb +4 -1
  40. data/lib/samagotchi/tools/execute.rb +2 -1
  41. data/lib/samagotchi/tools/registry.rb +11 -4
  42. data/lib/samagotchi/tools/shell.rb +32 -0
  43. data/lib/samagotchi/tools/task_runtime.rb +3 -3
  44. data/lib/samagotchi/turn_tally.rb +17 -6
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/public/command_view.js +71 -3
  47. data/lib/samagotchi/web/public/format.js +11 -1
  48. data/lib/samagotchi/web/public/index.html +25 -0
  49. data/lib/samagotchi/web/public/stage_model.js +5 -1
  50. data/lib/samagotchi/web/public/stage_view.js +1 -1
  51. data/lib/samagotchi/web/public/turn_view.js +3 -3
  52. metadata +5 -1
@@ -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
@@ -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,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 "tool_view"
4
+
3
5
  module Samagotchi
4
6
  # A running count of one turn's tool calls, for the one-line tally a long,
5
7
  # tool-heavy turn shows under its activity row:
@@ -32,10 +34,13 @@ module Samagotchi
32
34
 
33
35
  # @param key [Object] one call's identity within the turn (the event's
34
36
  # [iteration, call_index])
35
- def started(key:, tool:, params: nil)
37
+ # @param description [String, nil] a command's description, which the
38
+ # last call shows in place of its params
39
+ def started(key:, tool:, params: nil, description: nil)
36
40
  call = (@calls[key] ||= { tool: tool.to_s, params: "", status: "running" })
37
41
  call[:tool] = tool.to_s unless tool.to_s.empty?
38
42
  call[:params] = params.to_s unless params.nil?
43
+ call[:description] = description if description
39
44
  call[:status] = "running"
40
45
  call
41
46
  end
@@ -43,9 +48,10 @@ module Samagotchi
43
48
  # A completion with no start seen still counts as a call (as the web's
44
49
  # activity rows do). Only "error" is a failure: a call a guardrail or an
45
50
  # approval blocked still counts as a call, not as a failed one.
46
- def completed(key:, tool:, status:, params: nil)
47
- call = @calls[key] || started(key: key, tool: tool, params: params)
51
+ def completed(key:, tool:, status:, params: nil, description: nil)
52
+ call = @calls[key] || started(key: key, tool: tool, params: params, description: description)
48
53
  call[:params] = params.to_s if params
54
+ call[:description] = description if description
49
55
  call[:status] = status.to_s == "error" ? "error" : "ok"
50
56
  call
51
57
  end
@@ -58,7 +64,8 @@ module Samagotchi
58
64
  next unless part[:kind].to_s == "tool"
59
65
 
60
66
  key = [part[:iteration].to_i, part[:call_index].to_i]
61
- started(key: key, tool: part[:tool], params: part[:params])
67
+ view = part[:view].is_a?(Hash) ? part[:view].transform_keys(&:to_sym) : {}
68
+ started(key: key, tool: part[:tool], params: part[:params], description: ToolView.description_title(view))
62
69
  completed(key: key, tool: part[:tool], status: part[:status]) unless part[:status].to_s == "running"
63
70
  end
64
71
  self
@@ -85,8 +92,12 @@ module Samagotchi
85
92
  fields = [head] + top.map { |(tool, n), _| "#{tool} ×#{n}" }
86
93
  if last
87
94
  call = calls.last
88
- params = call[:params].to_s.gsub(/\s+/, " ").strip
89
- fields << "last: #{[call[:tool], params].reject(&:empty?).join(" ")}"
95
+ shown = if call[:description]
96
+ "#{call[:tool]}: #{call[:description]}"
97
+ else
98
+ [call[:tool], call[:params].to_s.gsub(/\s+/, " ").strip].reject(&:empty?).join(" ")
99
+ end
100
+ fields << "last: #{shown}"
90
101
  end
91
102
  cut(fields.join(SEPARATOR), width)
92
103
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.24.0"
4
+ VERSION = "0.25.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -17,12 +17,80 @@ export function rowHover(row) {
17
17
  // given: execute's may be relative to the project root), the command as
18
18
  // written (whitespace kept, its own scroll), a copy button, and a line
19
19
  // saying how much a cut command left out. "" for a call without a view.
20
- export function commandBlockHtml(view) {
20
+ //
21
+ // A view with steps (CommandSteps) shows them in the command's place, its
22
+ // leading cd as one more "in" tag, and a "raw" toggle (a checkbox, CSS
23
+ // only, so a live and a reloaded row behave alike) that swaps the steps
24
+ // for the command as written. The copy button copies the raw command
25
+ // either way. A view without steps (the parser's fallback) is the raw
26
+ // block alone: nothing hides the command.
27
+ //
28
+ // +title+ is the row's: the model's description of the command, when it
29
+ // gave one, cut to fit. The block starts with the whole description when
30
+ // the title is cut, never in place of the command.
31
+ export function commandBlockHtml(view, title = null) {
21
32
  const command = view?.command;
22
33
  if (!command) return "";
23
- const cwd = view.cwd ? `<div class="activity-command-cwd">in <span>${escapeHtml(view.cwd)}</span></div>` : "";
34
+ const desc = view.description && view.description !== title
35
+ ? `<div class="activity-command-desc">${escapeHtml(view.description)}</div>`
36
+ : "";
37
+ const steps = Array.isArray(view.steps) && view.steps.length ? view.steps : null;
38
+ const dirs = [view.cwd, steps && view.cd].filter(Boolean);
39
+ const where = dirs.length
40
+ ? `in ${dirs.map((d) => `<span>${escapeHtml(d)}</span>`).join(" <span class=\"activity-command-sep\">›</span> ")}`
41
+ : "";
24
42
  const cut = view.truncated
25
43
  ? `<div class="activity-command-cut">… (${command.length.toLocaleString("en-US")} of ${Number(view.chars || 0).toLocaleString("en-US")} chars)</div>`
26
44
  : "";
27
- return `<div class="activity-command code-wrap">${cwd}<pre><code>${escapeHtml(command)}</code></pre>${copyButtonHtml("code")}${cut}</div>`;
45
+ const raw = `<pre><code>${escapeHtml(command)}</code></pre>`;
46
+ if (!steps) {
47
+ const cwd = where ? `<div class="activity-command-cwd">${where}</div>` : "";
48
+ return `<div class="activity-command code-wrap">${desc}${cwd}${raw}${copyButtonHtml("code")}${cut}</div>`;
49
+ }
50
+ const head = `<div class="activity-command-head"><span class="activity-command-cwd">${where}</span>` +
51
+ `<label class="activity-command-raw" title="Show the command as written"><input type="checkbox">raw</label></div>`;
52
+ return `<div class="activity-command code-wrap has-steps">${desc}${head}${stepsHtml(steps)}${raw}${copyButtonHtml("code")}${cut}</div>`;
53
+ }
54
+
55
+ // What an op between two steps reads as: an arrow for "then" (&&, ;, a
56
+ // new line, a pipe), "else" for ||, "&" for one put in the background.
57
+ const OP_GLYPHS = { "&&": "→", ";": "→", "\n": "→", "|": "→", "|&": "→", "||": "else", "&": "&" };
58
+ const OP_NAMES = { "\n": "new line", "&": "after starting the step before in the background" };
59
+
60
+ function opHtml(op) {
61
+ if (!op) return `<span class="step-op"></span>`;
62
+ const glyph = OP_GLYPHS[op] || op;
63
+ const name = OP_NAMES[op] || op;
64
+ return `<span class="step-op${op === "||" ? " else" : ""}" title="${escapeHtml(name)}">${escapeHtml(glyph)}</span>`;
65
+ }
66
+
67
+ // A step's redirections to or from nowhere (2>&1, 2>/dev/null, </dev/null),
68
+ // dimmed: plumbing, not what the step does.
69
+ const PLUMBING = /(^|\s)(\d*>&\d+|&>\/dev\/null|\d*>>?\s*\/dev\/null|<\s*\/dev\/null)(?=\s|$)/g;
70
+
71
+ export function stepTextHtml(text) {
72
+ let out = "";
73
+ let at = 0;
74
+ for (const m of String(text).matchAll(PLUMBING)) {
75
+ const start = m.index + m[1].length;
76
+ out += `${escapeHtml(text.slice(at, start))}<span class="step-plumbing">${escapeHtml(m[2])}</span>`;
77
+ at = start + m[2].length;
78
+ }
79
+ return out + escapeHtml(String(text).slice(at));
80
+ }
81
+
82
+ // The steps as a list: a label as a small heading above its step, the op
83
+ // that links a step to the one before, its text, its limit and heredoc
84
+ // as chips (beside the text, below it when the row is narrow).
85
+ export function stepsHtml(steps) {
86
+ const items = steps.map((step) => {
87
+ const label = step.label ? `<li class="step-label">${escapeHtml(step.label)}</li>` : "";
88
+ const limit = step.limit ? `<span class="step-chip" title="output limited">${escapeHtml(step.limit)}</span>` : "";
89
+ const heredoc = step.heredoc
90
+ ? `<span class="step-chip heredoc" title="a heredoc: its text in the raw command">${escapeHtml(step.heredoc.tag)} · ` +
91
+ `${Number(step.heredoc.lines)} ${step.heredoc.lines === 1 ? "line" : "lines"}</span>`
92
+ : "";
93
+ return `${label}<li class="activity-step">${opHtml(step.op)}<span class="step-body"><code>${stepTextHtml(step.text)}</code>${limit}${heredoc}</span></li>`;
94
+ });
95
+ return `<ol class="activity-steps">${items.join("")}</ol>`;
28
96
  }
@@ -23,11 +23,21 @@ export function noteHtml(note) {
23
23
  return `<div class="note-line">${escapeHtml(from)}</div><div class="note-text">${escapeHtml(note?.content ?? "")}</div>`;
24
24
  }
25
25
 
26
+ // Who a steer names as its sender: a plugin's own label, chi's sender ids
27
+ // in words (the TUI's Formatting#steer_sender agrees;
28
+ // spec/shared/labels_matrix.json). "": no one named.
29
+ const STEER_SENDERS = { parent_agent: "parent agent", chi_send: "chi send", plugin_send: "plugin" };
30
+ export function steerSender(source) {
31
+ const id = String(source ?? "");
32
+ return STEER_SENDERS[id] || id;
33
+ }
34
+
26
35
  // A plugin's steer (a nudge put into the running turn): one line that
27
36
  // names who nudged the model, its text collapsed under it. +bubble+: on its
28
37
  // own between bubbles (no step holds it), not a row of a step.
29
38
  export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
30
- const who = source ? `${source} nudged the model` : "nudged the model";
39
+ const sender = steerSender(source);
40
+ const who = sender ? `${sender} nudged the model` : "nudged the model";
31
41
  return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
32
42
  `<div class="steer-text">${escapeHtml(text ?? "")}</div></details>`;
33
43
  }