samagotchi 0.26.0 → 0.28.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/docs/architecture.md +6 -0
  4. data/docs/cli.md +6 -3
  5. data/docs/configuration.md +17 -3
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/prompt-caching.md +38 -7
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bridge.rb +37 -1
  10. data/lib/samagotchi/bridge_client.rb +9 -0
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/config.rb +8 -0
  15. data/lib/samagotchi/desktop/macos/App.swift +6 -4
  16. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -3
  17. data/lib/samagotchi/desktop/macos/Panel.swift +30 -10
  18. data/lib/samagotchi/desktop/macos.rb +5 -1
  19. data/lib/samagotchi/engine.rb +1 -1
  20. data/lib/samagotchi/llm/openai_chat.rb +40 -4
  21. data/lib/samagotchi/llm/prompt_cache.rb +31 -5
  22. data/lib/samagotchi/log.rb +19 -3
  23. data/lib/samagotchi/session.rb +9 -0
  24. data/lib/samagotchi/session_manager.rb +4 -3
  25. data/lib/samagotchi/sessions_command.rb +26 -13
  26. data/lib/samagotchi/terminal_ui/repl_input.rb +13 -0
  27. data/lib/samagotchi/terminal_ui.rb +5 -0
  28. data/lib/samagotchi/thinking_tails.rb +1 -1
  29. data/lib/samagotchi/tool_activity.rb +3 -2
  30. data/lib/samagotchi/tool_view.rb +14 -3
  31. data/lib/samagotchi/tools/task_get.rb +3 -2
  32. data/lib/samagotchi/tools/task_runtime.rb +49 -12
  33. data/lib/samagotchi/tools/task_stop.rb +1 -1
  34. data/lib/samagotchi/tools/task_wait.rb +22 -10
  35. data/lib/samagotchi/turn_note.rb +4 -2
  36. data/lib/samagotchi/version.rb +1 -1
  37. data/lib/samagotchi/vision_context.rb +17 -4
  38. data/lib/samagotchi/web/app.rb +25 -0
  39. data/lib/samagotchi/web/markdown_renderer.rb +21 -6
  40. data/lib/samagotchi/web/public/app.js +4 -0
  41. data/lib/samagotchi/web/public/data.js +11 -0
  42. data/lib/samagotchi/web/public/index.html +19 -0
  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 +124 -0
  46. data/lib/samagotchi/web/public/turn_view.js +24 -1
  47. metadata +2 -1
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../config"
4
+ require_relative "../log"
5
+
3
6
  module Samagotchi
4
7
  module LLM
5
8
  # Prompt-cache breakpoints for Claude models behind an OpenAI-compatible
@@ -17,11 +20,33 @@ module Samagotchi
17
20
  # the stable part with the mark, the per-session tail (model, location,
18
21
  # session) without, so a new session reads the stable part from the
19
22
  # cache. `cache_split:` never goes on the wire (#without_split).
23
+ #
24
+ # cache.ttl: 1h marks the breakpoints with `ttl: "1h"` (#control), so a
25
+ # cache outlives a long reading gap; 5m, the default, sends Anthropic's
26
+ # default (no ttl).
20
27
  module PromptCache
21
28
  CONTROL = { type: "ephemeral" }.freeze
29
+ CONTROL_1H = { type: "ephemeral", ttl: "1h" }.freeze
30
+ TTLS = %w[5m 1h].freeze
22
31
 
23
32
  module_function
24
33
 
34
+ # The cache_control a breakpoint carries under cache.ttl (an unknown
35
+ # value warns once and is 5m).
36
+ def control
37
+ value = Config.get("cache.ttl").to_s.strip.downcase
38
+ return CONTROL_1H if value == "1h"
39
+
40
+ unless value.empty? || value == "5m" || @warned_ttl
41
+ @warned_ttl = true
42
+ Log.warn(:config, "invalid_value", echo: "Warning: invalid value for cache.ttl: #{value.inspect} (allowed: 5m, 1h) — using 5m",
43
+ key: "cache.ttl")
44
+ end
45
+ CONTROL
46
+ rescue StandardError
47
+ CONTROL
48
+ end
49
+
25
50
  # Whether +model+ (the bare id a host is asked for) names a Claude model.
