samagotchi 0.43.0 → 0.45.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/docs/cli.md +29 -7
  4. data/docs/configuration.md +18 -0
  5. data/docs/context.md +2 -1
  6. data/docs/internals/context-telemetry.md +4 -1
  7. data/docs/internals/engine.md +1 -0
  8. data/docs/plugins.md +4 -2
  9. data/docs/sessions.md +8 -4
  10. data/lib/samagotchi/bridge/card_store.rb +20 -9
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
  12. data/lib/samagotchi/bridge/turn_notice.rb +3 -1
  13. data/lib/samagotchi/bridge.rb +22 -10
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  18. data/lib/samagotchi/commands/registry.rb +54 -9
  19. data/lib/samagotchi/context_status.rb +18 -5
  20. data/lib/samagotchi/engine.rb +8 -3
  21. data/lib/samagotchi/host_registry.rb +28 -3
  22. data/lib/samagotchi/kernel_loop.rb +24 -9
  23. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  24. data/lib/samagotchi/llm_context_forget.rb +6 -2
  25. data/lib/samagotchi/llm_context_notice.rb +96 -0
  26. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  27. data/lib/samagotchi/model_notes.rb +8 -6
  28. data/lib/samagotchi/model_overlay.rb +3 -2
  29. data/lib/samagotchi/model_profile.rb +71 -2
  30. data/lib/samagotchi/plugin/context.rb +19 -7
  31. data/lib/samagotchi/self_report.rb +31 -2
  32. data/lib/samagotchi/session_commands.rb +24 -7
  33. data/lib/samagotchi/session_manager.rb +19 -1
  34. data/lib/samagotchi/session_metrics.rb +39 -6
  35. data/lib/samagotchi/sessions_command.rb +15 -5
  36. data/lib/samagotchi/terminal_ui/attached_loop.rb +87 -4
  37. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
  38. data/lib/samagotchi/terminal_ui/formatting.rb +26 -1
  39. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
  40. data/lib/samagotchi/terminal_ui.rb +84 -12
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +14 -13
  43. data/lib/samagotchi/web/message_parts.rb +90 -11
  44. data/lib/samagotchi/web/public/app.js +38 -19
  45. data/lib/samagotchi/web/public/command_complete.js +3 -1
  46. data/lib/samagotchi/web/public/ctx.js +42 -15
  47. data/lib/samagotchi/web/public/index.html +7 -1
  48. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  49. data/lib/samagotchi/web/public/stage_model.js +18 -4
  50. data/lib/samagotchi/web/public/stage_view.js +3 -3
  51. data/lib/samagotchi/web/public/turn_events.js +50 -4
  52. data/lib/samagotchi/web/public/turn_html.js +33 -2
  53. data/lib/samagotchi/web/public/turn_view.js +4 -2
  54. data/lib/samagotchi/web/session_hub.rb +9 -1
  55. data/lib/samagotchi/web/session_summary.rb +19 -2
  56. data/lib/samagotchi/worker.rb +156 -20
  57. data/lib/samagotchi/worker_inbound.rb +6 -5
  58. metadata +2 -1
@@ -187,7 +187,6 @@ module Samagotchi
187
187
  # named before the worker's first turn fails on it. Only a warning: some
188
188
  # hosts serve ids they don't list (a one-model llama.cpp server takes any
189
189
  # name, OpenRouter's :nitro variants), so the session starts anyway.
190
- # Never asks a host.
191
190
  #
192
191
  # Only a ref that names a host is checked (its alias resolved first, as
193
192
  # ModelRef.parse does), and only against that host's own list: a bare id
@@ -195,11 +194,24 @@ module Samagotchi
195
194
  # single list can judge it. Nothing is checked either without a saved
196
195
  # list for the host or with one older than ModelListStore::TTL_SECONDS
197
196
  # (a week: the host may serve different models by now).
197
+ #
198
+ # A saved list that lacks the id may itself be stale by content: a
199
+ # one-model server was reloaded with another model while the list still
200
+ # names the old one. So a miss in a saved list older than
201
+ # RELIST_AFTER_SECONDS re-lists that one host once, judges again against
202
+ # what it lists now, and lets the saved list take the re-list's ids (the
203
+ # default re-list saves them too). A younger saved list, a hit, no list,
204
+ # a stale list (TTL_SECONDS) or a ref that names no host never asks a
205
+ # host; a re-list that fails, times out (RE_LIST_TIMEOUT_SECONDS) or
206
+ # lists nothing warns from the saved list (a host that is down is no
207
+ # evidence either way).
198
208
  # @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
