samagotchi 0.22.0 → 0.24.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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/bin/chi +9 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +13 -4
  6. data/docs/hooks.md +20 -6
  7. data/docs/plugins.md +1 -1
  8. data/docs/releasing.md +1 -1
  9. data/docs/sub-agent.md +4 -3
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +4 -2
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
  13. data/lib/samagotchi/bootstrap/probe.rb +1 -1
  14. data/lib/samagotchi/bootstrap_command.rb +2 -2
  15. data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
  16. data/lib/samagotchi/bridge/card_store.rb +4 -3
  17. data/lib/samagotchi/bridge/sse_writer.rb +0 -6
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -3
  19. data/lib/samagotchi/bridge/turn_notice.rb +1 -0
  20. data/lib/samagotchi/bridge.rb +19 -9
  21. data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
  22. data/lib/samagotchi/bridge_client.rb +1 -1
  23. data/lib/samagotchi/bundle_command.rb +39 -29
  24. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
  26. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/check-in/plugin.rb +4 -1
  28. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
  29. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
  32. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
  34. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
  36. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
  37. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  38. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  39. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  40. data/lib/samagotchi/cancellation_controller.rb +1 -1
  41. data/lib/samagotchi/cli/command.rb +1 -1
  42. data/lib/samagotchi/client.rb +3 -5
  43. data/lib/samagotchi/commands/registry.rb +2 -2
  44. data/lib/samagotchi/config.rb +42 -14
  45. data/lib/samagotchi/context_note.rb +1 -1
  46. data/lib/samagotchi/context_status.rb +6 -6
  47. data/lib/samagotchi/continue_offer.rb +15 -8
  48. data/lib/samagotchi/debug_log.rb +1 -1
  49. data/lib/samagotchi/desktop/macos.rb +5 -4
  50. data/lib/samagotchi/empty_answer_retry.rb +1 -1
  51. data/lib/samagotchi/engine.rb +97 -18
  52. data/lib/samagotchi/gem_update.rb +2 -1
  53. data/lib/samagotchi/generation_phase.rb +83 -0
  54. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  55. data/lib/samagotchi/guardrails/approval.rb +5 -5
  56. data/lib/samagotchi/guardrails/approvals.rb +2 -2
  57. data/lib/samagotchi/guardrails/gate.rb +2 -2
  58. data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
  59. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  60. data/lib/samagotchi/guardrails/shell_lex.rb +113 -5
  61. data/lib/samagotchi/guardrails/shell_paths.rb +3 -3
  62. data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
  63. data/lib/samagotchi/hooks/loader.rb +18 -16
  64. data/lib/samagotchi/hooks/registry.rb +10 -2
  65. data/lib/samagotchi/host_registry.rb +1 -3
  66. data/lib/samagotchi/idle_recap.rb +22 -17
  67. data/lib/samagotchi/idle_reminders.rb +6 -3
  68. data/lib/samagotchi/image_store.rb +5 -2
  69. data/lib/samagotchi/kernel_loop.rb +50 -40
  70. data/lib/samagotchi/llm/chat_loop.rb +29 -16
  71. data/lib/samagotchi/llm/errors.rb +2 -2
  72. data/lib/samagotchi/llm/http.rb +12 -12
  73. data/lib/samagotchi/llm/openai_chat.rb +42 -28
  74. data/lib/samagotchi/llm/prompt_cache.rb +1 -1
  75. data/lib/samagotchi/log.rb +2 -2
  76. data/lib/samagotchi/log_subscriber.rb +5 -3
  77. data/lib/samagotchi/memory_bundle/builder.rb +9 -3
  78. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
  79. data/lib/samagotchi/memory_bundle/installer.rb +67 -57
  80. data/lib/samagotchi/memory_bundle/listing.rb +5 -1
  81. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  82. data/lib/samagotchi/memory_bundle/merger.rb +6 -4
  83. data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
  84. data/lib/samagotchi/memory_bundle/source.rb +7 -3
  85. data/lib/samagotchi/memory_bundle/status.rb +3 -1
  86. data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
  87. data/lib/samagotchi/memory_bundle/trash.rb +5 -2
  88. data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
  89. data/lib/samagotchi/memory_bundle.rb +1 -0
  90. data/lib/samagotchi/model_overlay.rb +3 -5
  91. data/lib/samagotchi/model_profile.rb +12 -8
  92. data/lib/samagotchi/note_command.rb +2 -1
  93. data/lib/samagotchi/output_formatter.rb +18 -17
  94. data/lib/samagotchi/owner_lock.rb +2 -1
  95. data/lib/samagotchi/plugin/api.rb +8 -5
  96. data/lib/samagotchi/plugin/context.rb +1 -3
  97. data/lib/samagotchi/plugin/loader.rb +1 -1
  98. data/lib/samagotchi/plugin/service.rb +1 -1
  99. data/lib/samagotchi/plugin/sessions.rb +1 -0
  100. data/lib/samagotchi/plugin_tasks.rb +1 -1
  101. data/lib/samagotchi/prompt.rb +72 -13
  102. data/lib/samagotchi/prompt_literal_guard.rb +1 -1
  103. data/lib/samagotchi/question_desk.rb +7 -7
  104. data/lib/samagotchi/recap_setup.rb +1 -1
  105. data/lib/samagotchi/relay_desk.rb +1 -1
  106. data/lib/samagotchi/reminder_store.rb +2 -1
  107. data/lib/samagotchi/send_command.rb +4 -2
  108. data/lib/samagotchi/session.rb +2 -2
  109. data/lib/samagotchi/session_archive_command.rb +5 -1
  110. data/lib/samagotchi/session_commands.rb +1 -3
  111. data/lib/samagotchi/session_manager.rb +2 -4
  112. data/lib/samagotchi/session_metrics.rb +10 -10
  113. data/lib/samagotchi/session_observer.rb +3 -2
  114. data/lib/samagotchi/session_retention.rb +2 -2
  115. data/lib/samagotchi/sessions_command.rb +14 -5
  116. data/lib/samagotchi/steer.rb +103 -8
  117. data/lib/samagotchi/system_prompt.rb +19 -8
  118. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
  119. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  120. data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
  121. data/lib/samagotchi/terminal_ui/formatting.rb +19 -10
  122. data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
  123. data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
  124. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  125. data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
  126. data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
  127. data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
  128. data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
  129. data/lib/samagotchi/terminal_ui.rb +14 -5
  130. data/lib/samagotchi/text_diff.rb +12 -12
  131. data/lib/samagotchi/thinking.rb +6 -2
  132. data/lib/samagotchi/thought_stream_splitter.rb +15 -6
  133. data/lib/samagotchi/tool_activity.rb +1 -0
  134. data/lib/samagotchi/tool_call_parser.rb +13 -14
  135. data/lib/samagotchi/tool_declarations.rb +36 -35
  136. data/lib/samagotchi/tool_runner.rb +7 -0
  137. data/lib/samagotchi/tool_view.rb +40 -0
  138. data/lib/samagotchi/tools/ask_user_question.rb +3 -4
  139. data/lib/samagotchi/tools/builtin_calls.rb +1 -1
  140. data/lib/samagotchi/tools/delegate.rb +1 -1
  141. data/lib/samagotchi/tools/delegate_relay.rb +1 -1
  142. data/lib/samagotchi/tools/delegate_wait.rb +0 -1
  143. data/lib/samagotchi/tools/edit.rb +14 -13
  144. data/lib/samagotchi/tools/execute.rb +3 -5
  145. data/lib/samagotchi/tools/list_reminders.rb +1 -1
  146. data/lib/samagotchi/tools/memory.rb +21 -22
  147. data/lib/samagotchi/tools/read.rb +7 -7
  148. data/lib/samagotchi/tools/task_create.rb +1 -1
  149. data/lib/samagotchi/tools/task_get.rb +1 -1
  150. data/lib/samagotchi/tools/task_list.rb +1 -1
  151. data/lib/samagotchi/tools/task_runtime.rb +8 -9
  152. data/lib/samagotchi/tools/task_stop.rb +1 -1
  153. data/lib/samagotchi/tools/task_wait.rb +3 -4
  154. data/lib/samagotchi/tools/web_fetch.rb +2 -4
  155. data/lib/samagotchi/tools/write.rb +1 -1
  156. data/lib/samagotchi/turn_flow.rb +8 -8
  157. data/lib/samagotchi/turn_note.rb +5 -1
  158. data/lib/samagotchi/version.rb +1 -1
  159. data/lib/samagotchi/vision_support.rb +2 -2
  160. data/lib/samagotchi/waiting_steer.rb +64 -0
  161. data/lib/samagotchi/web/app.rb +13 -6
  162. data/lib/samagotchi/web/markdown_renderer.rb +2 -1
  163. data/lib/samagotchi/web/message_parts.rb +5 -0
  164. data/lib/samagotchi/web/public/activity.js +5 -0
  165. data/lib/samagotchi/web/public/app.js +2 -1
  166. data/lib/samagotchi/web/public/command_view.js +28 -0
  167. data/lib/samagotchi/web/public/copy.js +7 -0
  168. data/lib/samagotchi/web/public/index.html +8 -0
  169. data/lib/samagotchi/web/public/stage_model.js +11 -4
  170. data/lib/samagotchi/web/public/stage_view.js +5 -2
  171. data/lib/samagotchi/web/public/turn_events.js +15 -1
  172. data/lib/samagotchi/web/public/turn_view.js +15 -6
  173. data/lib/samagotchi/web/qr.rb +7 -1
  174. data/lib/samagotchi/web/server.rb +6 -2
  175. data/lib/samagotchi/worker.rb +14 -3
  176. metadata +24 -20
