samagotchi 0.44.0 → 0.46.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +120 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +43 -12
  5. data/docs/configuration.md +102 -5
  6. data/docs/context.md +2 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-live.md +111 -0
  10. data/docs/plugins.md +4 -2
  11. data/docs/sessions.md +9 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +19 -0
  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/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  18. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  19. data/lib/samagotchi/commands/registry.rb +54 -9
  20. data/lib/samagotchi/config.rb +57 -4
  21. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  22. data/lib/samagotchi/engine.rb +47 -9
  23. data/lib/samagotchi/host_model.rb +116 -0
  24. data/lib/samagotchi/host_registry.rb +43 -12
  25. data/lib/samagotchi/kernel_loop.rb +2 -1
  26. data/lib/samagotchi/llm/chat_loop.rb +89 -7
  27. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  28. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  29. data/lib/samagotchi/model_catalog.rb +17 -12
  30. data/lib/samagotchi/model_notes.rb +8 -6
  31. data/lib/samagotchi/model_overlay.rb +3 -2
  32. data/lib/samagotchi/model_price.rb +54 -0
  33. data/lib/samagotchi/model_profile.rb +74 -3
  34. data/lib/samagotchi/plugin/context.rb +19 -7
  35. data/lib/samagotchi/process_group.rb +56 -9
  36. data/lib/samagotchi/recap_setup.rb +1 -1
  37. data/lib/samagotchi/self_report.rb +33 -3
  38. data/lib/samagotchi/session_commands.rb +47 -21
  39. data/lib/samagotchi/session_manager.rb +19 -1
  40. data/lib/samagotchi/session_metrics.rb +79 -11
  41. data/lib/samagotchi/sessions_command.rb +15 -5
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
  43. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  44. data/lib/samagotchi/terminal_ui/formatting.rb +45 -10
  45. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  46. data/lib/samagotchi/terminal_ui.rb +97 -12
  47. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  48. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +12 -11
  51. data/lib/samagotchi/web/public/app.js +30 -18
  52. data/lib/samagotchi/web/public/command_complete.js +3 -1
  53. data/lib/samagotchi/web/public/ctx.js +62 -22
  54. data/lib/samagotchi/web/public/format.js +7 -2
  55. data/lib/samagotchi/web/public/index.html +3 -1
  56. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  57. data/lib/samagotchi/web/public/model_pick.js +23 -3
  58. data/lib/samagotchi/web/public/model_picker.js +5 -3
  59. data/lib/samagotchi/web/public/stage_model.js +18 -4
  60. data/lib/samagotchi/web/public/stage_view.js +4 -3
  61. data/lib/samagotchi/web/public/turn_events.js +19 -6
  62. data/lib/samagotchi/web/public/turn_model.js +16 -0
  63. data/lib/samagotchi/web/public/turn_view.js +19 -0
  64. data/lib/samagotchi/web/session_hub.rb +9 -1
  65. data/lib/samagotchi/web/session_summary.rb +19 -2
  66. data/lib/samagotchi/worker.rb +172 -21
  67. data/lib/samagotchi/worker_inbound.rb +6 -5
  68. metadata +4 -1