199
209
  # entries); config.yml's by default
200
210
  # @param lists [#read] ModelListStore by default
211
+ # @param relist [#call, nil] how a miss re-lists the host:
212
+ # (host_name, env) -> ids or nil. HostRegistry#list_models by default.
201
213
  # @return [String, nil]
202
- def self.model_warning(model_name, env: ENV, hosts: nil, lists: nil)
214
+ def self.model_warning(model_name, env: ENV, hosts: nil, lists: nil, relist: nil)
203
215
  require_relative "config"
204
216
  require_relative "model_list_store"
205
217
  hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
@@ -210,10 +222,67 @@ module Samagotchi
210
222
 
211
223
  list = lists.read(env: env)[host.to_s.strip.downcase]
212
224
  return nil if list.nil? || list.stale? || list.known?(parsed.id)
225
+ # A saved list this recent is taken at its word. Re-listing every miss
226
+ # would make each launch of an id a host serves but never lists (a
227
+ # gateway's round-robin aliases) pay the re-list's cap and warn
228
+ # anyway; a list that old is the case worth catching (a one-model
229
+ # server reloaded with another model).
230
+ return unknown_model_message(parsed.id, host, list.ids) if list.age < RELIST_AFTER_SECONDS
231
+
232
+ fresh_ids = relist_or_nil(relist || default_relist(hosts), host, env)
233
+ # A re-list that answered: the saved list catches up (the default
234
+ # re-list, HostRegistry#list_models, saved it already).
235
+ if fresh_ids
236
+ Samagotchi::ModelListStore.save(host, fresh_ids, env: env)
237
+ return nil if fresh_ids.any? { |id| id.to_s.casecmp?(parsed.id.to_s.strip) }
238
+
239
+ return unknown_model_message(parsed.id, host, fresh_ids)
240
+ end
213
241
 
242
+ # No answer (failed, timed out, listed nothing): the saved list stands.
214
243
  unknown_model_message(parsed.id, host, list.ids)
215
244
  end
216
245
 
246
+ # How long a miss's re-list may take before it is treated as failed: a
247
+ # spawn (`chi send --new --model`, delegate) warns from the saved list
248
+ # then, rather than wait on a slow host.
249
+ RE_LIST_TIMEOUT_SECONDS = 5
250
+
251
+ # A saved list younger than this is not re-listed on a miss (the other
252
+ # way it can be wrong: stale by CONTENT within its TTL, a one-model
253
+ # server reloaded with another model). A host that serves ids it never
254
+ # lists (a gateway's round-robin aliases) would otherwise pay the
255
+ # re-list's cap on every launch.
256
+ RELIST_AFTER_SECONDS = 10 * 60
257
+
258
+ # The default re-list: one HostRegistry, one host listed (and saved).
259
+ # Bounded like `chi models`' `wait:`: a thread still listing at the cap
260
+ # is left running and its answer ignored (only a short-lived spawn is
261
+ # ever here).
262
+ # @return [#call] (host_name, env) -> ids or nil
263
+ def self.default_relist(hosts)
264
+ lambda do |host_name, relist_env|
265
+ require_relative "host_registry"
266
+ registry = Samagotchi::HostRegistry.new(hosts_config: hosts, env: relist_env)
267
+ answer = nil
268
+ thread = Thread.new { answer = registry.list_models(host_name) }
269
+ thread.join(RE_LIST_TIMEOUT_SECONDS)
270
+ answer
271
+ end
272
+ end
273
+ private_class_method :default_relist
274
+
275
+ # The re-list's ids, or nil when it fails, times out or lists nothing.
276
+ # A failing re-list is no evidence either way: the caller warns from the
277
+ # saved list.
278
+ def self.relist_or_nil(relist, host, env)
279
+ ids = relist.call(host, env)
280
+ ids.respond_to?(:any?) && ids.any? ? ids : nil
281
+ rescue StandardError
282
+ nil
283
+ end
284
+ private_class_method :relist_or_nil
285
+
217
286
  # The warning for an id the host doesn't list, with up to three close
218
287
  # ids (Config.near_names) when there are any.
219
288
  def self.unknown_model_message(id, host, ids)
@@ -105,13 +105,15 @@ module Samagotchi
105
105
 
106
106
  # Whether the session is a delegate child (Session#delegate?): a task