26
51
  def claude?(model) = model.to_s.match?(/claude/i)
27
52
 
@@ -34,8 +59,9 @@ module Samagotchi
34
59
  return without_split(messages) unless claude?(model)
35
60
 
36
61
  indexes = [system_index(messages), last_index(messages)].compact.uniq
62
+ breakpoint = control
37
63
  messages.each_with_index.map do |message, index|
38
- next marked(message.except(:cache_split), split: message[:cache_split]) if indexes.include?(index)
64
+ next marked(message.except(:cache_split), breakpoint, split: message[:cache_split]) if indexes.include?(index)
39
65
 
40
66
  message.key?(:cache_split) ? message.except(:cache_split) : message
41
67
  end
@@ -80,15 +106,15 @@ module Samagotchi
80
106
  # A String becomes one text part (two at +split+, the mark on the
81
107
  # first); an Array gets the mark on its last text part (the last part
82
108
  # when none is text).
83
- def marked(message, split: nil)
109
+ def marked(message, breakpoint, split: nil)
84
110
  content = message[:content]
85
111
  if content.is_a?(String)
86
112
  if split.is_a?(Integer) && split.positive? && split < content.length
87
- return message.merge(content: [{ type: "text", text: content[0, split], cache_control: CONTROL },
113
+ return message.merge(content: [{ type: "text", text: content[0, split], cache_control: breakpoint },
88
114
  { type: "text", text: content[split..] }])
89
115
  end
90
116
 
91
- return message.merge(content: [{ type: "text", text: content, cache_control: CONTROL }])
117
+ return message.merge(content: [{ type: "text", text: content, cache_control: breakpoint }])
92
118
  end
93
119
 
94
120
  target = content.rindex { |part| part.is_a?(Hash) && (part[:type] || part["type"]).to_s == "text" } ||
@@ -96,7 +122,7 @@ module Samagotchi
96
122
  part = content[target]
97
123
  key = part.key?("type") ? "cache_control" : :cache_control
98
124
  parts = content.dup
99
- parts[target] = part.merge(key => CONTROL)
125
+ parts[target] = part.merge(key => breakpoint)
100
126
  message.merge(content: parts)
101
127
  end
102
128
 
@@ -23,6 +23,8 @@ module Samagotchi
23
23
  # (once, until Config.reload!). While that runs (Config can warn itself)
24
24
  # records only echo. Nothing here raises into the caller: a record that
25
25
  # can't be formatted is dropped, an unwritable file pauses (DebugLog).
26
+ # The one exception is strict mode (SAMAGOTCHI_LOG_STRICT=1, the spec
27
+ # suite): an unknown tag or a malformed event raises, whatever the level.
26
28
  module Log
27
29
  LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
28
30
  DEFAULT_LEVEL = :info
@@ -39,6 +41,7 @@ module Samagotchi
39
41
  @state = nil
40
42
  @resolving = false
41
43
  @session_id = nil
44
+ @strict = ENV["SAMAGOTCHI_LOG_STRICT"] == "1"
42
45
 
43
46
  class << self
44
47
  # @param path [String, nil, :auto] the file; :auto → LogPath.resolve
@@ -72,6 +75,9 @@ module Samagotchi
72
75
  # carry its first SID_LENGTH chars unless they pass their own sid:.
73
76
  attr_accessor :session_id
74
77
 
78
+ # Raise on an unknown tag or a bad event instead of dropping the record.
79
+ attr_accessor :strict
80
+
75
81
  def debug(tag, event, **kw) = log(:debug, tag, event, **kw)
76
82
  def info(tag, event, **kw) = log(:info, tag, event, **kw)
77
83
  def warn(tag, event, **kw) = log(:warn, tag, event, **kw)
@@ -96,7 +102,19 @@ module Samagotchi
96
102
  resolved_state&.dig(:writer)&.path
97
103
  end
98
104
 
99
- def log(level, tag, event, payload: nil, sid: nil, echo: nil, **fields)
105
+ def log(level, tag, event, **kw)
106
+ check_names!(tag, event) if strict
107
+ write_record(level, tag, event, **kw)
108
+ end
109
+
110
+ private
111
+
112
+ def check_names!(tag, event)
113
+ raise ArgumentError, "unknown log tag #{tag}" unless LogLine::TAGS.include?(tag.to_s)
114
+ raise ArgumentError, "bad log event #{event}" unless event.to_s.match?(LogLine::SLUG)
115
+ end
116
+
117
+ def write_record(level, tag, event, payload: nil, sid: nil, echo: nil, **fields)
100
118
  options = @options
101
119
  echo_to_stderr(echo) if echo && options.fetch(:stderr, true)
102
120
  state = resolved_state
@@ -113,8 +131,6 @@ module Samagotchi
113
131
  nil
114
132
  end
115
133
 
116
- private
117
-
118
134
  def build(level, tag, event, payload:, sid:, fields:)
119
135
  tag = tag.to_s
120
136
  event = event.to_s
@@ -62,6 +62,15 @@ module Samagotchi
62
62
  # "ended_at" => iso8601, "seconds" => Float, "origin" =>
63
63
  # "client"|"reminder"|"delegate"}; nil before the first turn.
64
64
  attr_accessor :last_turn
65
+
66
+ # The hook that stopped the last turn (stop_turn, or a cut with no retry
67
+ # left), e.g. "loop-guard"; nil when it ended otherwise. Both strings
68
+ # and symbols are read (the file's keys are strings).
69
+ def stopped_by
70
+ return nil unless @last_turn.is_a?(Hash)
71
+
72
+ (@last_turn["stopped_by"] || @last_turn[:stopped_by])&.to_s
73
+ end
65
74
  # Archived (ArchiveStore): hidden from the lists. Set by .list (with
66
75
  # include_archived); not saved in session.json.
67
76
  attr_accessor :archived
@@ -259,12 +259,13 @@ module Samagotchi
259
259
  # @param include_archived [Boolean] archived sessions too
260
260
  # @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
261
261
  # updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
262
- # parent_short_id:, archived:, scratch:, test_run:, waiting:, waiting_id:}; busy = live with
262
+ # parent_short_id:, archived:, scratch:, test_run:, waiting:, waiting_id:, stopped_by:}; busy = live with
263
263
  # a turn running, recap = the saved recap's first sentence, project =
264
264
  # Session#project_root, waiting = the kind of question it waits on
265
265
  # ("question", "approval", "hook"; Session#waiting_question) or nil,
266
266
  # waiting_id = that question's id or nil, relayed_to = the parent
267
- # whose card it waits in too (the approval relay), its short id, or nil
267
+ # whose card it waits in too (the approval relay), its short id, or nil,
268
+ # stopped_by = the hook that stopped the last turn (Session#stopped_by)
268
269
  def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
269
270
  project_root: nil, include_archived: false, sort: nil, order: nil)
270
271
  sd = state_dir || Session.default_state_dir
@@ -284,7 +285,7 @@ module Samagotchi
284
285
  owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
285
286
  ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
286
287
  parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), archived: s.archived,