@@ -44,7 +44,8 @@ module Samagotchi
44
44
 
45
45
  FileUtils.mkdir_p(task_dir)
46
46
 
47
- output_io = File.open(output_path, "a")
47
+ # The child writes to it; the ensure below closes the parent's copy.
48
+ output_io = File.open(output_path, "a") # rubocop:disable Style/FileOpen
48
49
  output_io.sync = true
49
50
 
50
51
  wrapped_command = wrapped_shell_command(normalized_command, exit_code_path)
@@ -80,7 +81,7 @@ module Samagotchi
80
81
 
81
82
  write_record(record)
82
83
  [record, nil]
83
- rescue => e
84
+ rescue StandardError => e
84
85
  [nil, "Error: #{e.message}"]
85
86
  ensure
86
87
  output_io&.close
@@ -124,7 +125,7 @@ module Samagotchi
124
125
  return [nil, "Error: task not found: #{task_id}"] unless record
125
126
 
126
127
  [refresh_record(record), nil]
127
- rescue => e
128
+ rescue StandardError => e
128
129
  [nil, "Error: #{e.message}"]
129
130
  end
130
131
 
@@ -157,9 +158,7 @@ module Samagotchi
157
158
  end
158
159
 
159
160
  deadline = monotonic_time + STOP_GRACE_SEC