107
107
  # another session handed over, not the user's own.
108
- def delegate?
109
- id = session_id
110
- return false unless id
111
-
112
- Session.load(id, state_dir: @host.state_dir&.call || Session.default_state_dir).delegate?
113
- rescue ArgumentError
114
- false
108
+ def delegate? = !!saved_session&.delegate?
109
+
110
+ # Whether the session is a fork (ctx.sessions.fork: /btw keep, a
111
+ # plugin's): it has a parent and isn't a delegate child, and it
112
+ # started from a conversation, so its first prompt isn't its first
113
+ # user message.
114
+ def fork?
115
+ session = saved_session
116
+ !session.nil? && !session.parent_id.nil? && !session.delegate?
115
117
  end
116
118
 
117
119
  # @return [String] the session's working directory
@@ -297,6 +299,16 @@ module Samagotchi
297
299
  helper.respond_to?(:call) ? helper : nil
298
300
  end
299
301
 
302
+ # The session's file as saved, nil without an id or a file.
303
+ def saved_session
304
+ id = session_id
305
+ return nil unless id
306
+
307
+ Session.load(id, state_dir: @host.state_dir&.call || Session.default_state_dir)
308
+ rescue ArgumentError
309
+ nil
310
+ end
311
+
300
312
  def deep_freeze(value)
301
313
  case value
302
314
  when Hash then value.to_h { |k, v| [deep_freeze(k), deep_freeze(v)] }.freeze
@@ -197,14 +197,43 @@ module Samagotchi
197
197
  # takes them at its next build (/stats names the ones it carries).
198
198
  def model_notes_for(model, env)
199
199
  bare = HostRegistry.new(env: env).bare_name(model)
200
- notes = ModelNotes.for(name: bare, key: ModelOverlay.key_for(bare), muted: session_muted(env), env: env,
201
- warn: false)
200
+ key = ModelOverlay.key_for(bare)
201
+ notes = ModelNotes.for(name: bare, key: key, fallback_key: fallback_model_key(model, key, env),
202
+ muted: session_muted(env), env: env, warn: false)
202
203
  text = PromptNote.text(notes.map { |note| PromptNote.from_note(note) })
203
204
  text.empty? ? "none" : text
204
205
  rescue StandardError => e
205
206
  "(unreadable: #{e.message})"
206
207
  end
207
208
 
209
+ # The overlay key read when the key has none (SystemPrompt::ModelIdentity
210
+ # #fallback_key): the key of the name the model was typed as, when the
211
+ # session chi self runs in has one (an alias), else of the name chi self
212
+ # reports. nil when that is the key itself. Read-only, never raising.
213
+ def fallback_model_key(model, key, env)
214
+ typed = session_typed_model(env) || model
215
+ typed_key = ModelOverlay.key_for(HostRegistry.new(env: env).parse_qualified_model(typed).last)
216
+ typed_key == key ? nil : typed_key
217
+ rescue StandardError
218
+ nil
219
+ end
220
+
221
+ # The model name the session chi self runs in was typed as (an alias),
222
+ # its model_typed, else nil.
223
+ def session_typed_model(env)
224
+ id = env[PARENT_SESSION_ENV].to_s
225
+ return nil if id.empty? || !Session.valid_id?(id)
226
+
227
+ state_dir = Session.default_state_dir(env: env)
228
+ return nil unless Session.exist?(id, state_dir: state_dir)
229
+
230
+ session = Session.load(id, state_dir: state_dir)
231
+ typed = session.model_typed.to_s.strip
232
+ typed.empty? ? nil : typed
233
+ rescue StandardError
234
+ nil
235
+ end
236
+
208
237
  # The --mute list of the session chi self runs in (its execute exports
209
238
  # SAMAGOTCHI_PARENT_SESSION); [] outside one.
210
239
  def session_muted(env)
@@ -39,6 +39,9 @@ module Samagotchi
39
39
  # /exit --delete: delete the session on the way out.
40
40
  EXIT_DELETE_FLAG = "--delete"
41
41
 
42
+ # /help's note per listed mid_turn (the web's command list says the same).
43
+ MID_TURN_NOTES = { "anytime" => "mid-turn too", "depends" => "show: mid-turn too" }.freeze
44
+
42
45
  # @!attribute status [Symbol] :ok, or :error when the line was refused
43
46
  # @!attribute output [String, nil] what to tell the user