@@ -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
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A model's price on a host (hosts.<name>.models.<id>.price), in USD per
5
+ # 1M tokens: what a generation's cost is estimated from when the provider
6
+ # reports none (SessionMetrics, cost_estimate). An unset cache rate is the
7
+ # input rate: that overstates cached reads, and understates Anthropic
8
+ # cache writes (billed 1.25x input for the 5 min cache, 2x for 1 h).
9
+ ModelPrice = Data.define(:input, :cache_read, :cache_write, :output) do
10
+ # @param raw [Hash, nil] the price as written
11
+ # @param where [String] for the warning
12
+ # @return [ModelPrice, nil] nil when unset or invalid (warned once)
13
+ def self.parse(raw, where)
14
+ return nil if raw.nil?
15
+
16
+ values = raw.is_a?(Hash) ? raw.transform_keys(&:to_s).slice(*ModelPrice::KEYS) : nil
17
+ valid = values && values.key?("input") && values.key?("output") &&
18
+ # YAML's .inf and .nan are Floats too: JSON can't carry them
19
+ # (hosts_json_for_env would drop every host) and NaN poisons the sums.
20
+ values.values.all? { |v| v.is_a?(Numeric) && v.finite? && !v.negative? }
21
+ unless valid
22
+ warn_once "Warning: #{where}.price needs input and output, each a number >= 0 (USD per 1M tokens); ignored"
23
+ return nil
24
+ end
25
+ new(input: values["input"], cache_read: values.fetch("cache_read", values["input"]),
26
+ cache_write: values.fetch("cache_write", values["input"]), output: values["output"])
27
+ end
28
+
29
+ # config.rb requires model_price through host_model, so config is
30
+ # required here only when a warning needs it.
31
+ def self.warn_once(message)
32
+ require_relative "config"
33
+ ConfigFile.warn_once(message)
34
+ end
35
+ private_class_method :warn_once
36
+
37
+ # USD for one generation, OpenAI usage semantics: +prompt_tokens+ holds
38
+ # the cached reads and the cache writes (OpenRouter keeps that for
39
+ # Anthropic models), and reasoning is inside +completion_tokens+.
40
+ # llama.cpp's native timings.prompt_n leaves its cache_n out, so the
41
+ # uncached part is clamped at 0: an undercount, harmless because priced
42
+ # models are remote.
43
+ def cost(prompt_tokens:, cached_tokens:, cache_write_tokens:, completion_tokens:)
44
+ uncached = (prompt_tokens - cached_tokens - cache_write_tokens).clamp(0..)
45
+ ((uncached * input) + (cached_tokens * cache_read) + (cache_write_tokens * cache_write) +
46
+ (completion_tokens * output)) / 1e6
47
+ end
48
+
49
+ def to_config = to_h.transform_keys(&:to_s)
50
+ end
51
+
52
+ # The keys a price may hold (config validation checks them too).
53
+ ModelPrice::KEYS = %w[input cache_read cache_write output].freeze
54
+ end
@@ -187,19 +187,31 @@ 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
194
- # goes to whichever host lists it (HostRegistry#host_for_model), so no
193
+ # goes to whichever host lists or declares it (HostRegistry#host_for_model), so no
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)
@@ -207,13 +219,72 @@ module Samagotchi
207
219
  parsed = Samagotchi::ConfigFile.model_ref(model_name, env: env, hosts: hosts)
208
220
  host = parsed.host_name
209
221
  return nil unless host
222
+ # Declared under hosts.<name>.models: served whatever the host lists.
223
+ return nil if hosts[host.to_s.strip.downcase]&.dig(:models)&.key?(parsed.id.to_s.strip.downcase)
210
224
 
211
225
  list = lists.read(env: env)[host.to_s.strip.downcase]
212
226
  return nil if list.nil? || list.stale? || list.known?(parsed.id)
227
+ # A saved list this recent is taken at its word. Re-listing every miss
228
+ # would make each launch of an id a host serves but never lists (a
229
+ # gateway's round-robin aliases) pay the re-list's cap and warn
230
+ # anyway; a list that old is the case worth catching (a one-model
231
+ # server reloaded with another model).
232
+ return unknown_model_message(parsed.id, host, list.ids) if list.age < RELIST_AFTER_SECONDS
233
+
234
+ fresh_ids = relist_or_nil(relist || default_relist(hosts), host, env)
235
+ # A re-list that answered: the saved list catches up (the default
236
+ # re-list, HostRegistry#list_models, saved it already).
237
+ if fresh_ids
238
+ Samagotchi::ModelListStore.save(host, fresh_ids, env: env)
239
+ return nil if fresh_ids.any? { |id| id.to_s.casecmp?(parsed.id.to_s.strip) }
240
+
241
+ return unknown_model_message(parsed.id, host, fresh_ids)
242
+ end
213
243
 
244
+ # No answer (failed, timed out, listed nothing): the saved list stands.
214
245
  unknown_model_message(parsed.id, host, list.ids)
215
246
  end
216
247
 
