samagotchi 0.27.0 → 0.29.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -1
  3. data/docs/cli.md +6 -3
  4. data/docs/configuration.md +17 -3
  5. data/docs/internals/prompt-caching.md +38 -7
  6. data/docs/sessions.md +1 -1
  7. data/lib/samagotchi/bundle_command.rb +4 -2
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/config.rb +8 -0
  10. data/lib/samagotchi/desktop/macos/App.swift +6 -4
  11. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -3
  12. data/lib/samagotchi/desktop/macos/Panel.swift +30 -10
  13. data/lib/samagotchi/desktop/macos.rb +5 -1
  14. data/lib/samagotchi/engine.rb +5 -4
  15. data/lib/samagotchi/guardrails/shell_lex.rb +79 -5
  16. data/lib/samagotchi/kernel_loop.rb +8 -1
  17. data/lib/samagotchi/llm/openai_chat.rb +40 -4
  18. data/lib/samagotchi/llm/prompt_cache.rb +31 -5
  19. data/lib/samagotchi/memory_bundle/installer.rb +14 -0
  20. data/lib/samagotchi/session.rb +9 -0
  21. data/lib/samagotchi/session_commands.rb +6 -2
  22. data/lib/samagotchi/session_manager.rb +4 -3
  23. data/lib/samagotchi/sessions_command.rb +26 -13
  24. data/lib/samagotchi/terminal_ui/repl_input.rb +13 -0
  25. data/lib/samagotchi/terminal_ui.rb +5 -0
  26. data/lib/samagotchi/tool_activity.rb +32 -6
  27. data/lib/samagotchi/tools/builtins.rb +10 -7
  28. data/lib/samagotchi/tools/task_runtime.rb +21 -0
  29. data/lib/samagotchi/turn_note.rb +11 -1
  30. data/lib/samagotchi/version.rb +1 -1
  31. data/lib/samagotchi/vision_context.rb +17 -4
  32. data/lib/samagotchi/web/public/app.js +14 -4
  33. data/lib/samagotchi/web/public/index.html +37 -19
  34. data/lib/samagotchi/web/public/sessions_list.js +21 -4
  35. data/lib/samagotchi/web/public/stage_model.js +5 -2
  36. data/lib/samagotchi/web/public/task_stop.js +24 -1
  37. data/lib/samagotchi/web/public/turn_model.js +14 -5
  38. data/lib/samagotchi/web/public/turn_view.js +46 -10
  39. data/lib/samagotchi/worker.rb +9 -0
  40. metadata +1 -1
@@ -80,6 +80,17 @@ module Samagotchi
80
80
  false
81
81
  end
82
82
 
83
+ # true when +url+ is OpenAI's own API (api.openai.com).
84
+ def self.openai?(url)
85
+ URI(url.to_s).host.to_s.downcase == "api.openai.com"
86
+ rescue URI::Error
87
+ false
88
+ end
89
+
90
+ # Whether cache.key asks for the session id as `prompt_cache_key`
91
+ # (session; off, the default, or anything else sends none).
92
+ def self.cache_key? = Samagotchi::Config.get("cache.key").to_s.strip.downcase == "session"
93
+
83
94
  # Tool arguments as a Hash; "" is {}, invalid JSON stays a String.
84
95
  def self.parse_arguments(raw)
85
96
  return raw if raw.is_a?(Hash)
@@ -141,10 +152,10 @@ module Samagotchi
141
152
  # @return [ChatResponse]
142
153
  def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
143
154
  session_id: nil)
144
- body = request_body(messages, tools, model, options)
155
+ body = request_body(messages, tools, model, options, session_id)
145
156
  request = post_request("#{@base_url}/chat/completions", body, session_id: session_id)
146
157
  log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body),
147
- cache: ("on" if PromptCache.marked?(body[:messages])) }
158
+ cache: cache_summary(body[:messages]), cache_by: ("session" if body.key?(:prompt_cache_key)) }
148
159
  return chat_once(request, cancel_controller, log_fields) unless @stream