44
47
  # @!attribute changed [Array<Symbol>] :messages and/or :model
@@ -52,21 +55,28 @@ module Samagotchi
52
55
  # Puts the built-in commands into +registry+, in lookup order (!rollback
53
56
  # before !cmd): the ones #run runs, then the UIs' own (local: Tab and
54
57
  # help only).
58
+ #
59
+ # While a turn runs, a show form (the name alone) runs at once beside
60
+ # it (it only reads); setting forms and !cmd run after the turn; !rollback
61
+ # and /continue are refused (Commands::Registry::MID_TURN).
55
62
  def self.register_builtins(registry)
56
63
  registry.register(ROLLBACK_COMMAND, "discard the interrupted turn and restore the pre-turn state",
57
64
  id: :rollback) { |_text| rollback }
58
65
  registry.register("!", "run a shell command; its output goes into the conversation",
59
- id: :shell, match: ->(text) { text.match?(/\A!\s*\S/) }) { |text| shell(text) }
66
+ id: :shell, mid_turn: :queue, match: ->(text) { text.match?(/\A!\s*\S/) }) { |text| shell(text) }
60
67
  registry.register(CONTINUE_COMMAND, "answer the continue offer (yes, no or no, <reason>)") do |text|
61
68
  next reply("nothing to continue") unless @turn_flow.awaiting_continue?
62
69
 
63
70
  answer = text.delete_prefix(CONTINUE_COMMAND).strip
64
71
  continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
65
72
  end
66
- registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)") do |text|
73
+ # It reads the hosts only (the registry is mutex-guarded): mid-turn too.
74
+ registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)",
75
+ mid_turn: ->(_text) { :anytime }) do |text|
67
76
  reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
68
77
  end
69
- registry.register(GUARDRAILS_COMMAND, "list the guardrail rules and approvals (/guardrails revoke N)") do |text|
78
+ registry.register(GUARDRAILS_COMMAND, "list the guardrail rules and approvals (/guardrails revoke N)",
79
+ mid_turn: show_mid_turn(GUARDRAILS_COMMAND)) do |text|
70
80
  guardrails(text.delete_prefix(GUARDRAILS_COMMAND).strip)
71
81
  end
72
82
  # It only reads the store: mid-turn too.
@@ -74,11 +84,11 @@ module Samagotchi
74
84
  match: ->(text) { text.casecmp?(CONTEXT_COMMAND) }) do |_text|
75
85
  reply(context_listing)
76
86
  end
77
- registry.register(MODEL_COMMAND, "show or switch the model",
87
+ registry.register(MODEL_COMMAND, "show or switch the model", mid_turn: show_mid_turn(MODEL_COMMAND),
78
88
  match: ->(text) { text.match?(%r{\A/model(?:\s+.*)?\z}) }) { |text| model(text) }
79
- # Not anytime: it saves the session; it takes effect at the next turn's start.
89
+ # Setting it saves the session; it takes effect at the next turn's start.
80
90
  registry.register(LLM_CONTEXT_COMMAND, "show or set this session's LLM context strategy, apply rule and budget",
81
- id: :llm_context, match: ->(text) { text.match?(%r{\A/llm-context(?:\s+.*)?\z}) }) { |text| llm_context(text) }
91
+ id: :llm_context, mid_turn: show_mid_turn(LLM_CONTEXT_COMMAND), match: ->(text) { text.match?(%r{\A/llm-context(?:\s+.*)?\z}) }) { |text| llm_context(text) }
82
92
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
83
93
  registry.register("/stats", "show the session's stats", local: true)
84
94
  registry.register("/recap", "show the session's recap", local: true)
@@ -94,6 +104,13 @@ module Samagotchi
94
104
  registry
95
105
  end
96
106
 
107
+ # @return [#call] line → :anytime for +name+ alone (it shows), else
108
+ # :queue (it sets what the next turn runs under)
109
+ def self.show_mid_turn(name)
110
+ ->(text) { text == name ? :anytime : :queue }
111
+ end
112
+ private_class_method :show_mid_turn
113
+
97
114
  # @param word [String] the regexp source of the exit word
98
115
  def self.exit_match(word)
99
116
  pattern = /\A#{word}(?:\s+#{EXIT_DELETE_FLAG})?\z/i
@@ -149,7 +166,7 @@ module Samagotchi
149
166
  lines = entries.map do |entry|
150
167
  notes = []
151
168
  notes << entry.source unless entry.source == "core"