248
+ # How long a miss's re-list may take before it is treated as failed: a
249
+ # spawn (`chi send --new --model`, delegate) warns from the saved list
250
+ # then, rather than wait on a slow host.
251
+ RE_LIST_TIMEOUT_SECONDS = 5
252
+
253
+ # A saved list younger than this is not re-listed on a miss (the other
254
+ # way it can be wrong: stale by CONTENT within its TTL, a one-model
255
+ # server reloaded with another model). A host that serves ids it never
256
+ # lists (a gateway's round-robin aliases) would otherwise pay the
257
+ # re-list's cap on every launch.
258
+ RELIST_AFTER_SECONDS = 10 * 60
259
+
260
+ # The default re-list: one HostRegistry, one host listed (and saved).
261
+ # Bounded like `chi models`' `wait:`: a thread still listing at the cap
262
+ # is left running and its answer ignored (only a short-lived spawn is
263
+ # ever here).
264
+ # @return [#call] (host_name, env) -> ids or nil
265
+ def self.default_relist(hosts)
266
+ lambda do |host_name, relist_env|
267
+ require_relative "host_registry"
268
+ registry = Samagotchi::HostRegistry.new(hosts_config: hosts, env: relist_env)
269
+ answer = nil
270
+ thread = Thread.new { answer = registry.list_models(host_name) }
271
+ thread.join(RE_LIST_TIMEOUT_SECONDS)
272
+ answer
273
+ end
274
+ end
275
+ private_class_method :default_relist
276
+
277
+ # The re-list's ids, or nil when it fails, times out or lists nothing.
278
+ # A failing re-list is no evidence either way: the caller warns from the
279
+ # saved list.
280
+ def self.relist_or_nil(relist, host, env)
281
+ ids = relist.call(host, env)
282
+ ids.respond_to?(:any?) && ids.any? ? ids : nil
283
+ rescue StandardError
284
+ nil
285
+ end
286
+ private_class_method :relist_or_nil
287
+
217
288
  # The warning for an id the host doesn't list, with up to three close
218
289
  # ids (Config.near_names) when there are any.
219
290
  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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+
3
5
  module Samagotchi
4
6
  # A command run in a process group of its own, so a stop reaches what it
5
7
  # started too: TERM the group, a grace, then KILL it. Execute, TaskRuntime
@@ -51,12 +53,44 @@ module Samagotchi
51
53
 
52
54
  # Whether +pid+ runs and leads its own group, as #spawn's process does
53
55
  # (not, then, a reused pid in some other group).
54
- def leader?(pid)
55
- signalable?(pid) && Process.getpgid(pid) == pid
56
+ # @param started [String, nil] the start time #start_time read when chi
57
+ # spawned it: a process that started at another time is one that
58
+ # reused the pid, even as a group leader. nil checks the group only (a
59
+ # record from an older chi), and so does a time that can't be read
60
+ # now (a ps that failed once must not disown a live task).
61
+ # @param start_time [#call] (pid) the reader, #start_time by default
62
+ def leader?(pid, started: nil, start_time: method(:start_time))
63
+ return false unless signalable?(pid) && Process.getpgid(pid) == pid
64
+ return true if started.nil?
65
+
66
+ now = start_time.call(pid)
67
+ now.nil? || now == started
56
68
  rescue Errno::ESRCH, Errno::EPERM
57
69
  false
58
70
  end
59
71
 
72
+ # When +pid+'s process started, as an opaque string to compare: the
73
+ # start in clock ticks from /proc/PID/stat on Linux, ps's lstart (to the
74
+ # second, in UTC) elsewhere. nil when it isn't running or can't be read.
75
+ # @return [String, nil]
76
+ def start_time(pid)
77
+ return nil unless signalable?(pid)
78
+
79
+ stat = "/proc/#{pid}/stat"
80
+ return proc_start_time(File.read(stat)) if File.exist?(stat)
81
+
82
+ out, status = Open3.capture2({ "LC_ALL" => "C", "TZ" => "UTC" }, "ps", "-o", "lstart=", "-p", pid.to_s,
83
+ err: File::NULL)
84
+ time = out.strip
85
+ status.success? && !time.empty? ? time : nil
86
+ rescue SystemCallError, IOError
87
+ nil
88
+ end
89
+
90
+ # Field 22 (starttime); the command name (field 2) may hold spaces and
91
+ # parentheses, so the fields are counted after its last ")".
92
+ def proc_start_time(stat) = stat[/\A.*\)\s(.*)\z/m, 1]&.split&.[](19)
93
+
60
94
  # Whether +pid+ is still running (or a zombie not reaped yet).