160
- while process_alive?(pid) && monotonic_time < deadline
161
- sleep(STOP_POLL_INTERVAL_SEC)
162
- end
161
+ sleep(STOP_POLL_INTERVAL_SEC) while process_alive?(pid) && monotonic_time < deadline
163
162
 
164
163
  if process_alive?(pid)
165
164
  begin
@@ -176,7 +175,7 @@ module Samagotchi
176
175
  write_record(updated)
177
176
 
178
177
  [updated, nil]
179
- rescue => e
178
+ rescue StandardError => e
180
179
  [nil, "Error: #{e.message}"]
181
180
  end
182
181
 
@@ -278,7 +277,7 @@ module Samagotchi
278
277
  return nil if rubyopt.empty?
279
278
 
280
279
  filtered = rubyopt.split(/\s+/).reject do |arg|
281
- arg == "-rbundler/setup" || arg.match?(/\A-r.*bundler\/setup\z/)
280
+ arg == "-rbundler/setup" || arg.match?(%r{\A-r.*bundler/setup\z})
282
281
  end
283
282
  return nil if filtered.empty?
284
283
 
@@ -318,7 +317,7 @@ module Samagotchi
318
317
  return nil if path.to_s.empty? || !File.exist?(path)
319
318
 
320
319
  Integer(File.read(path).strip, exception: false)
321
- rescue => _e
320
+ rescue StandardError => _e
322
321
  nil
323
322
  end
324
323
 
@@ -21,7 +21,7 @@ module Samagotchi
21
21
  "stop_reason: #{record["stop_reason"]}",
22
22
  "output_path: #{record.fetch("output_path")}"
23
23
  ].join("\n")
24
- rescue => e
24
+ rescue StandardError => e
25
25
  "Error: #{e.message}"
26
26
  end
27
27
  end
@@ -1,4 +1,3 @@
1
-
2
1
  # frozen_string_literal: true
3
2
 
4
3
  require_relative "task_runtime"
@@ -48,16 +47,17 @@ module Samagotchi
48
47
  return cancelled_response(task_id, tail_lines, started) if cancelled.call
49
48
 
50
49
  break if monotonic_time > deadline
50
+
51
51
  sleep(POLL_INTERVAL)
52
52
  end
53
53
 
54
54
  # Timeout reached — do one final refresh to capture the latest status
55
- refreshed, _ = TaskRuntime.get_record(task_id)
55
+ refreshed, = TaskRuntime.get_record(task_id)
56
56
  return format_response(refreshed) unless refreshed.fetch("status") == "running"
57
57
 
58
58
  output_tail = TaskRuntime.output_tail_lines(refreshed.fetch("output_path"), tail_lines)
59
59
  format_response(refreshed, wait_result: "timeout", output_tail: output_tail)
60
- rescue => e
60
+ rescue StandardError => e
61
61
  "Error: #{e.message}"
62
62
  end
63
63
 
@@ -125,4 +125,3 @@ module Samagotchi
125
125
  end
126
126
  end
127
127
  end
128
-
@@ -1,4 +1,3 @@
1
-
2
1
  # frozen_string_literal: true
3
2
 
4
3
  require "net/http"
@@ -42,7 +41,7 @@ module Samagotchi
42
41
  "Error: could not resolve hostname"
43
42
  rescue Timeout::Error
44
43
  "Error: request timed out after #{TIMEOUT_SEC}s"
45
- rescue => e
44
+ rescue StandardError => e
46
45
  "Error: #{e.message}"
47
46
  end
48
47
 
@@ -59,7 +58,7 @@ module Samagotchi
59
58
  IPAddr.new("::1/128"),
60
59
  IPAddr.new("fc00::/7"),
61
60
  IPAddr.new("fe80::/10"),
62
- IPAddr.new("::ffff:0:0/96"),
61
+ IPAddr.new("::ffff:0:0/96")
63
62
  ]
64
63
 
65
64
  # Try to parse host as IP directly
@@ -160,4 +159,3 @@ module Samagotchi
160
159
  end