152
- notes << "mid-turn too" if entry.anytime
169
+ notes << MID_TURN_NOTES[entry.mid_turn_label] if MID_TURN_NOTES[entry.mid_turn_label]
153
170
  notes << (entry.uis ? "#{entry.uis.join(" and ")} only" : "terminal only") if entry.local
154
171
  line = " #{self.class.display_name(entry).ljust(width)} #{entry.description}"
155
172
  notes.empty? ? line : "#{line} (#{notes.join("; ")})"
@@ -12,6 +12,8 @@ require_relative "config"
12
12
  require_relative "session"
13
13
  require_relative "session_inbox"
14
14
  require_relative "session_metrics"
15
+ require_relative "llm_context_strategy"
16
+ require_relative "host_registry"
15
17
  require_relative "utf8_default"
16
18
  require_relative "turn_note"
17
19
  require_relative "owner_lock"
@@ -290,6 +292,10 @@ module Samagotchi
290
292
  sd = state_dir || Session.default_state_dir
291
293
  root = cwd && folder_path(cwd)
292
294
  roots = {}
295
+ # One registry per listing, built only when a row has a saved context
296
+ # to count: its session's llm_context budget is what ctx_pct counts
297
+ # against (SessionMetrics.saved_context_pct).
298
+ budgets = listing_budgets
293
299
  summaries = Session.list(state_dir: sd, sort: sort || "updated_at", order: order || "desc", project_root: project_root,
294
300
  include_archived: include_archived).lazy
295
301
  .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
@@ -302,7 +308,8 @@ module Samagotchi
302
308
  { id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
303
309
  project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
304
310
  owner: owner&.kind, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
305
- ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
311
+ ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
312
+ budget_tokens: -> { budgets.call(s) })&.round(1),
306
313
  parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), delegate: s.delegate?, archived: s.archived,
307
314
  scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
308
315
  end
@@ -316,6 +323,17 @@ module Samagotchi
316
323
  end
317
324
  private_class_method :waiting_fields
318
325
 
326
+ # One HostRegistry for a whole listing, built on the first row that
327
+ # needs it (a saved context to count) and kept for the rest: resolving a
328
+ # session's llm_context budget reads config.yml (mtime-cached), so not
329
+ # one registry per row.
330
+ # @return [#call] (session) -> the session's budget in tokens or nil
331
+ def self.listing_budgets
332
+ registry = nil
333
+ ->(session) { LLMContextStrategy.session_budget(session, registry: registry ||= HostRegistry.new) }
334
+ end
335
+ private_class_method :listing_budgets
336
+
319
337
  # The sessions delegated by +parent_id+ (the `delegate` tool), newest
320
338
  # first, as .session_summaries rows. A running one (busy) counts against
321
339
  # session.max_children. Archived ones too.
@@ -141,22 +141,29 @@ module Samagotchi
141
141
  # its saved analytics.json: a percentage, or nil when the file, the count
142
142
  # or the window is missing (the session lists read it per row).
143
143
  # @param session_dir [String]
144
+ # @param budget_tokens [Integer, Proc, nil] the session's LLM context
145
+ # budget; a positive one smaller than the window is what the fill is
146
+ # counted against (the live meter counts the same way,
147
+ # ContextStatus#counted_against). nil or 0: the window. A callable is
148
+ # resolved only when there is a context to count, so a caller can build
149
+ # a registry lazily.
144
150
  # @return [Float, nil]
145
- def self.saved_context_pct(session_dir)
146
- saved_summary(session_dir)&.ctx_pct
151
+ def self.saved_context_pct(session_dir, budget_tokens: nil)
152
+ saved_summary(session_dir, budget_tokens: budget_tokens)&.ctx_pct
147
153
  end
148
154
 
149
155
  # The context's fill and the token totals from the session's saved
150
156
  # analytics.json, read once; nil without a readable file.
151
157
  # @param session_dir [String]
158
+ # @param budget_tokens [Integer, Proc, nil] see .saved_context_pct
152
159
  # @return [SavedSummary, nil]
153
- def self.saved_summary(session_dir)
160
+ def self.saved_summary(session_dir, budget_tokens: nil)
154
161
  data = JSON.parse(File.read(File.join(session_dir, "analytics.json")))
155
162
  return nil unless data.is_a?(Hash)
156
163
 
157
164
  tokens = data["tokens"].is_a?(Hash) ? data["tokens"] : {}