61
95
  # @raise [ArgumentError] unless #signalable?
62
96
  def alive?(pid)
@@ -69,26 +103,39 @@ module Samagotchi
69
103
  true
70
104
  end
71
105
 
72
- # TERMs the group, waits up to +grace+ for +stopped+, then KILLs it.
106
+ # How long #stop waits for +stopped+ after the KILL by default.
107
+ KILL_WAIT_SEC = 1.0
108
+
109
+ # TERMs the group, waits up to +grace+ for +stopped+, then KILLs it and
110
+ # waits up to +kill_wait+ for +stopped+ again (a KILL is delivered, not
111
+ # done, when kill returns).
73
112
  # @param stopped [#call] true once it has stopped (default: the leader
74
113
  # is gone)
75
114
  # @param wait [#call] (seconds) between two checks (default: a sleep)
76
115
  # @param signal_options [Hash] for #signal (leader:, quiet:)
77
116
  # @return [Boolean] true when it stopped within the grace (no KILL sent)
78
117
  # @raise [ArgumentError] unless #signalable?
79
- def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) }, **signal_options)
118
+ def stop(pgid, grace:, poll:, stopped: -> { !alive?(pgid) }, wait: ->(seconds) { sleep(seconds) },
119
+ kill_wait: KILL_WAIT_SEC, **signal_options)
80
120
  check!(pgid)
81
121
  signal(pgid, "TERM", **signal_options)
82
- deadline = monotonic + grace
83
- until (done = stopped.call) || monotonic >= deadline
84
- wait.call(poll)
85
- end
86
- return true if done
122
+ return true if wait_for(stopped, grace, poll, wait)
87
123
 
88
124
  signal(pgid, "KILL", **signal_options)
125
+ wait_for(stopped, kill_wait, poll, wait)
89
126
  false
90
127
  end
91
128
 
129
+ # Checks +stopped+ every +poll+ until it is true or +seconds+ are over.
130
+ # @return [Boolean] whether it stopped
131
+ def wait_for(stopped, seconds, poll, wait)
132
+ deadline = monotonic + seconds
133
+ until (done = stopped.call) || monotonic >= deadline
134
+ wait.call(poll)
135
+ end
136
+ done ? true : false
137
+ end
138
+
92
139
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
140
 
94
141
  # Reads a pipe in chunks on a thread of its own, so the output read so
@@ -49,7 +49,7 @@ module Samagotchi
49
49
  elsif !IdleTarget.host_named?(**settings)
50
50
  # A model naming no host goes where a bare --model goes
51
51
  # (HostRegistry#host_for_model: the default host unless
52
- # another's list has it), resolved at each attempt.
52
+ # another lists or declares it), resolved at each attempt.
53
53
  -> { IdleTarget.resolve(**settings) }
54
54
  else
55
55
  fixed = fixed_target(settings)
@@ -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)
@@ -265,7 +294,8 @@ module Samagotchi
265
294
  served = ServedModel.from_props(props)
266
295
  return "unknown (no answer from the server's /props)" unless served
267
296
 
268
- ServedModel.differs?(bare, served) ? "#{served} (not #{bare}: the server serves its own model)" : served
297
+ expected = entry.models&.dig(bare.to_s.downcase)&.serves?(served)
298
+ ServedModel.differs?(bare, served) && !expected ? "#{served} (not #{bare}: the server serves its own model)" : served
269
299
  rescue StandardError => e
270
300
  "unknown (#{e.class})"
271
301
  end
@@ -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("; ")})"
@@ -475,10 +492,11 @@ module Samagotchi
475
492
  end
476
493
  end
477
494
 
