samagotchi 0.44.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/docs/cli.md +29 -7
  4. data/docs/context.md +2 -1
  5. data/docs/plugins.md +4 -2
  6. data/docs/sessions.md +8 -4
  7. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
  8. data/lib/samagotchi/bridge.rb +22 -10
  9. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  10. data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
  11. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  13. data/lib/samagotchi/commands/registry.rb +54 -9
  14. data/lib/samagotchi/engine.rb +8 -3
  15. data/lib/samagotchi/host_registry.rb +28 -3
  16. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  17. data/lib/samagotchi/model_notes.rb +8 -6
  18. data/lib/samagotchi/model_overlay.rb +3 -2
  19. data/lib/samagotchi/model_profile.rb +71 -2
  20. data/lib/samagotchi/plugin/context.rb +19 -7
  21. data/lib/samagotchi/self_report.rb +31 -2
  22. data/lib/samagotchi/session_commands.rb +24 -7
  23. data/lib/samagotchi/session_manager.rb +19 -1
  24. data/lib/samagotchi/session_metrics.rb +39 -6
  25. data/lib/samagotchi/sessions_command.rb +15 -5
  26. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
  27. data/lib/samagotchi/terminal_ui/formatting.rb +19 -1
  28. data/lib/samagotchi/terminal_ui.rb +84 -12
  29. data/lib/samagotchi/version.rb +1 -1
  30. data/lib/samagotchi/web/app.rb +7 -9
  31. data/lib/samagotchi/web/public/app.js +23 -15
  32. data/lib/samagotchi/web/public/command_complete.js +3 -1
  33. data/lib/samagotchi/web/public/ctx.js +42 -15
  34. data/lib/samagotchi/web/public/index.html +3 -1
  35. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  36. data/lib/samagotchi/web/public/stage_model.js +18 -4
  37. data/lib/samagotchi/web/public/stage_view.js +3 -3
  38. data/lib/samagotchi/web/public/turn_events.js +16 -4
  39. data/lib/samagotchi/web/session_hub.rb +9 -1
  40. data/lib/samagotchi/web/session_summary.rb +19 -2
  41. data/lib/samagotchi/worker.rb +156 -20
  42. data/lib/samagotchi/worker_inbound.rb +6 -5
  43. metadata +1 -1
@@ -235,6 +235,26 @@ module Samagotchi
235
235
  end
236
236
  end
237
237
 
238
+ # List one host's models and save them to ModelListStore: what
239
+ # ModelProfile.model_warning re-lists on a miss (a fresh saved list that
240
+ # lacks the id: the server may have been reloaded with another model),
241
+ # so the saved list catches up at spawn time. Only that host is asked,
242
+ # and never through the cache (a fresh listing is the point).
243
+ # @param name [String, Symbol, nil] a host name; anything else lists
244
+ # nothing
245
+ # @return [Array<String>, nil] the ids the host lists now, nil for an
246
+ # unknown name or when the host fails to list (nothing is saved then:
247
+ # a host that is down keeps the ids we know)
248
+ def list_models(name)
249
+ entry = name.nil? ? nil : find_entry(name)
250
+ return nil unless entry
251
+
252
+ save_model_list(entry.name, list_models_for(entry))&.ids
253
+ rescue StandardError => e
254
+ Log.warn(:model, "list_failed", host: name.to_s, error: e.class.name, msg: e.message.to_s[0, 500])
255
+ nil
256
+ end
257
+
238
258
  def client_for_model(raw_model)
239
259
  host_entry, bare = host_for_model(raw_model)
240
260
  [client_for(host_entry), bare, host_entry]
@@ -293,9 +313,7 @@ module Samagotchi
293
313
  models = list_models_for(entry)
294
314
  data = { host: entry.host, port: entry.port, transport: entry.transport, models: models, error: nil }
295
315
  @mutex.synchronize { @host_lists[name] = { data: data, at: @clock.call } }
296
- # On disk too: a process that spawns a worker without listing
297
- # (`chi send --new --model`, delegate) checks an id against it.
298
- ModelListStore.save(name, models.map(&:id))
316
+ save_model_list(name, models)
299
317
  rescue StandardError => e
300
318
  Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
301
319
  data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }
@@ -350,6 +368,13 @@ module Samagotchi
350
368
 
351
369
  private
352
370
 
371
+ # Save a host's just-listed models on disk, for the processes that spawn
372
+ # a worker without listing (`chi send --new --model`, delegate):
373
+ # ModelListStore is what they check an id against.
374
+ def save_model_list(name, models)
375
+ ModelListStore.save(name, Array(models).map(&:id))
376
+ end
377
+
353
378
  def fresh_list(name, entry)
354
379
  @mutex.synchronize do
355
380
  cached = @host_lists[name]
@@ -167,6 +167,43 @@ module Samagotchi
167
167
  budget_tokens: budget_origin)
168
168
  end
169
169
 