158
165
  count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
159
- SavedSummary.new(ctx_pct: saved_pct(data["context"]), prompt_sum: count.call("prompt_sum"),
166
+ SavedSummary.new(ctx_pct: saved_pct(data["context"], budget_tokens), prompt_sum: count.call("prompt_sum"),
160
167
  completion_sum: count.call("completion_sum"), cached_sum: count.call("cached_sum"),
161
168
  reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
162
169
  memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
@@ -164,15 +171,25 @@ module Samagotchi
164
171
  nil
165
172
  end
166
173
 
167
- def self.saved_pct(context)
174
+ def self.saved_pct(context, budget_tokens)
168
175
  return nil unless context.is_a?(Hash)
169
176
 
170
177
  used = context["used_tokens"]
171
178
  window = context["window_tokens"]
172
179
  return nil unless used.is_a?(Numeric) && window.is_a?(Numeric) && window.positive?
173
180
 
174
- used * 100.0 / window
181
+ used * 100.0 / counted_against(budget_tokens, window)
175
182
  end
183
+
184
+ # What the saved fill counts against: the window, or the budget when one
185
+ # is set and smaller (ContextStatus#counted_against, the live meter's
186
+ # rule). The budget is resolved here, not by the caller, so a caller
187
+ # that builds one lazily doesn't build it for a row with no context.
188
+ def self.counted_against(budget_tokens, window_tokens)
189
+ budget = budget_tokens.respond_to?(:call) ? budget_tokens.call : budget_tokens
190
+ budget.is_a?(Numeric) && budget.positive? ? [budget, window_tokens].min : window_tokens
191
+ end
192
+ private_class_method :counted_against
176
193
  private_class_method :saved_pct
177
194
 
178
195
  # One turn's tool records from the session's saved analytics.json, in
@@ -192,6 +209,22 @@ module Samagotchi
192
209
  []
193
210
  end
194
211
 
212
+ # One turn's saved record from the session's analytics.json, string-keyed
213
+ # as saved: what a join replays the turn's failure (or cancel) line from.
214
+ # nil without a turn id (an older session's prompt), the file, records,
215
+ # or a record for the turn.
216
+ # @param session_dir [String]
217
+ # @param turn_id [String, nil]
218
+ # @return [Hash, nil]
219
+ def self.saved_turn_record(session_dir, turn_id)
220
+ return nil if turn_id.to_s.empty?
221
+
222
+ records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["turn_records"]
223
+ Array(records).find { |r| r.is_a?(Hash) && r["id"] == turn_id }
224
+ rescue JSON::ParserError, SystemCallError, TypeError
225
+ nil
226
+ end
227
+
195
228
  # "ctx 12%" for the session lists, "" when unknown.
196
229
  # @param pct [Float, nil]
197
230
  # @return [String]
@@ -226,14 +226,24 @@ module Samagotchi
226
226
  recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
227
227
  (recap || Samagotchi::SessionManager.summary_text(s))[0, 60]
228
228
  end
229
+ # How full the context was after the last turn: "ctx 12%", counted
230
+ # against the session's llm_context budget when one is set and smaller
231
+ # than the window, as the live meter counts it (one registry for the
232
+ # whole table, built when the first row has a saved context). Blank when
233
+ # unknown.
234
+ budgets = nil
235
+ ctx = lambda do |s|
236
+ Samagotchi::SessionMetrics.context_label(
237
+ Samagotchi::SessionMetrics.saved_context_pct(
238
+ Samagotchi::Session.session_dir(s.id, state_dir: state_dir),
239
+ budget_tokens: -> { Samagotchi::LLMContextStrategy.session_budget(s, registry: budgets ||= Samagotchi::HostRegistry.new) }
240
+ )
241
+ )
242
+ end
229
243
  # A delegated session points at its parent: ↳ <parent's short id>.
230
244
  row = lambda do |s|
231
245
  flag = session_flag(scratch: s.scratch, test_run: s.test_run, archived: s.archived, stopped_by: s.stopped_by)
232
246
  child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
233
- # How full the context was after the last turn: "ctx 12%", blank when unknown.
234
- ctx = Samagotchi::SessionMetrics.context_label(
235
- Samagotchi::SessionMetrics.saved_context_pct(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
236
- )
237
247
  # A question waits for an answer (chi answer, the web, chi --attach).
238
248
  live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
239
249
  waiting = s.waiting_question(live: !!live)
@@ -241,7 +251,7 @@ module Samagotchi
241
251
  elsif waiting then "waiting"
242
252
  else s.status
243
253
  end
244
- "#{s.id} #{status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
254
+ "#{s.id} #{status.ljust(8)} #{ctx.call(s).ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
245
255
  end
246
256
  if project
247
257
  sessions.each { |s| @stdout.puts row.call(s) }
@@ -126,6 +126,10 @@ module Samagotchi
126
126
  @early_lines = []
127
127
  @question = nil
128
128
  @answered_ids = Set.new
129
+ # Commands queued for after the running turn, any UI's: a /archive
130
+ # typed during the turn waits for them too (#archive_due?).
131
+ @queued_ids = Set.new
132
+ @archive_after_turn = false
129
133
  @reader = nil
130
134
  @first_prompt = first_prompt
131
135
  @first_commands = Array(first_command)
@@ -188,6 +192,7 @@ module Samagotchi
188
192
  when :event
189
193
  ended = safely_handle(payload)
190
194
  return ended if %i[closed failed unanswered].include?(ended)
195
+ return archive_now if archive_due?
191
196
 
192
197
  ended = take_early_lines
193
198
  return ended if ended
@@ -264,9 +269,11 @@ module Samagotchi
264
269
  when :answer_display then @display_pending = false
265
270
  when :context_status, :used_memories_updated then @status.take_event(event)
266
271
  # Another client's anytime command: its line now, before the cards
267
- # it shows (its command_ran comes when it's done).
272
+ # it shows (its command_ran comes when it's done). One queued for
273
+ # after the turn says so now.
268
274
  when :command_queued
269
275
  @screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id]) && !event[:card]
276
+ command_waits(event) if event[:waits] && !event[:card]
270
277
  when :command_ran
271
278
  command_ran(event)
272
279
  return first_command_ran(event) if @first_command_id && event[:command_id] == @first_command_id
@@ -379,6 +386,7 @@ module Samagotchi
379
386
  local = command_registry.lookup_local(text)&.id
380
387
  return submit(nil) if local == :detach
381
388
  return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
389
+ return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
382
390
  return exit_and_archive if local == :archive
383
391
 
384
392
  if @continue_offer
@@ -446,6 +454,24 @@ module Samagotchi
446
454
  detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): chi sessions delete #{id}")