149
160
 
150
161
  assembly = Assembly.new
@@ -236,7 +247,7 @@ module Samagotchi
236
247
  [[open_timeout, timeout].min, timeout]
237
248
  end
238
249
 
239
- def request_body(messages, tools, model, options)
250
+ def request_body(messages, tools, model, options, session_id)
240
251
  body = {
241
252
  model: model,
242
253
  messages: cache_breakpoints(Array(messages).map { |message| wire_message(message) }, model),
@@ -244,6 +255,7 @@ module Samagotchi
244
255
  }
245
256
  body[:stream_options] = { include_usage: true } if @stream
246
257
  body[:max_tokens] = @default_max_tokens if @default_max_tokens
258
+ body[:prompt_cache_key] = session_id.to_s if prompt_cache_key?(session_id)
247
259
  unless Array(tools).empty?
248
260
  body[:tools] = tools
249
261
  body[:tool_choice] = "auto"
@@ -264,10 +276,34 @@ module Samagotchi
264
276
  PromptCache.mark(messages, model: model)
265
277
  end
266
278
 
279
+ # The session id as `prompt_cache_key` (cache.key: session) goes only
280
+ # to the APIs that document it, OpenAI's and OpenRouter's (which also
281
+ # routes by it), for the session's own chat requests: a local server or
282
+ # another gateway may reject an unknown field.
283
+ def prompt_cache_key?(session_id)
284
+ return false if session_id.to_s.empty? || !@remote || @purpose != "chat"
285
+ return false unless self.class.openai?(@base_url) || self.class.openrouter?(@base_url)
286
+
287
+ self.class.cache_key?
288
+ rescue StandardError
289
+ false
290
+ end
291
+
292
+ # The log line's cache= : on for breakpoints with Anthropic's default
293
+ # TTL, 1h for cache.ttl 1h, nil for none.
294
+ def cache_summary(messages)
295
+ return nil unless PromptCache.marked?(messages)
296
+
297
+ hour = Array(messages).any? do |message|
298
+ Array(message[:content]).any? { |part| part.is_a?(Hash) && part.dig(:cache_control, :ttl) == "1h" }
299
+ end
300
+ hour ? "1h" : "on"
301
+ end
302
+
267
303
  # The body's fields beyond the conversation and the stream, for the
268
304
  # log line: "temperature=0.6 presence_penalty=1.5", nil for none.
269
305
  def sampling_summary(body)
270
- SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
306
+ SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice, :prompt_cache_key))
271
307
  end
272
308
 
273
309
  # A String stays a String, an Array of parts an Array; both scrubbed
@@ -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
 
@@ -303,6 +303,7 @@ module Samagotchi
303
303
  # Like the rules, the bundle's plugin replaces an earlier one.
304
304
  plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
305
305
  warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
306
+ warn_rules_requires_chi(manifest, incoming_rules)
306
307
 
307
308
  # Files the previous version shipped and this one doesn't.
308
309
  if @upgrade && existing_provenance && existing_provenance[:files]
@@ -559,6 +560,19 @@ module Samagotchi
559
560
  end
560
561
  end
561
562
 
563
+ # GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
564
+ # requires_chi this chi doesn't meet, and a session that never loaded
565
+ # them records a required load failure (the gate refuses guarded calls
566
+ # until chi is updated): say so at install, as the hooks warning does.
567
+ def warn_rules_requires_chi(manifest, rule_files)
568
+ return if manifest.nil? || rule_files.empty?
569
+
570
+ if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
571
+ @warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
572
+ "(chi update), guarded tool calls are refused"
573
+ end
574
+ end
575
+
562
576
  # Copy the manifest's plugin file into the bundle's plugin/ dir,
563
577
  # warning when its sha256 differs from the declared one or this chi
