samagotchi 0.25.0 → 0.27.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/docs/architecture.md +6 -0
  4. data/docs/configuration.md +1 -0
  5. data/docs/hooks.md +1 -1
  6. data/docs/internals/engine.md +478 -0
  7. data/docs/internals/prompt-caching.md +33 -7
  8. data/docs/plugins.md +3 -2
  9. data/lib/samagotchi/bridge.rb +37 -1
  10. data/lib/samagotchi/bridge_client.rb +9 -0
  11. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -5
  13. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/cancellation_controller.rb +8 -0
  17. data/lib/samagotchi/client.rb +53 -3
  18. data/lib/samagotchi/config.rb +9 -2
  19. data/lib/samagotchi/engine.rb +50 -7
  20. data/lib/samagotchi/kernel_loop.rb +39 -3
  21. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  22. data/lib/samagotchi/llm/http.rb +5 -4
  23. data/lib/samagotchi/log.rb +19 -3
  24. data/lib/samagotchi/prompt_warmup.rb +95 -0
  25. data/lib/samagotchi/terminal_ui.rb +2 -0
  26. data/lib/samagotchi/thinking_tails.rb +4 -3
  27. data/lib/samagotchi/tool_activity.rb +3 -2
  28. data/lib/samagotchi/tool_declarations.rb +1 -1
  29. data/lib/samagotchi/tool_view.rb +14 -3
  30. data/lib/samagotchi/tools/task_get.rb +3 -2
  31. data/lib/samagotchi/tools/task_runtime.rb +49 -12
  32. data/lib/samagotchi/tools/task_stop.rb +1 -1
  33. data/lib/samagotchi/tools/task_wait.rb +22 -10
  34. data/lib/samagotchi/turn_note.rb +28 -2
  35. data/lib/samagotchi/turn_state.rb +6 -0
  36. data/lib/samagotchi/version.rb +1 -1
  37. data/lib/samagotchi/web/app.rb +25 -0
  38. data/lib/samagotchi/web/markdown_renderer.rb +21 -6
  39. data/lib/samagotchi/web/public/app.js +4 -0
  40. data/lib/samagotchi/web/public/command_view.js +6 -3
  41. data/lib/samagotchi/web/public/data.js +11 -0
  42. data/lib/samagotchi/web/public/index.html +39 -13
  43. data/lib/samagotchi/web/public/stage_model.js +8 -2
  44. data/lib/samagotchi/web/public/stage_view.js +14 -2
  45. data/lib/samagotchi/web/public/task_stop.js +101 -0
  46. data/lib/samagotchi/web/public/turn_view.js +24 -1
  47. data/lib/samagotchi/worker.rb +3 -0
  48. metadata +4 -1
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "log"
5
+
6
+ module Samagotchi
7
+ # The turn-end warm-up (cache.warmup). After a turn, the next turn's prompt
8
+ # differs from the last request at its tail: the previous answer's
9
+ # thinking is stripped (Gemma's and Qwen's specs), so a hybrid model on
10
+ # llama.cpp prefills from a restore point well before the end, longer
11
+ # with every turn. The warm-up sends that prompt, up to where the new user
12
+ # turn starts, while the user reads, so the next turn prefills only its
13
+ # own message (measured on Ornith: 0.73 s -> 0.11 s; 417 -> 26 tokens).
14
+ #
15
+ # The slot rule (R4 spike, llama.cpp with its host-memory prompt cache):
16
+ # - the warm-up runs on the slot the turn's last request used, which still
17
+ # holds its state (Client#warm_up, `id_slot`);
18
+ # - the next request is pinned to that slot only while the warm-up still
19
+ # runs, so it queues behind it (0.6 s) instead of starting cold on
20
+ # another slot (12 s);
21
+ # - once the warm-up is done, nothing is pinned: the server finds the
22
+ # state itself, and pinning to a slot it has since cleared skips its
23
+ # prompt cache (12 s again). #take_pin forgets the slot either way.
24
+ class PromptWarmup
25
+ MODES = %w[auto off].freeze
26
+
27
+ # Whether cache.warmup allows a warm-up (auto, the default; off, false,
28
+ # no or 0 turn it off; anything else warns once and is auto).
29
+ def self.enabled?
30
+ value = Config.get("cache.warmup").to_s.strip.downcase
31
+ return false if %w[off false no 0].include?(value)
32
+
33
+ unless value.empty? || value == "auto" || @warned
34
+ @warned = true
35
+ Log.warn(:config, "invalid_value", echo: "Warning: invalid value for cache.warmup: #{value.inspect} (allowed: auto, off) — using auto",
36
+ key: "cache.warmup")
37
+ end
38
+ true
39
+ rescue StandardError
40
+ true
41
+ end
42
+
43
+ def initialize
44
+ @mutex = Mutex.new
45
+ # The pin (#take_pin forgets it) and the last warm-up's thread.
46
+ @state = nil
47
+ @thread = nil
48
+ end
49
+
50
+ # Start a warm-up of +prompt+ on +client+ in its own thread.
51
+ # @param slot [Integer, nil] the last request's slot (nil: unpinned, and
52
+ # nothing to pin the next request to)
53
+ # @return [Thread]
54
+ def start(client:, prompt:, model:, slot:, images: [])
55
+ thread = Thread.new do
56
+ Thread.current.report_on_exception = false
57
+ result = client.warm_up(prompt, model: model, slot: slot, images: images)
58
+ Log.info(:model, "warmup", slot: result&.slot, cached: result&.cache_n, prefilled: result&.prompt_n,
59
+ prefill_ms: result&.prompt_ms, chars: prompt.length)
60
+ result
61
+ rescue StandardError => e
62
+ Log.warn(:model, "warmup_failed", error: e.class.name, msg: e.message.to_s[0, 200])
63
+ nil
64
+ end
65
+ @mutex.synchronize do
66
+ @thread = thread
67
+ @state = { thread: thread, client: client, slot: slot }
68
+ end
69
+ thread
70
+ end
71
+
72
+ # The slot the next request on +client+ must run on: the warm-up's,
73
+ # while it still runs there; nil otherwise (done, failed, unpinned, or
74
+ # another host). Called once per request; the first call forgets the
75
+ # warm-up, so no later request is ever pinned.
76
+ def take_pin(client)
77
+ state = @mutex.synchronize { @state.tap { @state = nil } }
78
+ return nil unless state && state[:slot].is_a?(Integer) && state[:client].equal?(client)
79
+
80
+ state[:thread].alive? ? state[:slot] : nil
81
+ end
82
+
83
+ # Whether a warm-up is running (specs, measurements).
84
+ def running?
85
+ thread = @mutex.synchronize { @thread }
86
+ thread&.alive? || false
87
+ end
88
+
89
+ # Wait up to +timeout+ seconds for the running warm-up (specs).
90
+ def wait(timeout = nil)
91
+ thread = @mutex.synchronize { @thread }
92
+ thread&.join(timeout)
93
+ end
94
+ end
95
+ 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.)
@@ -23,7 +23,7 @@ module Samagotchi
23
23
  class ThinkingTails