161
160
  end
162
161
  end
163
-
@@ -22,7 +22,7 @@ module Samagotchi
22
22
  FileUtils.mkdir_p(File.dirname(path))
23
23
  File.write(path, content)
24
24
  "Written #{content.bytesize} bytes to #{path}"
25
- rescue => e
25
+ rescue StandardError => e
26
26
  "Error: #{e.message}"
27
27
  end
28
28
  end
@@ -27,10 +27,10 @@ module Samagotchi
27
27
  return [:resume, nil] if normalized.empty?
28
28
 
29
29
  lowered = normalized.downcase
30
- return [:resume, nil] if lowered == CONTINUE_COMMAND || lowered == "yes" || lowered == "y"
31
- return [:abort, nil] if lowered == "no" || lowered == "n"
30
+ return [:resume, nil] if [CONTINUE_COMMAND, "yes", "y"].include?(lowered)
31
+ return [:abort, nil] if %w[no n].include?(lowered)
32
32
 
33
- reason_match = normalized.match(/\A(?:no|n)\s*[,:\-]\s*(.+)\z/i)
33
+ reason_match = normalized.match(/\A(?:no|n)\s*[,:-]\s*(.+)\z/i)
34
34
  if reason_match
35
35
  reason = reason_match[1].to_s.strip
36
36
  return [:abort, nil] if reason.empty?
@@ -220,11 +220,11 @@ module Samagotchi
220
220
  lines << "- original_prompt: #{original_prompt.to_s.empty? ? "(unavailable)" : original_prompt}"
221
221
 
222
222
  tool_trace = context ? Array(context[:tool_trace]) : []
223
- if tool_trace.empty?
224
- lines << "- interrupted_tools: (none)"
225
- else
226
- lines << "- interrupted_tools: #{tool_trace.join("; ")}"
227
- end
223
+ lines << if tool_trace.empty?
224
+ "- interrupted_tools: (none)"
225
+ else
226
+ "- interrupted_tools: #{tool_trace.join("; ")}"
227
+ end
228
228
 
229
229
  model_intent = context && context[:last_model_intent]
230
230
  lines << "- last_model_intent: #{model_intent.to_s.empty? ? "(unavailable)" : model_intent}"
@@ -98,7 +98,11 @@ module Samagotchi
98
98
  # emptyAnswerLine words it the same).
99
99
  def empty_answer_line(retries)
100
100
  count = retries.to_i
101
- after = count.positive? ? " (after #{count} #{count == 1 ? "retry" : "retries"})" : ""
101
+ after = if count.positive?
102
+ " (after #{count} #{count == 1 ? "retry" : "retries"})"
103
+ else
104
+ ""
105
+ end
102
106
  "no answer: the model returned nothing#{after}"
103
107
  end
104
108
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.22.0"
4
+ VERSION = "0.24.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -45,7 +45,7 @@ module Samagotchi
45
45
  marker.is_a?(String) && !marker.empty? ? marker : nil
46
46
  end
47
47
 
48
- def self.configured(target, entry, models, names)
48
+ def self.configured(_target, entry, models, names)
49
49
  models ||= begin
50
50
  ConfigFile.model_settings
51
51
  rescue StandardError
@@ -58,7 +58,7 @@ module Samagotchi
58
58
  Answer.new(value: entry.vision, reason: entry.vision ? nil : "hosts.#{entry.name} sets vision: false")
59
59
  end
60
60
 
61
- def self.native(target, entry, profile, trust_config:)
61
+ def self.native(target, _entry, profile, trust_config:)
62
62
  client = target.client
63
63
  transport = client.transport.name
64
64
  return no("#{transport} hosts take no images (only llama.cpp does)") unless transport == :llama_cpp
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A message for the running turn that may cut its generation but came too
5
+ # early (the generation hadn't streamed only thinking for long enough):
6
+ # it waits here until the thinking passes steer.cut_after (the Engine
7
+ # checks on each thinking chunk), or until a drain hands the message to
8
+ # the model at a boundary, or the turn ends. One cut covers every waiting
9
+ # message: the first one's source names it.
10
+ #
11
+ # The epoch counts the drains that took input. A caller reads it before
12
+ # it queues its message; a drain after that may have taken the message
13
+ # already, so the message doesn't wait (it may be in the model's prompt).
14
+ # Written from the UIs' threads and the turn's; its own Mutex.
15
+ class WaitingSteer
16
+ def initialize
17
+ @mutex = Mutex.new
18
+ @source = nil
19
+ @epoch = 0
20
+ end
21
+
22
+ # @return [Integer] the drains that took input so far
23
+ def epoch
24
+ @mutex.synchronize { @epoch }
25
+ end
26
+
27
+ # +source+'s message waits for a cut, unless a drain took input since
28
+ # +epoch+. Returns whether it waits.
29
+ def wait!(source, epoch: nil)
30
+ @mutex.synchronize do
31
+ next false if epoch && epoch != @epoch
32
+
33
+ @source ||= source.to_s
34
+ true
35
+ end
36
+ end
37
+
38
+ def waiting?
39
+ @mutex.synchronize { !@source.nil? }
40
+ end
41
+
42
+ # The waiting source, taken (nil when none waits).
43
+ def take
44
+ @mutex.synchronize do
45
+ source = @source
46
+ @source = nil
47
+ source
48
+ end
49
+ end
50
+
51
+ # A drain took input: what waited is delivered.
52
+ def delivered!
53
+ @mutex.synchronize do
54
+ @epoch += 1
55
+ @source = nil
56
+ end
57
+ end
58
+
59
+ # The turn ended, or a cut happened: nothing waits.
60
+ def clear!
61
+ @mutex.synchronize { @source = nil }
62
+ end
63
+ end
64
+ end
@@ -157,6 +157,7 @@ module Samagotchi
157
157
  req = Rack::Request.new(env)