447
455
  end
448
456
 
457
+ # /archive during a turn (D2): it waits for the turn's end and the
458
+ # commands queued behind it (#archive_due?), then runs.
459
+ # @return [nil]
460
+ def archive_after_turn
461
+ @archive_after_turn = true
462
+ @screen.commit("(archives after this turn)")
463
+ nil
464
+ end
465
+
466
+ def archive_due? = @archive_after_turn && !@running && @queued_ids.empty?
467
+
468
+ # @return [Symbol] :detached
469
+ def archive_now
470
+ @archive_after_turn = false
471
+ exit_and_archive
472
+ :detached
473
+ end
474
+
449
475
  # /archive: the worker is asked to exit as for /exit, then the session
450
476
  # is archived. A worker staying up for another UI (or reminders) is
451
477
  # stopped by the archive, as the web's archive stops it; a turn
@@ -546,13 +572,36 @@ module Samagotchi
546
572
  "worker unreachable: #{head.downcase}"
547
573
  end
548
574
 
575
+ QUEUED_NOTE = "(queued: runs after this turn)"
576
+
577
+ # A command queued for after the running turn (command_queued with
578
+ # waits): its line now (another client's) and a note; its command_ran
579
+ # has no line again (queued: true).
580
+ def command_waits(event)
581
+ @queued_ids << event[:command_id]
582
+ if own?(event[:client_id])
583
+ first = event[:command_id] == @first_command_id
584
+ return @screen.commit(first ? first_command_queued : QUEUED_NOTE)
585
+ end
586
+
587
+ @screen.commit("#{prompt_line(event[:client_id], event[:line])} #{QUEUED_NOTE}")
588
+ end
589
+
590
+ def first_command_queued
591
+ flag = @first_command_line.to_s.start_with?("/model") ? "--model" : "--llm-context"
592
+ "(#{flag} queued: applies after the running turn)"
593
+ end
594
+
549
595
  def command_ran(event)
550
596
  @open_ids.delete(event[:command_id])