287
- scratch: s.scratch, test_run: s.test_run }.merge(waiting_fields(s.waiting_question(live: owned)))
288
+ scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
288
289
  end
289
290
  (limit ? summaries.first(limit) : summaries.to_a)
290
291
  end
@@ -112,6 +112,28 @@ module Samagotchi
112
112
  options.to_h { |key, value| [key, INTEGER_OPTIONS.include?(key) ? value.to_i : value] }
113
113
  end
114
114
 
115
+ # The flags a list row carries: a chi scratch session, a test run, an
116
+ # archived one, and the turn's stopper (see #stopped_flag).
117
+ def session_flag(scratch:, test_run:, archived:, stopped_by: nil)
118
+ flag = if scratch
119
+ " [scratch]"
120
+ else
121
+ (test_run ? " [test]" : "")
122
+ end
123
+ flag += " [archived]" if archived
124
+ flag + stopped_flag(stopped_by)
125
+ end
126
+
127
+ # The marker for a turn a hook stopped: "[looped]" for loop-guard (the
128
+ # bundle's thinking watch, and the word `chi sessions stats` uses for its
129
+ # cuts), "[stopped by X]" for any other hook; "" otherwise. A cut with no
130
+ # retry left records the same stopped_by.
131
+ def stopped_flag(by)
132
+ return "" if by.to_s.empty?
133
+
134
+ by.to_s == "loop-guard" ? " [looped]" : " [stopped by #{by}]"
135
+ end
136
+
115
137
  def list