170
+ # A session's LLM context strategy, apply rule and budget, each with
171
+ # where it came from (Explained#summary), as its worker would resolve
172
+ # them; nil when they can't be (an unreadable config, a model that
173
+ # resolves nowhere). Resolves from config and the registry only: no
174
+ # network, no model call. What a session page, a session list's ctx %
175
+ # and a spawn-time check all read.
176
+ # @param session [Session, nil]
177
+ # @param registry [HostRegistry, nil] the hosts to resolve against;
178
+ # HostRegistry.new by default (what the caller usually has)
179
+ # @return [Hash, nil] Explained#summary, symbol keys
180
+ def for_session(session, registry: nil)
181
+ return nil if session.nil?
182
+
183
+ require_relative "config"
184
+ registry ||= begin
185
+ require_relative "host_registry"
186
+ HostRegistry.new
187
+ end
188
+ name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
189
+ target = registry.resolve(name)
190
+ names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
191
+ own = session.llm_context
192
+ explain(target, names: names, **(own&.resolve_args || {})).summary(own)
193
+ rescue StandardError
194
+ nil
195
+ end
196
+
197
+ # The budget a session's turns run under (for_session's :budget_tokens),
198
+ # nil when it is off or can't be resolved. What a session list's ctx %
199
+ # counts against, so a saved session's fill reads as the live meter.
200
+ # @param session [Session, nil]
201
+ # @param registry [HostRegistry, nil] see .for_session
202
+ # @return [Integer, nil]
203
+ def session_budget(session, registry: nil)
204
+ for_session(session, registry: registry)&.fetch(:budget_tokens, nil)
205
+ end
206
+
170
207
  # [the budget, its Origin]: the session's own first (0 there is off,
171
208
  # whatever the model says).
172
209
  def budget_for(target, names, models, session_budget)
@@ -63,7 +63,8 @@ module Samagotchi
63
63
  files(scope, env).filter_map do |path|
64
64
  next unless seen.add?(File.expand_path(path))
65
65
 
66
- note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once)
66
+ note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once,
67
+ env: env)
67
68
  end
68
69
  end
69
70
  warn_sizes(notes) if warn
@@ -110,7 +111,7 @@ module Samagotchi
110
111
  []
111
112
  end
112
113
 
113
- def note_at(path, scope, name:, key:, fallback_key:, muted:, small:)
114
+ def note_at(path, scope, name:, key:, fallback_key:, muted:, small:, env: ENV)
114
115
  stem = File.basename(path, ".md")
115
116
  return nil if dotted?(path, stem)
116
117
  return nil if MutedMemories.muted?(stem, muted)
@@ -122,7 +123,7 @@ module Samagotchi
122
123
  body = File.read(path, encoding: "UTF-8").sub(/\A[^\n]*\n?/, "").strip
123
124
  return nil if body.empty?
124
125
 
125
- body = with_overlay(body, stem, scope, [key, fallback_key])
126
+ body = with_overlay(body, stem, scope, [key, fallback_key], env: env)
126
127
 
127
128
  Note.new(name: stem, scope: scope, body: body, chars: body.length, digest: Digest::SHA256.hexdigest(body)[0, 12])
128
129
  rescue StandardError
@@ -131,10 +132,11 @@ module Samagotchi
131
132
 
132
133
  # The note's model overlay appended as memory_read appends one: the
133
134
  # key's, else the fallback key's. The file is read by its own path, not
134
- # by name through memory_read (whose names are comma lists).
135
- def with_overlay(body, stem, scope, keys)
135
+ # by name through memory_read (whose names are comma lists). +env+: the
136
+ # XDG env the scope's dir (and so the overlay) resolves from.
137
+ def with_overlay(body, stem, scope, keys, env: ENV)
136
138
  keys.compact.each do |overlay_key|
137
- overlay = ModelOverlay.overlay_path_for(stem, overlay_key, scope)
139
+ overlay = ModelOverlay.overlay_path_for(stem, overlay_key, scope, env: env)
138
140
  next unless overlay && File.file?(overlay)
139
141
 
140
142
  return "#{body}#{Tools::MemoryRead::SEPARATOR}Model-specific guidance (#{overlay_key}):\n" \
@@ -38,10 +38,11 @@ module Samagotchi
38
38
 
39
39
  # Return the expected file path for an overlay in the given scope.
40
40
  # Respects MemoryBundle overrides (delegates to Tools::MemoryRead.memories_dir).
41
- def self.overlay_path_for(name, key, scope)
41
+ # @param env [Hash] the XDG env the scope's memories dir resolves from
42
+ def self.overlay_path_for(name, key, scope, env: ENV)
42
43
  return nil unless key && !key.to_s.strip.empty?
43
44
 
44
- File.join(Tools::MemoryRead.memories_dir(scope), "#{name}.#{key.to_s.strip}.md")
45
+ File.join(Tools::MemoryRead.memories_dir(scope, env: env), "#{name}.#{key.to_s.strip}.md")
45
46
  end
46
47
 
47
48
  # Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z
@@ -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) }