564
578
  # doesn't meet requires_chi (the Engine then won't load it).
@@ -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
@@ -5,6 +5,7 @@ require_relative "model_profile"
5
5
  require_relative "served_model"
6
6
  require_relative "turn_flow"
7
7
  require_relative "tools/execute"
8
+ require_relative "tools/builtins"
8
9
  require_relative "commands/registry"
9
10
 
10
11
  module Samagotchi
@@ -207,12 +208,15 @@ module Samagotchi
207
208
  end
208
209
 
209
210
  # The output goes into the conversation for the next turn. Not saved
210
- # here (as in the REPL, the next turn saves it).
211
+ # here (as in the REPL, the next turn saves it). No parent-session
212
+ # marker: the user runs this, not the model. A worker's `chi --model X`
213
+ # default is unset, though: it was that run's, not the default of a chi
214
+ # run here (Tools::Builtins.cli_model_unset).
211
215
  def shell(text)
212
216
  command = text.delete_prefix(SHELL_BANG_PREFIX).strip
213
217
  return reply("!: please provide a shell command after '!'", status: :error) if command.empty?
214
218
 
215
- output = Samagotchi::Tools::Execute.call(command)
219
+ output = Samagotchi::Tools::Execute.call(command, env: Samagotchi::Tools::Builtins.cli_model_unset)
216
220
  @engine.append_messages([{ role: "user", content: "!(#{command})\n#{output}" }])
217
221
  # Rolling back past this would silently drop the command output.
218
222
  @turn_flow.note_conversation_changed
@@ -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.)
@@ -12,6 +12,7 @@ require_relative "tools/edit"
12
12
  require_relative "tools/task_create"
13
13
  require_relative "tools/task_get"
14
14
  require_relative "tools/task_list"
15
+ require_relative "tools/task_runtime"
15
16
  require_relative "tools/task_stop"
16
17
  require_relative "tools/task_wait"
17
18
  require_relative "tools/web_fetch"
@@ -62,11 +63,13 @@ module Samagotchi
62
63
  # command without its "cd … &&"): a file tool's path relative to +cwd+
63
64
  # when it is under it, a command's description (the model's few words),
64
65
  # else its first step and how many more (CommandSteps; its first line
65
- # when it has no steps), a memory's name;
66
- # nil for the other tools (a plugin's row keeps its preview, the
66
+ # when it has no steps), a memory's name, a task tool's task command
67
+ # (task_title); nil for the other tools (a plugin's row keeps its preview, the
67
68
  # params). +params+ stays the full key=value line (the TUI, the
68
69
  # guardrails).
69
70
  def tool_title(tool_name, call, cwd: nil)
71
+ return task_title(tool_name, call, cwd) if TASK_TOOLS.include?(tool_name)
72
+
70
73
  if [Tools::Execute::NAME, Tools::TaskCreate::NAME].include?(tool_name)
71
74
  title = command_description(tool_name, call) || steps_title(call[:content])
72
75
  return title if title
@@ -90,19 +93,42 @@ module Samagotchi
90
93
  end
91
94
  end
92
95
 
96
+ TASK_TOOLS = [Tools::TaskWait::NAME, Tools::TaskGet::NAME, Tools::TaskStop::NAME].freeze
97
+
98
+ # A task_wait / task_get / task_stop row's title: the task's command
99
+ # (its record under +cwd+, the process's directory when nil) as a
100
+ # command's title, and for a wait how long it waits ("npm test · up to
101
+ # 600s"). nil when there is no record: the row keeps its id.
102
+ def task_title(tool_name, call, cwd)
103
+ command = Tools::TaskRuntime.command_of(call[:content], root: cwd)
104
+ return nil unless command
105
+
106
+ suffix = ""
107
+ if tool_name == Tools::TaskWait::NAME
108
+ timeout = call[:timeout].to_s.strip
109
+ suffix = " · up to #{timeout.empty? ? Tools::TaskWait::TIMEOUT_DEFAULT : timeout.to_i}s"
110
+ end
111
+ room = TOOL_ACTIVITY_PREVIEW_LIMIT - suffix.length
112
+ text = steps_title(command, room) || command_title(command).gsub(/\s+/, " ")
113
+ return nil if text.empty?
114
+
115
+ text = "#{text[0, room - 1]}…" if text.length > room
116
+ "#{text}#{suffix}"
117
+ end
118
+
93
119
  # A leading "cd <dir> &&" / "cd <dir>;" goes: the model's habit, and it