158
158
  return forbidden unless local_host_header?(env)
159
159
  return cross_origin if cross_site?(env)
160
+
160
161
  if @lan && !LOOPBACK_PEERS.include?(env["REMOTE_ADDR"].to_s)
161
162
  denied = token_gate(req)
162
163
  return denied if denied
@@ -506,13 +507,16 @@ module Samagotchi
506
507
 
507
508
  def sanitize_limit(val)
508
509
  return nil if val.nil? || val.to_s.strip.empty?
510
+
509
511
  n = val.to_i
510
512
  return nil if n <= 0
513
+
511
514
  [n, 1000].min
512
515
  end
513
516
 
514
517
  def sanitize_offset(val)
515
518
  return 0 if val.nil? || val.to_s.strip.empty?
519
+
516
520
  n = val.to_i
517
521
  n.positive? ? n : 0
518
522
  end
@@ -522,6 +526,7 @@ module Samagotchi
522
526
  unless body.is_a?(Hash)
523
527
  return error_response(400, "invalid_json", "invalid JSON body")
524
528
  end
529
+
525
530
  prompt = body["prompt"] || body[:prompt]
526
531
  # idle: a session with no first turn, for a first message with
527
532
  # images (the page uploads them into it, then sends the turn).
@@ -529,6 +534,7 @@ module Samagotchi
529
534
  if prompt.to_s.strip.empty? && !idle
530
535
  return error_response(400, "missing_fields", "prompt is required")
531
536
  end
537
+
532
538
  # dir: the folder the chat starts in (the page's scope); without it,
533
539
  # the server's own cwd.
534
540
  dir, error = scope_dir(body["dir"])
@@ -891,11 +897,13 @@ module Samagotchi
891
897
  unless body.is_a?(Hash)
892
898
  return error_response(400, "invalid_json", "invalid JSON body")
893
899
  end
900
+
894
901
  prompt = body["prompt"] || body[:prompt]
895
902
  client_id = body["client_id"] || body[:client_id]
896
903
  if prompt.to_s.strip.empty?
897
904
  return error_response(400, "missing_fields", "prompt is required")
898
905
  end
906
+
899
907
  images = turn_images(id, body["images"])
900
908
  return error_response(400, "bad_images", images) if images.is_a?(String)
901
909
 
@@ -1173,7 +1181,7 @@ module Samagotchi
1173
1181
  lambda do |io|
1174
1182
  query = req.query_string.to_s.empty? ? "" : "?#{req.query_string}"
1175
1183
  body = ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: query, headers: req.env,
1176
- server_running: @server_running)
1184
+ server_running: @server_running)
1177
1185
  body.each { |chunk| io.write(chunk) }
1178
1186
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
1179
1187
  nil # client went away — end the stream quietly
@@ -1249,7 +1257,7 @@ module Samagotchi
1249
1257
  def serve_static(req)
1250
1258
  # Serve files under public_dir for /assets/* and bare paths
1251
1259
  rel = req.path_info.sub(%r{\A/(assets|public)/}, "")
1252
- rel = req.path_info.sub(%r{\A/}, "") if rel == req.path_info
1260
+ rel = req.path_info.delete_prefix("/") if rel == req.path_info
1253
1261
  # Prevent directory traversal
1254
1262
  rel = rel.split("/").reject { |p| p == ".." || p.empty? }.join("/")
1255
1263
  full = File.join(@public_dir, rel)
@@ -1483,7 +1491,7 @@ module Samagotchi
1483
1491
 
1484
1492
  norm_role = role == "model" ? "assistant" : role
1485
1493
  # normalize assistant vs model, keep user as is
1486
- norm_role = "assistant" if norm_role == "assistant" || norm_role == "model"
1494
+ norm_role = "assistant" if %w[assistant model].include?(norm_role)
1487
1495
  norm_role = "user" if norm_role == "user"
1488
1496
  next unless %w[user assistant].include?(norm_role)
1489
1497
 
@@ -1644,7 +1652,6 @@ module Samagotchi
1644
1652
  json_response(status, { error: code, detail: detail })
1645
1653
  end
1646
1654
 
1647
-
1648
1655
  def not_found(path:)