551
- @screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
597
+ @queued_ids.delete(event[:command_id])
598
+ unless own?(event[:client_id]) || event[:anytime] || event[:card] || event[:queued]
599
+ @screen.commit(prompt_line(event[:client_id], event[:line]))
600
+ end
552
601
  output = event[:output].to_s
553
602
  if output.empty?
554
603
  nil
555
- elsif event[:status] == "busy" || PromptHistory.shell_line?(event[:line])
604
+ elsif %w[busy dropped].include?(event[:status]) || PromptHistory.shell_line?(event[:line])
556
605
  @screen.commit(output)
557
606
  put_back_busy_command(event) if event[:status] == "busy"
558
607
  else
@@ -992,6 +1041,14 @@ module Samagotchi
992
1041
  @shown_enqueued << entry[:enqueued_id]
993
1042
  @screen.commit("queued #{prompt_line(entry[:client_id], entry[:prompt])}")
994
1043
  end
1044
+ # Commands waiting for the turn's end (their command_ran prints no
1045
+ # line); a resync prints only the ones it hadn't shown.
1046
+ shown = @queued_ids.dup
1047
+ @queued_ids.clear
1048
+ Array(snapshot[:queued_commands]).each do |entry|
1049
+ @queued_ids << entry[:command_id]
1050
+ @screen.commit("queued #{prompt_line(entry[:client_id], entry[:line])}") unless shown.include?(entry[:command_id])
1051
+ end
995
1052
  return send_first_command unless @first_commands.empty?
996
1053
 
997
1054
  send_first_prompt
@@ -1076,10 +1133,18 @@ module Samagotchi
1076
1133
  Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
1077
1134
  from = messages.index { |m| m.equal?(exchange[last_user]) }
1078
1135
  render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
1136
+ failed = join_failed_record(exchange[last_user])
1079
1137
  # A turn that ended with no answer: its notice, as live.
1080
1138
  empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
1081
1139
  if empty
1082
1140
  @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
1141
+ elsif failed
1142
+ # A failed turn whose steps stayed: the line the live view left,
1143
+ # and no step text as if it were the answer (the web keeps a
1144
+ # failed turn's texts as steps too).
1145
+ failure = failed["failure"]
1146
+ @screen.commit(@view.turn_failed_line(failure["summary"] || failure["message"], failed["duration_ms"]))
1147
+ @screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) if failure["kept_steps"]
1083
1148
  else
1084
1149
  # The saved answer is raw: the latest keeps its thinking, and one may
1085
1150
  # be only a tool call.
@@ -1119,6 +1184,23 @@ module Samagotchi
1119
1184
  []
1120
1185
  end
1121
1186
 
1187
+ # The turn's saved record when it failed with the fields its line is
1188
+ # made of (SessionMetrics#failure_fields), else nil: a completed,
1189
+ # canceled or older turn — or one saved before the failure field —
1190
+ # draws its answer as before.
1191
+ def join_failed_record(prompt)
1192
+ record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id),
1193
+ prompt[:turn_id] || prompt["turn_id"])
1194
+ return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
1195
+
1196
+ failure = record["failure"]
1197
+ return nil if failure["summary"].to_s.empty? && failure["message"].to_s.empty?
1198
+
1199
+ record
1200
+ rescue Session::InvalidId
1201
+ nil
1202
+ end
1203
+
1122
1204
  # The duration of +part+'s call: the next record of its tool, in call
1123
1205
  # order, consumed with the ones it skipped. By tool and order, not by
1124
1206
  # iteration: an empty retry spends an iteration that left no message.
@@ -1222,7 +1304,8 @@ module Samagotchi
1222
1304
  # hook's notice, a retry's line), as they were drawn live.
1223
1305
  # Questions are left out: the pending one is asked after the
1224
1306
  # replay, answered ones showed no line.
1225
- when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut then @renderer.call(event)
1307
+ when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut, :llm_context_edited
1308
+ @renderer.call(event)
1226
1309
  end
1227
1310
  end
1228
1311
  @view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
@@ -83,6 +83,9 @@ module Samagotchi
83
83
  when :steer_cut
84
84
  @view.finish_thinking_spinner
85
85
  @view.print_line(@view.format_steer_cut_line(event))
86
+ when :llm_context_edited
87
+ @view.finish_thinking_spinner
88
+ @view.print_line(@view.format_llm_context_line(event))
86
89
  when :turn_completed
87
90
  render_turn_summary(event[:turn_summary]) if event[:turn_summary]
88
91
  when :turn_canceled