94
120
  # eats the line.
95
121
  LEADING_CD = /\Acd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*/
96
122
 
97
- # "<first step> +N" (N the steps after it), cut to fit
98
- # TOOL_ACTIVITY_PREVIEW_LIMIT; nil when the command has no steps.
99
- def steps_title(command)
123
+ # "<first step> +N" (N the steps after it), cut to fit +limit+
124
+ # (TOOL_ACTIVITY_PREVIEW_LIMIT); nil when the command has no steps.
125
+ def steps_title(command, limit = TOOL_ACTIVITY_PREVIEW_LIMIT)
100
126
  steps = CommandSteps.parse(command)&.steps
101
127
  return nil unless steps
102
128
 
103
129
  first = steps.first.text.gsub(/\s+/, " ")
104
130
  more = steps.size > 1 ? " +#{steps.size - 1}" : ""
105
- room = TOOL_ACTIVITY_PREVIEW_LIMIT - more.length
131
+ room = limit - more.length
106
132
  first = "#{first[0, room - 1]}…" if first.length > room
107
133
  "#{first}#{more}"
108
134
  end
@@ -117,13 +117,16 @@ module Samagotchi
117
117
  peers = kctx.peers
118
118
  id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
119
119
  model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
120
- vars = { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
121
- SESSION_MODEL_ENV => model.empty? ? nil : model }
122
- if env[ModelProfile::MODEL_FROM_CLI_ENV]
123
- vars[ModelProfile::MODEL_ENV] = nil
124
- vars[ModelProfile::MODEL_FROM_CLI_ENV] = nil
125
- end
126
- vars
120
+ { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
121
+ SESSION_MODEL_ENV => model.empty? ? nil : model }.merge(cli_model_unset(env))
122
+ end
123
+
124
+ # The unsets that keep a `chi --model X` worker's default from passing
125
+ # for the default of a chi run here (nothing when it wasn't the CLI's).
126
+ def cli_model_unset(env = ENV)
127
+ return {} unless env[ModelProfile::MODEL_FROM_CLI_ENV]
128
+
129
+ { ModelProfile::MODEL_ENV => nil, ModelProfile::MODEL_FROM_CLI_ENV => nil }
127
130
  end
128
131
 
129
132
  SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
@@ -129,6 +129,27 @@ module Samagotchi
129
129
  end.uniq
130
130
  end
131
131
 
132
+ # A task id as task_create makes them: no path parts.
133
+ TASK_ID = /\A[A-Za-z0-9_-]+\z/
134
+
135
+ # The command of the task +task_id+ under +root+ (a session's working
136
+ # directory) as its record has it, for a UI row's title; nil for no
137
+ # such task or an unreadable record. Reads the record only (no
138
+ # refresh), so it is cheap and never fails.
139
+ # @return [String, nil]
140
+ def command_of(task_id, root: Dir.pwd)
141
+ id = task_id.to_s.strip
142
+ return nil unless id.match?(TASK_ID)
143
+
144
+ path = File.join(root || Dir.pwd, TASKS_DIR, id, METADATA_FILENAME)
145
+ return nil unless File.file?(path)
146
+
147
+ command = JSON.parse(File.read(path))["command"]
148
+ command.is_a?(String) && !command.strip.empty? ? command : nil
149
+ rescue StandardError
150
+ nil
151
+ end
152
+
132
153
  def stopped_by_user?(record) = record["stop_reason"] == "stopped_by_user"