116
138
  format, scope, cwd = @opts.values_at(:format, :scope, :cwd)
117
139
  unless format.nil? || %w[text json tsv].include?(format)
@@ -156,7 +178,7 @@ module Samagotchi
156
178
  # id, for chi answer --question; relayed_to: the parent session (short
157
179
  # id) whose card it also waits in (the approval relay), or nil
158
180
  keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct waiting
159
- waiting_id relayed_to]
181
+ waiting_id relayed_to stopped_by]
160
182
  @stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
161
183
  when "tsv"
162
184
  summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
@@ -176,12 +198,8 @@ module Samagotchi
176
198
  end
177
199
  # A delegated session points at its parent.
178
200
  child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
179
- flag = if summary[:scratch]
180
- " [scratch]"
181
- else
182
- (summary[:test_run] ? " [test]" : "")
183
- end
184
- flag += " [archived]" if summary[:archived]
201
+ flag = session_flag(scratch: summary[:scratch], test_run: summary[:test_run], archived: summary[:archived],
202
+ stopped_by: summary[:stopped_by])
185
203
  ctx = Samagotchi::SessionMetrics.context_label(summary[:ctx_pct])
186
204
  @stdout.puts "#{summary[:id]} #{state.ljust(8)} #{ctx.ljust(8)} #{summary[:updated_at]} #{desc}#{flag}#{child}"
187
205
  end
@@ -209,12 +227,7 @@ module Samagotchi
209
227
  end
210
228
  # A delegated session points at its parent: ↳ <parent's short id>.
211
229
  row = lambda do |s|
212
- flag = if s.scratch
213
- " [scratch]"
214
- else
215
- (s.test_run ? " [test]" : "")
216
- end
217
- flag += " [archived]" if s.archived
230
+ flag = session_flag(scratch: s.scratch, test_run: s.test_run, archived: s.archived, stopped_by: s.stopped_by)
218
231
  child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
219
232
  # How full the context was after the last turn: "ctx 12%", blank when unknown.