1649
1656
  error_response(404, "not_found", "not found: #{path}")
1650
1657
  end
@@ -1740,12 +1747,12 @@ module Samagotchi
1740
1747
  @server_running = server_running
1741
1748
  end
1742
1749
 
1743
- def each(&block)
1750
+ def each(&)
1744
1751
  # Forward the browser's auto-reconnect cursor: the bridge prefers the
1745
1752
  # Last-Event-ID header over ?from_seq, and the reconnect URL carries a
1746
1753
  # stale initial cursor — without this the bridge would replay content
1747
1754
  # already delivered (duplicate bubbles).
1748
- @client.stream(query: @query, last_event_id: @headers["HTTP_LAST_EVENT_ID"], running: @server_running, &block)
1755
+ @client.stream(query: @query, last_event_id: @headers["HTTP_LAST_EVENT_ID"], running: @server_running, &)
1749
1756
  end
1750
1757
  end
1751
1758
  end
@@ -34,7 +34,7 @@ module Samagotchi
34
34
 
35
35
  html = Commonmarker.to_html(markdown.to_s, options: {
36
36
  render: { escape: true }, # escape raw HTML from source; highlighter output is still emitted
37
- extension: { header_ids: nil },
37
+ extension: { header_ids: nil }
38
38
  })
39
39
  return nil unless html
40
40
 
@@ -71,6 +71,7 @@ module Samagotchi
71
71
  def sanitize_attributes(node)
72
72
  node.attribute_nodes.each do |attribute|
73
73
  next if allowed_attribute?(node, attribute.name)
74
+
74
75
  node.remove_attribute(attribute.name)
75
76
  end
76
77
  return unless node.name == "a"
@@ -3,6 +3,7 @@
3
3
  require_relative "../model_profile"
4
4
  require_relative "../tool_call_parser"
5
5
  require_relative "../tool_activity"
6
+ require_relative "../tool_view"
6
7
  require_relative "../llm/native_tool_normalizer"
7
8
  require_relative "../tools/builtins"
8
9
  require_relative "../image_store"
@@ -158,6 +159,10 @@ module Samagotchi
158
159
  part = { tool: name, params: params.to_s }
159
160
  title = ToolActivity.tool_title(name, call, cwd: cwd)
160
161
  part[:title] = title if title
162
+ # Built from the saved call (never saved itself), so older sessions
163
+ # get it too.
164
+ view = ToolView.for(name, call)&.to_h
165
+ part[:view] = view if view
161
166
  part[:label] = label if label.is_a?(String) && !label.empty?
162
167
  unless output.nil?
163
168
  part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
@@ -50,6 +50,7 @@ export function addStarted(model, event = {}) {
50
50
  row.label = event.label || row.label;
51
51
  row.params = event.params || row.params;
52
52
  row.title = event.title || row.title;
53
+ row.view = event.view || row.view;
53
54
  return { row, created: false };
54
55
  }
55
56
  row = {
@@ -63,6 +64,9 @@ export function addStarted(model, event = {}) {
63
64
  // project-relative path, a command without its "cd … &&"); rows show it
64
65
  // in place of the params when there is one.
65
66
  title: event.title || null,
67
+ // The call's full arguments for the expanded row and the hovers (the
68
+ // server's ToolView: an execute/task_create's command, its cwd).
69
+ view: event.view || null,
66
70
  status: ROW_STATUS.RUNNING,
67
71
  output: "",
68
72
  output_truncated: false,
@@ -87,6 +91,7 @@ export function addCompleted(model, event = {}) {
87
91
  label: event.label,
88
92
  params: event.activity?.params || "",
89
93
  title: event.activity?.title,
94
+ view: event.view,
90
95
  });
91
96
  row = synthesized.row;
92
97
  }
@@ -45,7 +45,7 @@ import {
45
45
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
46
46
  } from "./timing.js";
47
47
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
48
- import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
48
+ import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
49
49
 
50
50
  const $ = (s) => document.querySelector(s);
51
51
  const topStripEl = $("#topStrip");
@@ -1861,6 +1861,7 @@ const streamHandlers = {
1861
1861
  // The loop asks again after an empty answer: a row of the empty step
1862
1862
  // (a reload puts it back from the snapshot's cards).
1863
1863
  empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
1864
+ steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data) }); },
1864
1865
  // The provider is asked again after an error, or the turn waits for
1865
1866
  // plugins' setup: a note on the live timing line until it streams.
1866
1867
  generation_retrying: (data) => setLiveTurnNote(retryStatusLine(data)),