133
154
 
134
155
  def get_record(task_id)
@@ -143,7 +143,7 @@ module Samagotchi
143
143
  def cut_retry(by, reason)
144
144
  why = reason.to_s.strip.empty? ? "" : ": #{one_line(reason)}"
145
145
  retry_nudge(message("your last reply was cut off by #{by || "a plugin"}#{why}. Don't start the same reasoning " \
146
- "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."))
147
147
  end
148
148
 
149
149
  # A retry nudge is marked, so the spent one is found by the mark
@@ -188,6 +188,16 @@ module Samagotchi
188
188
  list
189
189
  end
190
190
 
191
+ # A copy of +messages+ without the note at its tail when that note is a
192
+ # retry nudge (anything else stays: an earlier turn's note is what says
193
+ # why that turn ended).
194
+ def without_trailing_nudge(messages)
195
+ list = Array(messages).dup
196
+ index = trailing_index(list)
197
+ list.delete_at(index) if index && retry_nudge?(list[index])
198
+ list
199
+ end
200
+
191
201
  # The index of the note at the tail of +list+ (behind context notes at
192
202
  # most), or nil.
193
203
  def trailing_index(list)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.27.0"
4
+ VERSION = "0.29.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
@@ -39,7 +39,7 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
39
39
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
40
40
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
41
41
  import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
42
- import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
42
+ import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
43
43
  import { createNotifications } from "./notifications.js";
44
44
  import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
45
45
  import {
@@ -328,8 +328,12 @@ function cardHtml(s, { deletable = false } = {}) {
328
328
  const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens))}">${escapeHtml(ctx)}</span>` : "";
329
329
  const waiting = waitingBadge(s);
330
330
  const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
331
+ // A turn a hook stopped wears its own badge, unless the session waits on
332
+ // the user (the waiting badge wins: the stop is behind the need).
333
+ const stopped = waiting ? null : stoppedBadge(s);
334
+ const stoppedHtml = stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "";
331
335
  const dotWord = waiting ? waiting.title : s.status;
332
- return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
336
+ return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${stoppedHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
333
337
  }
334
338
 
335
339
  // A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
@@ -1483,6 +1487,10 @@ function clearTimingInterval() {
1483
1487
  }
1484
1488
 
1485
1489
  let liveTurnNumber = null;
1490
+ // The live line sits in the stage's status row, which already says the turn
1491
+ // runs ("running a tool"): it drops its own "running" there. It never moves
1492
+ // mid-turn (finishLiveTurnTiming rewrites it before any hand-off).
1493
+ let liveTurnTimingInStage = false;
1486
1494
 
1487
1495
  function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null) {
1488
1496
  clearTimingInterval();
@@ -1495,7 +1503,9 @@ function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null
1495
1503
  liveTurnTimingEl.className = "turn-timing live";
1496
1504
  // The stage's status row while it holds the turn: the line moves into the
1497
1505
  // history with it.
1498
- (turnDom.timingHost?.() || historyEl).appendChild(liveTurnTimingEl);
1506
+ const stageHost = turnDom.timingHost?.() || null;
1507
+ liveTurnTimingInStage = !!stageHost;
1508
+ (stageHost || historyEl).appendChild(liveTurnTimingEl);
1499
1509
  updateLiveTiming();
1500
1510
  timingInterval = setInterval(updateLiveTiming, 1000);
1501
1511
  }
@@ -1509,7 +1519,7 @@ function appendToHistory(el) {
1509
1519
  function updateLiveTiming() {
1510
1520
  const duration = elapsedSince(current.timing.activeTurn?.started_at);
1511
1521
  if (liveTurnTimingEl && duration !== null) {
1512
- liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
1522
+ liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: !liveTurnTimingInStage }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
1513
1523
  }
1514
1524
  updateInfoBar();
1515
1525
  }