220
233
  ctx = Samagotchi::SessionMetrics.context_label(
@@ -94,6 +94,19 @@ module Samagotchi
94
94
  # @return [Boolean] whether it went in
95
95
  def prefill(text) = open? && @reader.prefill(text)
96
96
 
97
+ # What the open prompt holds, nil when none is open: a line typed
98
+ # ahead there is the next turn's input.
99
+ def typed_text = @reader&.typed_text
100
+
101
+ # Whether the next turn's input is already here: text typed ahead at
102
+ # the open prompt, or a line a turn took as steering but never merged
103
+ # (it runs as the next turn).
104
+ def waiting_input?
105
+ @lock.synchronize { return true unless @front.empty? }
106
+
107
+ !typed_text.to_s.strip.empty?
108
+ end
109
+
97
110
  # A question answered at the open prompt: the lines submitted from now
98
111
  # on go to it, at the ? prompt. What was typed at the prompt is put aside
99
112
  # and comes back once the question closes.
@@ -175,6 +175,11 @@ module Samagotchi
175
175
  @engine.interface = @non_interactive ? :non_interactive : :repl
176
176
  # A --non-interactive run exits after its turn: no turn-end warm-up.
177
177
  @engine.next_turn_waiting = -> { true } if @non_interactive
178
+ # A warm-up never pays off while a line typed ahead at the open prompt
179
+ # (or a left-over steer) already forms the next turn: only prefill when
180
+ # the prompt is empty. The REPL-only check composes with the one above.
181
+ next_waiting = @engine.next_turn_waiting
182
+ @engine.next_turn_waiting = -> { @repl_input&.waiting_input? || next_waiting&.call }
178
183
  @turn_flow = TurnFlow.new(engine: @engine)
179
184
  # A recap written while a continue offer waits says the turn stopped
180
185
  # 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
 
@@ -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
@@ -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,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "model_profile"
4
+ require_relative "thinking_tails"
4
5
 
5
6
  module Samagotchi
6
7
  # The one-line system note a turn leaves in the conversation when it ends
@@ -23,7 +24,8 @@ module Samagotchi
23
24
  EMPTY_ANSWER = :empty_answer
24
25
  # An empty step's thinking kept on the marker: its tail, so a model
25
26
  # that thought for minutes doesn't put megabytes in the session file.
26
- STEP_CHARS = 20_000
27
+ # The same cap as ThinkingTails' saved tails.
28
+ STEP_CHARS = ThinkingTails::TAIL_CHARS
27
29
  # A step's native thinking starts with one of these; the cut keeps it,
28
30
  # so the UIs still find the thinking (MessageParts).
29
31
  THINK_OPENS = ["<think>", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN].freeze
@@ -141,7 +143,7 @@ module Samagotchi
141
143
  def cut_retry(by, reason)
142
144
  why = reason.to_s.strip.empty? ? "" : ": #{one_line(reason)}"
143
145
  retry_nudge(message("your last reply was cut off by #{by || "a plugin"}#{why}. Don't start the same reasoning " \
144
- "again; answer the user's last message now, briefly."))
146
+ "again; continue the task with your next tool call, or answer if it is done."))
145
147
  end
146
148
 
147
149
  # A retry nudge is marked, so the spent one is found by the mark
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.26.0"
4
+ VERSION = "0.28.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -66,8 +66,10 @@ module Samagotchi
66
66
 
67
67
  # Which images of a conversation one request sends. Every image that is
68
68
  # not sent becomes a placeholder line: all of them when the model can't
69
- # see images, the older ones past limits.max_per_request (the newest go),
70
- # and any whose file is gone.
69
+ # see images, the oldest ones past limits.max_per_request, and any whose
70
+ # file is gone. Past the limit the oldest images are dropped in batches
71
+ # (ImagePlan.dropped_count), so the cut stays put for a few new images
72
+ # and the history before it stays a cacheable prefix.
71
73
  class ImagePlan
72
74
  Item = Data.define(:ref, :data, :placeholder) do
73
75
  def sent? = !data.nil?
@@ -92,7 +94,18 @@ module Samagotchi
92
94
  Array(entry[:images]).each_index { |position| occurrences << [index, position] }
93
95
  end
94
96
  @limit = vision&.limits&.max_per_request || ImageStore::Limits.from_config.max_per_request
95
- @sent = occurrences.last(@limit)
97
+ @sent = occurrences.drop(self.class.dropped_count(occurrences.size, @limit))
98
+ end
99
+
100
+ # How many of +total+ images (oldest first) a request leaves out with
101
+ # +limit+ per request: none up to the limit, then whole steps of
102
+ # limit/2, so it's a pure function of the count. With limit 20, 21..30
103
+ # images drop the oldest 10, 31..40 the oldest 20.
104
+ def self.dropped_count(total, limit)
105
+ return 0 if total <= limit
106
+
107
+ step = [limit / 2, 1].max
108
+ (((total - limit) + step - 1) / step) * step
96
109
  end
97
110
 
98
111
  # The Items for conversation[+index+] (none when it has no images).
@@ -120,7 +133,7 @@ module Samagotchi
120
133
  def skip_reason(index, position)
121
134
  return "no session images here" if @vision.nil?
122
135
  return CANT_SEE unless @vision.sendable?
123
- return "only the newest #{@limit} images are sent" unless @sent.include?([index, position])
136
+ return "an older image (chi sends up to the newest #{@limit})" unless @sent.include?([index, position])
124
137
 
125
138
  nil
126
139
  end