24
24
  FILE = "thinking_tails.jsonl"
25
25
  # The last chars of a generation's thinking kept: a loop's cycle and
26
- # some of its lead-in.
26
+ # some of its lead-in. TurnNote cuts an empty step's thinking to it too.
27
27
  TAIL_CHARS = 20_000
28
28
  MAX_RECORDS = 10
29
29
 
@@ -55,8 +55,9 @@ module Samagotchi
55
55
  when :generation_chunk then add(event[:thinking].to_s)
56
56
  when :generation_completed then completed(event)
57
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"
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"
60
61
  end
61
62
  rescue StandardError
62
63
  nil
@@ -150,12 +150,13 @@ module Samagotchi
150
150
  # "error" for an "Error:" result, and for an execute whose command
151
151
  # exited non-zero (its last "exit: N" line; no number: killed by a
152
152
  # signal), so the turn tally's "(N failed)" counts it. "stopped" for a
153
- # task_wait the user's Stop ended: the task itself runs on.
153
+ # task_wait the user's Stop ended (the task itself runs on), and for one
154
+ # whose task was stopped (the stop-task button, or the model's task_stop).
154
155
  def tool_activity_status(result, tool_name = nil)
155
156
  text = result.to_s
156
157
  return "error" if text.start_with?("Error:")