478
- # "; served: <name>" when the server serves another model than asked.
495
+ # "; served: <name>" when the server serves another model than asked
496
+ # and its host's served: doesn't expect it.
479
497
  def served_note
480
498
  served, asked = @engine.served_model
481
- ServedModel.differs?(asked, served) ? "; served: #{served}" : ""
499
+ ServedModel.differs?(asked, served) && !@engine.served_expected?(served) ? "; served: #{served}" : ""
482
500
  rescue StandardError
483
501
  ""
484
502
  end
@@ -550,6 +568,7 @@ module Samagotchi
550
568
 
551
569
  seen = Set.new
552
570
  lines = []
571
+ host_rows = HostModel.rows(results, registry.entries)
553
572
  # Sort hosts for deterministic output
554
573
  results.keys.sort.each do |hname|
555
574
  data = results[hname]
@@ -558,29 +577,35 @@ module Samagotchi
558
577
  lines << "#{host_label} — unreachable: #{data[:error]}"
559
578
  next
560
579
  end
561
- models = Array(data[:models])
562
- if models.empty?
580
+ # Declared ids (hosts.<name>.models) first, as "(config)", never cut
581
+ # by MODELS_PER_HOST.
582
+ rows = host_rows[hname]
583
+ if rows.empty?
563
584
  lines << "#{host_label} — no models discovered"
564
585
  next
565
586
  end
587
+ declared = []
566
588
  shown = []
567
- batch_variants = batch_variant_ids(models, needle)
568
- models.each do |entry|
569
- identifier = entry.id.to_s.empty? ? "unknown" : entry.id
589
+ batch_variants = batch_variant_ids(rows, needle)
590
+ rows.each do |row|
591
+ identifier = row.id.to_s.empty? ? "unknown" : row.id
570
592
  seen << identifier.to_s.downcase
571
593
  # also track host-qualified seen for orphan logic
572
594
  seen << "#{hname}:#{identifier}".downcase
573
- next if batch_variants.include?(identifier)
595
+ next if !row.configured && batch_variants.include?(identifier)
596
+ next unless needle.empty? || identifier.to_s.downcase.include?(needle)
574
597
 
575
- shown << [entry, identifier] if needle.empty? || identifier.to_s.downcase.include?(needle)
598
+ (row.configured ? declared : shown) << [row, identifier]
576
599
  end
577
- next if shown.empty?
600
+ next if declared.empty? && shown.empty?
578
601
 
579
602
  lines << "#{host_label}:"
580
603
  hidden = needle.empty? ? [shown.size - MODELS_PER_HOST, 0].max : 0
581
- shown.first(shown.size - hidden).each do |entry, identifier|
582
- raw_status = entry.raw["status"] || entry.raw[:status]
604
+ (declared + shown.first(shown.size - hidden)).each do |row, identifier|
605
+ raw = row.info&.raw || {}
606
+ raw_status = raw["status"] || raw[:status]
583
607
  status = raw_status.is_a?(Hash) ? (raw_status["value"] || raw_status[:value] || raw_status["status"] || raw_status[:status]) : raw_status
608
+ status = ["config", status].reject { |s| s.to_s.empty? }.join(", ") if row.configured
584
609
  base = status.to_s.empty? ? " #{identifier}" : " #{identifier} (#{status})"
585
610
  alias_list = (by_model[identifier.to_s.downcase] || []) + (by_model["#{hname}:#{identifier}".downcase] || [])
586
611
  alias_list.uniq!
@@ -614,9 +639,10 @@ module Samagotchi
614
639
  def batch_variant_ids(models, needle)
615
640
  return Set.new if needle.include?("batch")
616
641
 
642
+ # Any case: a declared id keeps its own spelling (HostModel.rows).
617
643
  ids = models.map { |entry| entry.id.to_s }
618
- plain = ids.to_set
619
- ids.select { |id| id.end_with?(":batch") && plain.include?(id.delete_suffix(":batch")) }.to_set
644
+ plain = ids.to_set(&:downcase)
645
+ ids.select { |id| id.downcase.end_with?(":batch") && plain.include?(id.downcase.delete_suffix(":batch")) }.to_set
620
646
  end
621
647
  end
622
648
  end
@@ -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.