@@ -0,0 +1,28 @@
1
+ // A tool call's full command in a richer UI than the TUI's params line:
2
+ // the server's view (ToolView: an execute/task_create's command uncut up to
3
+ // 8,000 chars, the call's cwd: argument). Pure HTML builders, shared by the
4
+ // live row (turn_view.js fillActivityRow) and the reloaded one
5
+ // (reloadRowHtml), so the two can't differ.
6
+ import { copyButtonHtml } from "./copy.js";
7
+ import { escapeHtml } from "./format.js";
8
+
9
+ // What a row's title hovers: the full command when the call has one,
10
+ // else the params line when the row shows a title in its place.
11
+ export function rowHover(row) {
12
+ if (row?.view?.command) return row.view.command;
13
+ return row?.title && row?.params ? row.params : "";
14
+ }
15
+
16
+ // The expanded row's command block: "in <cwd>" when the call gave one (as
17
+ // given: execute's may be relative to the project root), the command as
18
+ // written (whitespace kept, its own scroll), a copy button, and a line
19
+ // saying how much a cut command left out. "" for a call without a view.
20
+ export function commandBlockHtml(view) {
21
+ const command = view?.command;
22
+ if (!command) return "";
23
+ const cwd = view.cwd ? `<div class="activity-command-cwd">in <span>${escapeHtml(view.cwd)}</span></div>` : "";
24
+ const cut = view.truncated
25
+ ? `<div class="activity-command-cut">… (${command.length.toLocaleString("en-US")} of ${Number(view.chars || 0).toLocaleString("en-US")} chars)</div>`
26
+ : "";
27
+ return `<div class="activity-command code-wrap">${cwd}<pre><code>${escapeHtml(command)}</code></pre>${copyButtonHtml("code")}${cut}</div>`;
28
+ }
@@ -88,6 +88,13 @@ const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"
88
88
  const BUBBLES = ".bubble.output:not(.streaming), .bubble.user";
89
89
  const CODE = ".bubble.output pre";
90
90
 
91
+ // The button as markup, for a builder that returns HTML (a tool row's
92
+ // command block, command_view.js); the same click handler answers it.
93
+ export function copyButtonHtml(kind) {
94
+ const title = kind === "code" ? "Copy code" : "Copy";
95
+ return `<button type="button" class="copy-btn copy-${kind}" title="${title}" aria-label="${title}">${ICON}</button>`;
96
+ }
97
+
91
98
  function makeButton(doc, kind) {
92
99
  const btn = doc.createElement("button");
93
100
  btn.type = "button";
@@ -134,10 +134,18 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
134
134
  .activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
135
135
  .activity-diff>summary:hover{color:var(--text-2)}
136
136
  .activity-diff[open]>summary{margin-bottom:4px}
137
+ /* An execute/task_create's full command, above its output and lined up with it: as written, its own scroll. */
138
+ .activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px 60px}
139
+ .activity-command pre{margin:0;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.45;color:var(--text-2);background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;padding:6px 34px 6px 8px}
140
+ .activity-command-cwd,.activity-command-cut{font-size:11px;color:var(--text-faint)}
141
+ .activity-command-cwd{margin-bottom:2px}
142
+ .activity-command-cwd>span{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-muted)}
143
+ .activity-command-cut{margin-top:2px}
137
144
  @media(max-width:600px){
138
145
  .activity-params{flex:1 1 0}
139
146
  .activity-output{margin-left:0}
140
147
  .activity-diff{margin-left:0}
148
+ .activity-row>.activity-command{margin-left:0}
141
149
  }
142
150
  .bubble.thinking{border-left:3px solid var(--think);background:color-mix(in srgb,var(--think) 6%,var(--bg-panel));padding:8px 12px}
143
151
  .bubble.thinking:not([open]),.bubble.activity:not([open]){padding:3px 10px;background:transparent;border-color:transparent;align-self:flex-start;border-radius:6px;transition:background .12s ease}
@@ -78,6 +78,12 @@ function rowTitle(row) {
78
78
  return row.title || row.params || "";
79
79
  }
80
80
 
81
+ // A slot's full command (the row's view), for its hover; none for a tool
82
+ // without one.
83
+ function withCommand(slot, row) {
84
+ return row.view?.command ? { ...slot, command: row.view.command } : slot;
85
+ }
86
+
81
87
  // While the first step has no thinking and no tool rows the turn is plain:
82
88
  // its text streams in the stage's answer slot, as the turn view's
83
89
  // chrome-less block streams like a bubble.
@@ -98,9 +104,10 @@ const NO_ANSWER = "no answer";
98
104
  // headline the newest narration sentence (+narration+, the view's sentence
99
105
  // feed), else the newest thinking one (+thinking+, its ticker),
100
106
  // else "…";
101
- // tool the running call ({name, title, kind}) or null;
107
+ // tool the running call ({name, title, kind}, and command: its full
108
+ // command when it has one) or null;
102
109
  // trail the last three finished calls, newest last (a file's title
103
- // without its dir);
110
+ // without its dir; command as for tool);
104
111
  // ticks one per call: {kind, status, step (the gen's index)}.
105
112
  // +pending+: a card waits for the user; +ended+: the turn's end kind;
106
113
  // +emptyAnswer+: a completed turn ended with no answer
@@ -123,13 +130,13 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
123
130
  else if (thinking) headline = { text: thinking, thinking: true };