157
158
  return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
158
- return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^wait_result: canceled$/)
159
+ return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^(wait_result: canceled|status: stopped)$/)
159
160
 
160
161
  "ok"
161
162
  end
@@ -28,7 +28,7 @@ module Samagotchi
28
28
  },
29
29
  description: {
30
30
  type: "string",
31
- description: "Optional: what this command does, in a few words (what, not why)"
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
32
  },
33
33
  cwd: {
34
34
  type: "string",
@@ -3,6 +3,7 @@
3
3
  require_relative "command_steps"
4
4
  require_relative "tools/execute"
5
5
  require_relative "tools/task_create"
6
+ require_relative "tools/task_wait"
6
7
 
7
8
  module Samagotchi
8
9
  # What a richer UI shows of a tool call beyond its one-line params: the
@@ -18,8 +19,9 @@ module Samagotchi
18
19
  # can't (the fallback: the UI shows the command itself) or the command
19
20
  # is cut. +description+: what the model said the command does (execute's
20
21
  # 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
22
+ # nil when it gave none. +task_id+: the task a task_wait waits on (its
23
+ # only field), for the web's stop-task button.
24
+ ToolView = Data.define(:command, :cwd, :truncated, :chars, :cd, :steps, :description, :task_id) do
23
25
  def to_h = super.merge(steps: steps&.map(&:to_h)).compact.reject { |_key, value| value == false }
24
26
  end
25
27
 
@@ -37,6 +39,7 @@ module Samagotchi
37
39
  # @return [ToolView, nil] nil for a tool without a view and for an
38
40
  # empty command
39
41
  def self.for(tool_name, call)
42
+ return task_wait(call) if tool_name == Tools::TaskWait::NAME && call.is_a?(Hash)
40
43
  return nil unless TOOLS.include?(tool_name) && call.is_a?(Hash)
41
44
 
42
45
  command = call[:content].to_s
@@ -47,9 +50,17 @@ module Samagotchi
47
50
  parsed = truncated ? nil : CommandSteps.parse(command)
48
51
  new(command: truncated ? command[0, COMMAND_LIMIT] : command, cwd: cwd.empty? ? nil : cwd,
49
52
  truncated: truncated, chars: truncated ? command.length : nil, cd: parsed&.cd, steps: parsed&.steps,
50
- description: description(call))
53
+ description: description(call), task_id: nil)
51
54
  end
52
55
 
56
+ def self.task_wait(call)
57
+ task_id = call[:content].to_s.strip
58
+ return nil if task_id.empty?
59
+
60
+ new(command: nil, cwd: nil, truncated: false, chars: nil, cd: nil, steps: nil, description: nil, task_id: task_id)
61
+ end
62
+ private_class_method :task_wait
63
+
53
64
  # The call's description as a view shows it, nil for none.
54
65
  def self.description(call)
55
66
  text = call[:description]
@@ -30,8 +30,9 @@ module Samagotchi
30
30
  "finished_at: #{record["finished_at"]}",
31
31
  "exit_code: #{record["exit_code"]}",
32
32
  "stop_reason: #{record["stop_reason"]}",
33
- "output_path: #{record.fetch("output_path")}"
34
- ].join("\n")
33
+ "output_path: #{record.fetch("output_path")}",
34
+ (TaskRuntime::USER_STOP_NOTE if TaskRuntime.stopped_by_user?(record))
35
+ ].compact.join("\n")
35
36
  end
36
37
  private_class_method :format_record
37
38
  end
@@ -22,6 +22,9 @@ module Samagotchi
22
22
  SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
23
23
  # Set by chi (Builtins.parent_env); the model's env can't change it.
24
24
  MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