124
131
  const trail = rows.filter(({ row }) => row.status !== "running").slice(-TRAIL).map(({ row }) => {
125
132
  const title = rowTitle(row);
126
- return { name: toolName(row), title: FILE_TOOLS.has(row.tool) ? title.split("/").pop() : title, status: row.status };
133
+ return withCommand({ name: toolName(row), title: FILE_TOOLS.has(row.tool) ? title.split("/").pop() : title, status: row.status }, row);
127
134
  });
128
135
  return {
129
136
  phase,
130
137
  step: ended ? gens.filter((g) => !g.answer || g.thinking || g.tools.length).length : gens.length,
131
138
  headline,
132
- tool: running ? { name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) } : null,
139
+ tool: running ? withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running) : null,
133
140
  trail,
134
141
  ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
135
142
  };
@@ -195,6 +195,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
195
195
  const elapsed = endKind ? formatDuration(endedAt - startedAt) : "";
196
196
  chipLabel.textContent = `${slots.step} step${slots.step === 1 ? "" : "s"} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
197
197
  miniTool.textContent = collapsed && slots.tool ? `${slots.tool.name} ${slots.tool.title}` : "";
198
+ miniTool.title = (collapsed && slots.tool?.command) || "";
198
199
 
199
200
  live.hidden = plain || !!endKind;
200
201
  cloud.hidden = !blockEl || blockEl.parentNode !== cloudBody;
@@ -222,7 +223,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
222
223
 
223
224
  function drawNow(tool, gen) {
224
225
  const quiet = gen?.text && !gen.textDone ? "writing…" : "thinking…";
225
- const key = tool ? `run|${tool.name}|${tool.title}` : `quiet|${quiet}`;
226
+ const key = tool ? `run|${tool.name}|${tool.title}|${tool.command || ""}` : `quiet|${quiet}`;
226
227
  if (shown.now === key) return;
227
228
  shown.now = key;
228
229
  now.replaceChildren();
@@ -233,7 +234,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
233
234
  name.textContent = tool.name;
234
235
  const title = node("span", "ts-title", now);
235
236
  title.textContent = tool.title;
236
- title.title = tool.title;
237
+ // The full command when the call has one (the title is its first line).
238
+ title.title = tool.command || tool.title;
237
239
  } else {
238
240
  const word = node("span", "ts-title quiet", now);
239
241
  word.textContent = quiet;
@@ -251,6 +253,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
251
253
  const failed = item.status === "error";
252
254
  const el = node("span", `ts-trail-item${failed ? " error" : ""}`, trail);
253
255
  el.textContent = `${failed ? "✕" : "✓"} ${item.name} ${item.title}`.trim();
256
+ if (item.command) el.title = item.command;
254
257
  }
255
258
  if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
256
259
  }
@@ -123,6 +123,17 @@ export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy } = {}) {
123
123
  return `↻ ${what}, asking again (${attempt}/${of})`;
124
124
  }
125
125
 
126
+ // The row a step gets when a message for the running turn cut a generation
127
+ // that was only thinking (steer_cut; source "" is the user's, as is an
128
+ // unknown one). The TUI words it the same (spec/shared/labels_matrix.json).
129
+ const STEER_CUT_FOR = {
130
+ chi_send: "a message sent with chi send",
131
+ parent_agent: "the parent agent's message",
132
+ };
133
+ export function steerCutLine({ source } = {}) {
134
+ return `↪ cut in for ${STEER_CUT_FOR[source] || "your message"}`;
135
+ }
136
+
126
137
  // The one muted line for a turn that ended with no answer (turn_completed's
127
138
  // turn_summary.empty_answer, or a reloaded turn's marker): the TUI words
128
139
  // it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
@@ -136,6 +147,7 @@ export function emptyAnswerLine({ retries = 0 } = {}) {
136
147
  // notice as "<label>: text", the loop's asking-again row as its line.
137
148
  export function noticeLine(entry = {}) {
138
149
  if (entry.type === "empty_answer_retry") return emptyRetryLine(entry);
150
+ if (entry.type === "steer_cut") return steerCutLine(entry);
139
151
  return `${hookNoticeLabel(entry.hook)}: ${entry.text || ""}`;
140
152
  }
141
153
 
@@ -451,7 +463,8 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
451
463
  const call = { iteration: part.iteration, call_index: part.call_index, tool: part.tool };
452
464
  if (part.label) call.label = part.label;
453
465
  const title = part.title ? { title: part.title } : {};
454
- events.push({ type: "tool_call_started", ...call, params: part.params, ...title });
466
+ const view = part.view ? { view: part.view } : {};
467
+ events.push({ type: "tool_call_started", ...call, params: part.params, ...title, ...view });
455
468
  if (part.status !== "running") {
456
469
  // The activity as the live event had it; action and duration_ms
457
470
  // only in snapshots from a worker that keeps them (the TUI's
@@ -463,6 +476,7 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
463
476
  };
464
477
  if (part.duration_ms != null) completed.duration_ms = part.duration_ms;
465
478
  if (part.diff) completed.diff = part.diff;
479
+ if (part.view) completed.view = part.view;
466
480
  events.push(withImages(completed, part.images));
467
481
  }
468
482
  break;