25
+ # task_wait and task_get add it to a task the user stopped (the web's
26
+ # stop-task button), so the model doesn't rerun the job.
27
+ USER_STOP_NOTE = "note: the user stopped this task; don't restart it unless they ask."
25
28
 
26
29
  module_function
27
30
 
@@ -107,19 +110,27 @@ module Samagotchi
107
110
  # A native tool_response joins its calls' results with "---".
108
111
  # @return [Array<Hash>] {id:, command:}, oldest first
109
112
  def running_created_in(messages)
110
- ids = Array(messages).flat_map do |message|
113
+ created_ids_in(messages).filter_map do |id|
114
+ record, _error = get_record(id)
115
+ { id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
116
+ end
117
+ end
118
+
119
+ # The ids of every task this conversation's task_create calls started,
120
+ # whatever their status now, oldest first.
121
+ # @return [Array<String>]
122
+ def created_ids_in(messages)
123
+ Array(messages).flat_map do |message|
111
124
  next [] unless (message[:role] || message["role"]).to_s == "tool_response"
112
125
 
113
126
  (message[:content] || message["content"]).to_s.split("\n\n---\n\n").filter_map do |block|
114
127
  block[/\A\[task_create\]\ntask_id: (\S+)/, 1]
115
128
  end
116
- end
117
- ids.uniq.filter_map do |id|
118
- record, _error = get_record(id)
119
- { id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
120
- end
129
+ end.uniq
121
130
  end
122
131
 
132
+ def stopped_by_user?(record) = record["stop_reason"] == "stopped_by_user"
133
+
123
134
  def get_record(task_id)
124
135
  record = load_record(task_id)
125
136
  return [nil, "Error: task not found: #{task_id}"] unless record
@@ -142,13 +153,19 @@ module Samagotchi
142
153
  content.lines.last(line_count).join
143
154
  end
144
155
 
145
- def stop_task(task_id)
156
+ # A requested stop ends as "stopped" whatever the exit code: the flag
157
+ # is on disk before the TERM, so a reader refreshing the record while
158
+ # the group dies records the stop, not a failure.
159
+ # @param by [String] "model" (task_stop) or "user" (the web's button)
160
+ def stop_task(task_id, by:)
146
161
  record = load_record(task_id)
147
162
  return [nil, "Error: task not found: #{task_id}"] unless record
148
163
 
149
164
  refreshed = refresh_record(record)
150
165
  return [refreshed, nil] unless refreshed["status"] == "running"
151
166
 
167
+ refreshed["stop_requested_by"] = by.to_s
168
+ write_record(refreshed)
152
169
  pid = refreshed["pid"].to_i
153
170
 
154
171
  begin
@@ -168,10 +185,7 @@ module Samagotchi
168
185
  end
169
186
  end
170
187
 
171
- updated = refresh_record(refreshed)
172
- updated["status"] = "stopped" if updated["status"] == "running"
173
- updated["finished_at"] ||= timestamp
174
- updated["stop_reason"] = "stopped_by_user"
188
+ updated = mark_stopped(refresh_record(refreshed))
175
189
  write_record(updated)
176
190
 
177
191
  [updated, nil]
@@ -294,9 +308,15 @@ module Samagotchi
294
308
  end
295
309
 
296
310
  def mark_finished_without_exit_code(record)
311
+ record = current_record(record)
312
+ return record unless record["status"] == "running"
313
+
297
314
  exit_code = read_exit_code(record["exit_code_path"])
298
315
 
299
- if exit_code.nil?
316
+ if record["stop_requested_by"]
317
+ record["exit_code"] = exit_code
318
+ mark_stopped(record)
319
+ elsif exit_code.nil?
300
320
  record["status"] = "failed"
301
321
  record["exit_code"] = nil
302
322
  record["stop_reason"] ||= "process_ended_without_exit_code"
@@ -313,6 +333,23 @@ module Samagotchi
313
333
  record
314
334
  end
315
335
 
336
+ # The disk copy when it is newer than +record+: already finished, or
337
+ # flagged by a stop that started after +record+ was read.
338
+ def current_record(record)
339
+ disk = load_record(record.fetch("id"))
340
+ return record unless disk
341
+ return disk if disk["status"] != "running" || disk["stop_requested_by"]
342
+
343
+ record
344
+ end
345
+
346
+ def mark_stopped(record)
347
+ record["status"] = "stopped"
348
+ record["stop_reason"] = "stopped_by_#{record["stop_requested_by"]}"
349
+ record["finished_at"] ||= timestamp
350
+ record
351
+ end
352
+
316
353
  def read_exit_code(path)
317
354
  return nil if path.to_s.empty? || !File.exist?(path)
318
355
 
@@ -10,7 +10,7 @@ module Samagotchi
10
10
  def self.name = NAME
11
11
 
12
12
  def self.call(task_id)
13
- record, error = TaskRuntime.stop_task(task_id.to_s.strip)
13
+ record, error = TaskRuntime.stop_task(task_id.to_s.strip, by: "model")
14
14
  return error if error
15
15
 
16
16
  [
@@ -37,10 +37,10 @@ module Samagotchi
37
37
  return "Error: #{error}" if error
38
38
 
39
39
  status = record.fetch("status")
40
- return format_response(record) unless status == "running"
40
+ return finished_response(record, tail_lines) unless status == "running"
41
41
 
42
42
  if completion_pattern
43
- output_tail = TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
43
+ output_tail = output_tail(record, tail_lines)
44
44
  return format_response(record, wait_result: "pattern_matched", output_tail: output_tail) if completion_pattern.match?(output_tail)
45
45
  end
46
46
 
@@ -53,10 +53,9 @@ module Samagotchi
53
53
 
54
54
  # Timeout reached — do one final refresh to capture the latest status
55
55
  refreshed, = TaskRuntime.get_record(task_id)
56
- return format_response(refreshed) unless refreshed.fetch("status") == "running"
56
+ return finished_response(refreshed, tail_lines) unless refreshed.fetch("status") == "running"
57
57
 
58
- output_tail = TaskRuntime.output_tail_lines(refreshed.fetch("output_path"), tail_lines)
59
- format_response(refreshed, wait_result: "timeout", output_tail: output_tail)
58
+ format_response(refreshed, wait_result: "timeout", output_tail: output_tail(refreshed, tail_lines))
60
59
  rescue StandardError => e
61
60
  "Error: #{e.message}"
62
61
  end
@@ -66,18 +65,31 @@ module Samagotchi
66
65
  def self.cancelled_response(task_id, tail_lines, started)
67
66
  record, error = TaskRuntime.get_record(task_id)
68
67
  return "Error: #{error}" if error
69
- return format_response(record) unless record.fetch("status") == "running"
68
+ return finished_response(record, tail_lines) unless record.fetch("status") == "running"
70
69
 
71
- output_tail = TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
72
70
  note = [
73
71
  "note: the user stopped the turn while waiting; the task is STILL RUNNING.",
74
72
  " To keep waiting: task_wait #{task_id}. If it is stuck or no longer needed: task_stop #{task_id}.",
75
73
  "waited: #{(monotonic_time - started).round}s"
76
74
  ]
77
- format_response(record, wait_result: "canceled", output_tail: output_tail, extra: note)
75
+ format_response(record, wait_result: "canceled", output_tail: output_tail(record, tail_lines), extra: note)
78
76
  end
79
77
  private_class_method :cancelled_response
80
78
 
79
+ # A task the user stopped also says so, with the tail of its output:
80
+ # the model sees where the job was cut off.
81
+ def self.finished_response(record, tail_lines)
82
+ return format_response(record) unless TaskRuntime.stopped_by_user?(record)
83
+
84
+ format_response(record, output_tail: output_tail(record, tail_lines), extra: [TaskRuntime::USER_STOP_NOTE])
85
+ end
86
+ private_class_method :finished_response
87
+
88
+ def self.output_tail(record, tail_lines)
89
+ TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
90
+ end
91
+ private_class_method :output_tail
92
+
81
93
  def self.format_response(record, wait_result: nil, output_tail: nil, extra: [])
82
94
  lines = [
83
95
  "task_id: #{record.fetch("id")}",
@@ -86,9 +98,9 @@ module Samagotchi
86
98
  "stop_reason: #{record["stop_reason"]}",
87
99
  "output_path: #{record.fetch("output_path")}"
88
100
  ]
89
- return lines.join("\n") unless wait_result
101
+ return lines.join("\n") unless output_tail
90
102
 
91
- lines << "wait_result: #{wait_result}"
103
+ lines << "wait_result: #{wait_result}" if wait_result
92
104
  lines.concat(extra)
93
105
  lines << "output_tail:"
94
106
  lines << output_tail.rstrip unless output_tail.empty?
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "model_profile"
4
+ require_relative "thinking_tails"
5
+
3
6
  module Samagotchi
4
7
  # The one-line system note a turn leaves in the conversation when it ends
5
8
  # without an answer: failed before the model replied, cancelled, or over
@@ -19,6 +22,13 @@ module Samagotchi
19
22
  TASK_COMMAND_CHARS = 60
20
23
  RETRY_NUDGE = :retry_nudge
21
24
  EMPTY_ANSWER = :empty_answer
25
+ # An empty step's thinking kept on the marker: its tail, so a model
26
+ # that thought for minutes doesn't put megabytes in the session file.
27
+ # The same cap as ThinkingTails' saved tails.
28
+ STEP_CHARS = ThinkingTails::TAIL_CHARS
29
+ # A step's native thinking starts with one of these; the cut keeps it,
30
+ # so the UIs still find the thinking (MessageParts).
31
+ THINK_OPENS = ["<think>", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN].freeze
22
32
 
23
33
  module_function
24
34
 
@@ -75,16 +85,32 @@ module Samagotchi
75
85
  # UIs, which draw the turn's notice and steps from it after a reload:
76
86
  # +retries+ the empty-answer retries spent, +steps+ the empty
77
87
  # generations as model messages (their thinking; the loops keep them out
78
- # of the conversation, where they'd be empty assistant turns). Never
88
+ # of the conversation, where they'd be empty assistant turns), each
89
+ # text cut to its last STEP_CHARS. Never
79
90
  # sent: the payload builders send a system message's content, and the
80
91
  # copies for hooks, plugins and the recap drop it (AnswerDisplay.strip).
81
92
  def empty(retries: 0, steps: [])
82
93
  marker = { retries: retries.to_i }
83
- marker[:steps] = steps.map { |step| step.slice(:role, :content, :thinking) } unless steps.empty?
94
+ marker[:steps] = steps.map { |step| capped_step(step) } unless steps.empty?
84
95
  message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
85
96
  .merge(EMPTY_ANSWER => marker)
86
97
  end
87
98
 
99
+ def capped_step(step)
100
+ step.slice(:role, :content, :thinking).to_h do |key, value|
101
+ [key, key == :role || !value.is_a?(String) ? value : tail_of(value)]
102
+ end
103
+ end
104
+
105
+ # The last STEP_CHARS of +text+ after a line saying how much was cut
106
+ # (and the thinking's opening tag when it had one); short text as is.
107
+ def tail_of(text)
108
+ return text if text.length <= STEP_CHARS
109
+
110
+ open = THINK_OPENS.find { |tag| text.start_with?(tag) }
111
+ "#{open}[… #{text.length - STEP_CHARS} earlier characters cut]\n#{text[-STEP_CHARS..]}"
112
+ end
113
+
88
114
  # The empty-answer marker of +entry+ (either key type), or nil.
89
115
  def empty_answer(entry)
90
116
  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:})
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.25.0"
4
+ VERSION = "0.27.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -139,6 +139,7 @@ module Samagotchi
139
139
  ["GET", %r{\A/api/sessions/([^/]+)/stream\z}, :handle_stream],
140
140
  ["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
141
141
  ["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
142
+ ["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop],
142
143
  ["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
143
144
  ["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart],
144
145
  ["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
@@ -1019,6 +1020,30 @@ module Samagotchi
1019
1020
  error_response(404, "not_found", e.message)
1020
1021
  end
1021
1022
 
1023
+ # A task id as TaskRuntime.generate_task_id makes it.
1024
+ TASK_ID = /\A\d{14}-\h{8}\z/
1025
+
1026
+ # The stop-task button: the worker stops the task as the user
1027
+ # (Bridge#handle_task_stop) and its task_wait returns. Its own 404
1028
+ # (not this conversation's task) and 409 (already ended) pass through;
1029
+ # a 404 from a worker without the route is the stale worker's 501.
1030
+ def handle_task_stop(_req, id, task_id)
1031
+ return error_response(400, "invalid_task_id", "not a task id: #{task_id.to_s[0, 80]}") unless TASK_ID.match?(task_id)
1032
+
1033
+ @session_class.load(id, state_dir: default_state_dir)
1034
+ request = ->(client) { client.stop_task(task_id) }
1035
+ relay(id, bridge_client(id), request, what: "the task was not stopped", cant: "stop tasks") do |reply|
1036
+ body = reply.json || {}
1037
+ case reply.status
1038
+ when 200 then json_response(200, { status: body["status"], stop_reason: body["stop_reason"], task_id: task_id, session_id: id })
1039
+ when 404 then (error_response(404, "task_not_found", "no task #{task_id} in this conversation") if body["error"] == "task_not_found")
1040
+ when 409 then json_response(409, { error: "not_running", status: body["status"], task_id: task_id, session_id: id })
1041
+ end
1042
+ end
1043
+ rescue ArgumentError => e
1044
+ error_response(404, "not_found", e.message)
1045
+ end
1046
+
1022
1047
  def handle_stop(_req, id)
1023
1048
  # Bounded, so a Rack thread isn't held long; a worker still exiting
1024
1049
  # after it just means an immediate resume finds it (rare).
@@ -8,7 +8,7 @@ module Samagotchi
8
8
  class MarkdownRenderer
9
9
  WARNING = "Markdown rendering is enabled, but the optional commonmarker gem is not installed. Install it with: gem install commonmarker"
10
10
  # NOTE: `span` is allowed because the syntax highlighter (commonmarker/syntect)
11
- # emits inline-styled <span>s; keeping it lets highlighting render.
11
+ # emits classed <span>s; keeping it lets highlighting render.
12
12
  ALLOWED_ELEMENTS = %w[a blockquote br code del em h1 h2 h3 h4 h5 h6 hr li ol p pre s strong span table tbody td th thead tr ul].freeze
13
13
  DROP_ELEMENTS = %w[iframe object script style svg].freeze
14
14
  ALLOWED_PROTOCOLS = %w[http https mailto].freeze
@@ -35,6 +35,11 @@ module Samagotchi
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
37
  extension: { header_ids: nil }
38
+ }, plugins: {
39
+ # An empty theme makes syntect emit scope classes (`keyword`, `string`, ...)
40
+ # instead of inline colours from one fixed theme, so the page's light and
41
+ # dark palettes colour code blocks (the --code-* tokens in index.html).
42
+ syntax_highlighter: { theme: "" }
38
43
  })
39
44
  return nil unless html
40
45
 
@@ -74,6 +79,7 @@ module Samagotchi
74
79
 
75
80
  node.remove_attribute(attribute.name)
76
81
  end
82
+ prefix_highlight_classes(node) if node.name == "span"
77
83
  return unless node.name == "a"
78
84
 
79
85
  href = node["href"].to_s.strip
@@ -85,16 +91,25 @@ module Samagotchi
85
91
  node["rel"] = "noopener noreferrer"
86
92
  end
87
93
 
88
- # The syntax highlighter emits inline `style=` on <span>/<pre>/<code>. Those
89
- # values come from the (trusted) theme, not user input, and raw HTML from the
90
- # markdown source is already escaped by commonmarker (escape: true), so style
91
- # attributes are safe to keep on these elements only.
94
+ # The syntax highlighter emits `class=` on <pre> ("syntax-highlighting") and
95
+ # <span> (syntect scope names). They come from the highlighter, not user input:
96
+ # raw HTML from the markdown source is already escaped by commonmarker
97
+ # (escape: true), so class attributes are safe to keep on these elements only.
92
98
  def allowed_attribute?(node, name)
93
- return true if name == "style" && %w[span pre code].include?(node.name)
99
+ return true if name == "class" && %w[span pre].include?(node.name)
94
100
 
95
101
  node.name == "a" && %w[href title].include?(name)
96
102
  end
97
103
 
104
+ # Scope names are plain words ("diff", "meta", "name", ...) that page CSS and
105
+ # querySelectors use too: prefix them so they only ever match the hl-* rules.
106
+ def prefix_highlight_classes(node)
107
+ words = node["class"].to_s.split
108
+ return node.remove_attribute("class") if words.empty?
109
+
110
+ node["class"] = words.map { |word| "hl-#{word}" }.join(" ")
111
+ end
112
+
98
113
  def safe_href?(href)
99
114
  return true if href.start_with?("#", "/")
100
115
 
@@ -23,6 +23,7 @@ import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, t
23
23
  import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
24
24
  import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
25
25
  import { createStageView } from "./stage_view.js";
26
+ import { createTaskStopper } from "./task_stop.js";
26
27
  import { appendQuote } from "./annotations.js";
27
28
  import { parsePresets } from "./annotate_presets.js";
28
29
  import { createAnnotateBar } from "./annotate_bar.js";
@@ -86,6 +87,9 @@ const viewArgs = {
86
87
  isNearBottom: () => isNearBottom(),
87
88
  sawText: (k) => seenContent.add(k),
88
89
  sessionId: () => selected,
90
+ // The stop-task button on a running task_wait, for a worker that has
91
+ // the route (its sidecar features).
92
+ taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
89
93
  };
90
94
  const turnDom = view === "stage"
91
95
  ? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
@@ -20,8 +20,9 @@ export function rowHover(row) {
20
20
  //
21
21
  // A view with steps (CommandSteps) shows them in the command's place, its
22
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
23
+ // only, so a live and a reloaded row behave alike; beside the first step
24
+ // when there's no "in" tag) that swaps the steps for the command as
25
+ // written. The copy button copies the raw command
25
26
  // either way. A view without steps (the parser's fallback) is the raw
26
27
  // block alone: nothing hides the command.
27
28
  //
@@ -47,7 +48,9 @@ export function commandBlockHtml(view, title = null) {
47
48
  const cwd = where ? `<div class="activity-command-cwd">${where}</div>` : "";
48
49
  return `<div class="activity-command code-wrap">${desc}${cwd}${raw}${copyButtonHtml("code")}${cut}</div>`;
49
50
  }
50
- const head = `<div class="activity-command-head"><span class="activity-command-cwd">${where}</span>` +
51
+ // No "in" tag: the toggle alone, floated beside the first step (an
52
+ // empty line for it would sit above the steps).
53
+ const head = (where ? `<div class="activity-command-head"><span class="activity-command-cwd">${where}</span>` : `<div class="activity-command-head bare">`) +
51
54
  `<label class="activity-command-raw" title="Show the command as written"><input type="checkbox">raw</label></div>`;
52
55
  return `<div class="activity-command code-wrap has-steps">${desc}${head}${stepsHtml(steps)}${raw}${copyButtonHtml("code")}${cut}</div>`;
53
56
  }
@@ -123,6 +123,17 @@ export function stopSession(id, opts = {}) {
123
123
  return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
124
124
  }
125
125
 
126
+ // Kills a background task the session's conversation started (the
127
+ // stop-task button): 200 {status, stop_reason}; 409 it already ended, 503
128
+ // no live worker.
129
+ export function stopTask(id, taskId, opts = {}) {
130
+ return api(`/api/sessions/${encodeURIComponent(id)}/tasks/${encodeURIComponent(taskId)}/stop`, {
131
+ method: "POST",
132
+ body: JSON.stringify({}),
133
+ ...opts,
134
+ });
135
+ }
136
+
126
137
  // Hands the session to a new worker on the newest chi installed: 200
127
138
  // {status, from_version, version}, or 409 with the reason in words (detail).
128
139
  export function restartSession(id, opts = {}) {