samagotchi 0.70.0 → 0.72.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/docs/architecture.md +16 -10
  4. data/docs/cli.md +7 -4
  5. data/docs/configuration.md +24 -14
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/anthropic.md +1 -1
  8. data/docs/internals/background-tasks.md +2 -2
  9. data/docs/internals/engine.md +38 -13
  10. data/docs/internals/llm-context-live.md +1 -1
  11. data/docs/internals/prompt-caching.md +6 -0
  12. data/docs/plugins.md +18 -7
  13. data/docs/sessions.md +4 -2
  14. data/docs/testing.md +12 -3
  15. data/lib/samagotchi/bootstrap/config_writer.rb +2 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  17. data/lib/samagotchi/bridge.rb +5 -2
  18. data/lib/samagotchi/broadcast/scope_card.rb +2 -2
  19. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -7
  20. data/lib/samagotchi/bundles/claude-peer/plugin.rb +2 -2
  21. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -6
  22. data/lib/samagotchi/bundles/codex-peer/plugin.rb +2 -2
  23. data/lib/samagotchi/bundles/coordinator/manifest.yml +8 -6
  24. data/lib/samagotchi/bundles/coordinator/plugin.rb +10 -0
  25. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +2 -1
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/config.rb +7 -25
  29. data/lib/samagotchi/context_note.rb +2 -6
  30. data/lib/samagotchi/effective_model.rb +1 -1
  31. data/lib/samagotchi/engine.rb +91 -213
  32. data/lib/samagotchi/engine_peers.rb +2 -0
  33. data/lib/samagotchi/event_routes.rb +83 -0
  34. data/lib/samagotchi/hook_routes.rb +16 -37
  35. data/lib/samagotchi/host_config.rb +1 -1
  36. data/lib/samagotchi/host_keys.rb +0 -2
  37. data/lib/samagotchi/paths.rb +13 -0
  38. data/lib/samagotchi/plugin/api.rb +11 -5
  39. data/lib/samagotchi/plugin/peer.rb +5 -8
  40. data/lib/samagotchi/prompt_files.rb +2 -4
  41. data/lib/samagotchi/session.rb +2 -1
  42. data/lib/samagotchi/session_manager.rb +6 -16
  43. data/lib/samagotchi/session_model_settings.rb +227 -0
  44. data/lib/samagotchi/session_setup.rb +2 -1
  45. data/lib/samagotchi/sessions_command.rb +12 -3
  46. data/lib/samagotchi/system_prompt.rb +18 -13
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +36 -15
  48. data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
  49. data/lib/samagotchi/tool_activity.rb +20 -1
  50. data/lib/samagotchi/tool_declarations.rb +4 -0
  51. data/lib/samagotchi/tool_output_cut.rb +11 -2
  52. data/lib/samagotchi/toolkit.rb +60 -22
  53. data/lib/samagotchi/toolkit_binding.rb +7 -5
  54. data/lib/samagotchi/toolkit_command.rb +28 -5
  55. data/lib/samagotchi/tools/builtins.rb +2 -1
  56. data/lib/samagotchi/tools/delegate.rb +32 -6
  57. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  58. data/lib/samagotchi/tools/peers.rb +5 -2
  59. data/lib/samagotchi/tools/register_reminder.rb +1 -0
  60. data/lib/samagotchi/tools/registry.rb +13 -2
  61. data/lib/samagotchi/tools/task_wait.rb +4 -6
  62. data/lib/samagotchi/tools/tool_index.rb +15 -4
  63. data/lib/samagotchi/turn_note.rb +4 -1
  64. data/lib/samagotchi/version.rb +1 -1
  65. data/lib/samagotchi/web/app.rb +18 -17
  66. data/lib/samagotchi/web/message_parts.rb +1 -1
  67. data/lib/samagotchi/web/public/answer_refs.js +1 -0
  68. data/lib/samagotchi/web/public/app.js +20 -18
  69. data/lib/samagotchi/web/public/bubbles.js +1 -1
  70. data/lib/samagotchi/web/public/cards_place.js +6 -10
  71. data/lib/samagotchi/web/public/chains.js +1 -1
  72. data/lib/samagotchi/web/public/chip_popover.js +3 -1
  73. data/lib/samagotchi/web/public/composer.js +13 -6
  74. data/lib/samagotchi/web/public/context_chips.js +25 -12
  75. data/lib/samagotchi/web/public/data.js +5 -1
  76. data/lib/samagotchi/web/public/format.js +5 -0
  77. data/lib/samagotchi/web/public/index.html +1 -0
  78. data/lib/samagotchi/web/public/sessions_list.js +2 -3
  79. data/lib/samagotchi/web/public/stage_frame.js +1 -1
  80. data/lib/samagotchi/web/public/stage_view.js +8 -2
  81. data/lib/samagotchi/web/public/stream_handlers.js +6 -3
  82. data/lib/samagotchi/web/public/timing.js +1 -1
  83. data/lib/samagotchi/web/public/toolkit_chip.js +3 -1
  84. data/lib/samagotchi/web/public/turn_html.js +55 -3
  85. data/lib/samagotchi/web/session_hub.rb +8 -0
  86. metadata +3 -1
@@ -33,7 +33,7 @@ module Samagotchi
33
33
  super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
34
34
  end
35
35
 
36
- # A hosts: entry as written (YAML, or a SAMAGOTCHI_HOSTS_JSON override),
36
+ # A hosts: entry as written in config.yml,
37
37
  # or nil when it is disabled (enabled: false) or invalid. An invalid
38
38
  # one warns once, naming the entry, the way each reader of a setting
39
39
  # does for a bad value (that value is then unset).
@@ -131,8 +131,6 @@ module Samagotchi
131
131
  # The command that stores the key for +name+ (prompting for it).
132
132
  def store_command(name) = "security add-generic-password -s #{SERVICE} -a #{name} -w"
133
133
 
134
- def clear_cache! = @mutex.synchronize { @cache.clear }
135
-
136
134
  # Names only: the cache holds keys.
137
135
  def inspect = "#<#{name} cached=#{@mutex.synchronize { @cache.keys }.inspect}>"
138
136
 
@@ -28,5 +28,18 @@ module Samagotchi
28
28
  xdg = env.fetch("XDG_CONFIG_HOME", "").to_s.strip
29
29
  xdg.empty? ? File.expand_path("~/.config") : xdg
30
30
  end
31
+
32
+ # +path+ as chi shows it: ~ for the home folder (~/a for one in it),
33
+ # else as it is; as it is too when there's no home (HOME unset).
34
+ # @return [String]
35
+ def tilde(path)
36
+ path = path.to_s
37
+ home = Dir.home
38
+ return "~" if path == home
39
+
40
+ path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
41
+ rescue ArgumentError
42
+ path
43
+ end
31
44
  end
32
45
  end
@@ -279,13 +279,19 @@ module Samagotchi
279
279
 
280
280
  # A named toolkit sessions can be put on (/toolkit NAME, chi --toolkit,
281
281
  # toolkit.default, ctx.use_toolkit): +base+'s tools (a toolkit chi or
282
- # a bundle has) with +add+ declared and +remove+ left out. A name chi
283
- # (full, lean, child) or another bundle has is a load error; a base
284
- # that doesn't resolve leaves the sessions on it with every tool
282
+ # a bundle has) with +add+ declared and +remove+ left out, and
283
+ # +prompt+: text the sessions on it get in their system prompt (nil:
284
+ # its base's, "" none; at most Toolkit::PROMPT_MAX characters). A
285
+ # name chi (full, lean, child) or another bundle has is a load error,
286
+ # and so is a prompt that isn't text or is too long; a base that
287
+ # doesn't resolve leaves the sessions on it with every tool
285
288
  # (Toolkit#resolve).
286
- def toolkit(name, base:, add: [], remove: [])
289
+ def toolkit(name, base:, add: [], remove: [], prompt: nil)
287
290
  name = name.to_s
288
291
  raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
292
+
293
+ problem = Toolkit.prompt_error(prompt)
294
+ raise ArgumentError, "toolkit #{name}: #{problem}" if problem
289
295
  raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
290
296
  if Toolkit::BUILTIN_KITS.key?(name)
291
297
  raise ArgumentError, "toolkit #{name} is chi's own"
@@ -294,7 +300,7 @@ module Samagotchi
294
300
  end
295
301
  raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
296
302
 
297
- @toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, source: @bundle)
303
+ @toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt, source: @bundle)
298
304
  nil
299
305
  end
300
306
 
@@ -5,6 +5,7 @@ require "json"
5
5
  require "open3"
6
6
  require "tempfile"
7
7
  require "time"
8
+ require_relative "../paths"
8
9
 
9
10
  module Samagotchi
10
11
  module Plugin
@@ -88,11 +89,10 @@ module Samagotchi
88
89
  "it; install-skill: install #{peer::NAME}'s chi-peer skill", anytime: true do |args, ctx|
89
90
  run_command(args.to_s.strip, ctx)
90
91
  end
91
- # A chi without chi.annotation (before 0.66) loads the bundle without the pill.
92
- chi.annotation "Confirm with #{peer::NAME}", command: "#{peer::COMMAND} validate" if chi.respond_to?(:annotation)
92
+ chi.annotation "Confirm with #{peer::NAME}", command: "#{peer::COMMAND} validate"
93
93
  # The outbox source is the peer's answers: its own wake budget and
94
- # line (context.peer_turns). A chi before 0.67 has no peer_source.
95
- chi.peer_source peer::OUTBOX, label: peer::NAME if chi.respond_to?(:peer_source)
94
+ # line (context.peer_turns).
95
+ chi.peer_source peer::OUTBOX, label: peer::NAME
96
96
  chi.on(:before_turn) do |event|
97
97
  @lock.synchronize { @before_turn = Array(event[:messages]) }
98
98
  end
@@ -362,10 +362,7 @@ module Samagotchi
362
362
  "(diff isn't there to show how)"
363
363
  end
364
364
 
365
- def tilde(path)
366
- home = Dir.home
367
- path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
368
- end
365
+ def tilde(path) = Paths.tilde(path)
369
366
 
370
367
  def follow_up(kind)
371
368
  case kind
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "memory_paths"
4
+ require_relative "paths"
4
5
 
5
6
  module Samagotchi
6
7
  # The files whose text goes into the system prompt as they are
@@ -76,10 +77,7 @@ module Samagotchi
76
77
  rel = top && relative_dir(path, top)
77
78
  return rel if rel && rel != "."
78
79
 
79
- home = Dir.home
80
- path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
81
- rescue ArgumentError
82
- path
80
+ Paths.tilde(path)
83
81
  end
84
82
 
85
83
  # A chi --append-system-prompt value: [absolute path, nil], or
@@ -189,7 +189,8 @@ module Samagotchi
189
189
  # which is on Toolkit::FULL (Toolkit.of). Saved and never resolved
190
190
  # again from config, so a respawned worker declares the same tools; a
191
191
  # continue and a fork copy it (SessionSetup.of), a delegate child gets
192
- # toolkit.child (SessionManager.spawn_session).
192
+ # the one its delegate call named, else toolkit.child
193
+ # (SessionManager.spawn_session).
193
194
  attr_reader :toolkit
194
195
 
195
196
  # @param value [Toolkit, Hash, String, nil] the session file's form too
@@ -140,9 +140,10 @@ module Samagotchi
140
140
  # @param title [String, nil] what the lists show before the first turn
141
141
  # (the prompt's preview by default, else the seed's first user message)
142
142
  # @param setup [SessionSetup] the session's own settings (chi
143
- # --llm-context; a fork and a continue copy their session's); without
144
- # a toolkit the session is stored on config's toolkit.default, a
145
- # delegate child always on toolkit.child (Toolkit.configured)
143
+ # --llm-context; a fork and a continue copy their session's; a delegate
144
+ # child the toolkit its delegate call named); without a toolkit the
145
+ # session is stored on config's toolkit.default, a delegate child on
146
+ # toolkit.child (Toolkit.configured)
146
147
  # @param continues [String, nil] the session this one continues (a
147
148
  # session field; SessionContinue)
148
149
  # @param note [String, nil] a context note the session starts with,
@@ -179,7 +180,7 @@ module Samagotchi
179
180
  setup: setup,
180
181
  continues: continues
181
182
  )
182
- session.toolkit = Toolkit.configured(delegate: delegate) if delegate || session.toolkit.nil?
183
+ session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
183
184
  session.model_warning = model_warning
184
185
  # With no prompt there is no first turn to run (an attaching UI sends
185
186
  # the prompts), so the session starts idle.
@@ -209,8 +210,7 @@ module Samagotchi
209
210
  # is when the worker starts); opts[:env] adds to it (merged, not
210
211
  # replaced) what it can't read there: this `chi`'s CLI settings
211
212
  # (Config.cli_env) and the absolute log file. It unsets
212
- # SAMAGOTCHI_GUARDRAILS_*, the session markers (SESSION_MARKER_ENV) and
213
- # SAMAGOTCHI_HOSTS_JSON (HOSTS_JSON_ENV).
213
+ # SAMAGOTCHI_GUARDRAILS_* and the session markers (SESSION_MARKER_ENV).
214
214
  # XDG_CONFIG_HOME passes: whoever starts or
215
215
  # wakes the worker picks which config.yml it reads (docs/guardrails.md).
216
216
  private_class_method def self.spawn_options(session)
@@ -227,12 +227,6 @@ module Samagotchi
227
227
  Log.warn(:worker, "cwd_gone", sid: session.id, dir: dir, cwd: Dir.pwd)
228
228
  end
229
229
  child_env = {}
230
- # The worker reads config.yml's hosts: as it starts, like every other
231
- # setting: a host added or changed after this process (a parent
232
- # worker, `chi web`) started reaches it. Unset, not just left out: a
233
- # worker an older chi started still has one, and its delegates would
234
- # inherit its start-time hosts.
235
- child_env[HOSTS_JSON_ENV] = nil
236
230
  # A worker gets no CLI args: --model, --thinking, --log-level and the
237
231
  # other flags this chi was started with.
238
232
  child_env.merge!(Config.cli_env)
@@ -716,10 +710,6 @@ module Samagotchi
716
710
  wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
717
711
  end
718
712
 
719
- # ConfigFile.hosts_config's override of config.yml's hosts:, which no
720
- # spawned worker inherits (spawn_options).
721
- HOSTS_JSON_ENV = "SAMAGOTCHI_HOSTS_JSON"
722
-
723
713
  STOP_CANCEL_WAIT = 3.0
724
714
  # The cancel reason of a turn a session stop cancels (Bridge::CANCEL_REASONS):
725
715
  # not the user's Stop, which only ends the turn ("user").
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "context_status"
4
+ require_relative "effective_model"
5
+ require_relative "sampling_settings"
6
+ require_relative "session_setting"
7
+ require_relative "steer_cut"
8
+ require_relative "steer_cut_after"
9
+ require_relative "thinking"
10
+
11
+ module Samagotchi
12
+ # The session's settings on its effective model, as the Engine reads them:
13
+ # the session's own thinking, reasoning_history and llm_context values
14
+ # (SessionSetting) over the effective model's (EffectiveModel), the
15
+ # summaries /model and /stats show, and what the next turn resolves.
16
+ # Every read resolves the model again (EffectiveModel.of, never kept); a
17
+ # reader with a fallback gives it when the model doesn't resolve
18
+ # (#model_setting). The Engine forwards its public readers here.
19
+ class SessionModelSettings
20
+ # @param registry [HostRegistry] the Engine's (never replaced)
21
+ # @param choice [#call] → the ModelChoice turns run on now (/model moves it)
22
+ # @param session [#call] → the Engine's Session, nil before one
23
+ def initialize(registry:, choice:, session:)
24
+ @registry = registry
25
+ @choice = choice
26
+ @session = session
27
+ end
28
+
29
+ # The effective model now (EffectiveModel.of): resolved on each call,
30
+ # never kept (config.yml, a model listing and another process's saved
31
+ # lists all move a resolve). Raises as HostRegistry#resolve does.
32
+ # @return [EffectiveModel]
33
+ def effective_model(choice = @choice.call) = EffectiveModel.of(@registry, choice)
34
+
35
+ # The block's answer for the effective model, or +fallback+ when the
36
+ # model doesn't resolve: the readers whose rescue always covered the
37
+ # resolve too. Each setting's own failure is the EffectiveModel's.
38
+ def model_setting(fallback)
39
+ # One read: the log line names the model that failed, even if a
40
+ # /model switched it meanwhile.
41
+ choice = @choice.call
42
+ model = begin
43
+ effective_model(choice)
44
+ rescue StandardError => e
45
+ return EffectiveModel::Unresolved.fallback(:effective_model_unresolved, choice.name, e, fallback)
46
+ end
47
+ yield model
48
+ end
49
+
50
+ # The status line's ctx from the saved context, for a worker that has run
51
+ # no turn yet (it woke after an idle exit): nil when either count is
52
+ # unknown or context.status is off. Counted as the turns count it:
53
+ # against the effective model's llm_context budget when it has one.
54
+ def saved_context_status(context, model: :current)
55
+ return nil unless context
56
+
57
+ llm_context = model == :current ? turn_llm_context : model&.llm_context_explained(llm_context_session_args)&.resolved
58
+ ContextStatus.new(llm_context: llm_context)
59
+ .display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
60
+ end
61
+
62
+ # #turn_llm_context with where each value came from
63
+ # (LLMContextStrategy.explain), nil when it can't be resolved.
64
+ # @return [LLMContextStrategy::Explained, nil]
65
+ def llm_context_explained
66
+ model_setting(nil) { |model| model.llm_context_explained(llm_context_session_args) }
67
+ end
68
+
69
+ # #llm_context_explained's summary (Explained#summary), nil when it
70
+ # can't be resolved: /stats, the snapshot, the web's info bar.
71
+ # @return [Hash, nil]
72
+ def llm_context_summary
73
+ llm_context_explained&.summary(llm_context_override)
74
+ end
75
+
76
+ # The session's own llm_context values, nil when it has none.
77
+ # @return [LLMContextOverride, nil]
78
+ def llm_context_override = SessionSetting::LLM_CONTEXT.own(@session.call)
79
+
80
+ # The effective model is on a chat host (api: openai or anthropic), whose loop uses
81
+ # no prompt profile.
82
+ def chat_model? = effective_model.chat?
83
+
84
+ # "temperature=0.6 (hosts.work)" for /model: the effective model's
85
+ # configured sampling, nil when none.
86
+ def sampling_summary = model_setting(nil, &:sampling_summary)
87
+
88
+ # "off (models: qwen)", "low (session)" for /model: the effective
89
+ # model's thinking level and where it came from, nil when none is set.
90
+ def thinking_summary
91
+ explained = thinking_explained
92
+ explained&.source ? explained.label : nil
93
+ end
94
+
95
+ # The seconds a cut waits for the effective model to stream only
96
+ # thinking (SteerCutAfter.resolve), resolved on every call, as
97
+ # turn_sampling; on any error, the global and its name.
98
+ # @return [Array(Integer, String|nil)]
99
+ def steer_cut_after_explained
100
+ model_setting([SteerCut.cut_after, "steer.cut_after"], &:steer_cut_after_explained)
101
+ end
102
+
103
+ # #steer_cut_after_explained's seconds: what SteerCut#cut_for_steer
104
+ # reads, per host and model.
105
+ def steer_cut_after = steer_cut_after_explained.first
106
+
107
+ # "after 60 s (models: qwen)", "never (hosts.splash)" for /model: the
108
+ # effective model's cut wait and where it came from, nil for the
109
+ # default.
110
+ def steer_cut_summary
111
+ seconds, source = steer_cut_after_explained
112
+ source ? SteerCutAfter.label(seconds, source) : nil
113
+ end
114
+
115
+ # The effective model's thinking level with where it came from and the
116
+ # session's own (SettingExplained), as the next turn resolves it;
117
+ # nil when it can't be resolved.
118
+ # @return [SettingExplained, nil]
119
+ # +own+: the session's own level to resolve with (the current one by
120
+ # default; /thinking asks what a change would resolve to).
121
+ def thinking_explained(own: thinking_override)
122
+ model_setting(nil) { |model| model.thinking_explained(own: own) }
123
+ end
124
+
125
+ # "drop (models: qwen)" for /model: the effective chat model's
126
+ # reasoning_history level and where it came from; nil when it is the
127
+ # default, the model runs on the native loop (which ignores it) or it
128
+ # can't be resolved.
129
+ def reasoning_history_summary
130
+ model_setting(nil) { |model| model.reasoning_history_summary(own: reasoning_history_override) }
131
+ end
132
+
133
+ # The effective chat model's reasoning_history level with where it came
134
+ # from and the session's own (SettingExplained), as the next turn
135
+ # resolves it; nil on a native host (the native loop ignores it; native:
136
+ # true resolves it there too, for /reasoning-history) or when it can't
137
+ # be resolved.
138
+ # @return [SettingExplained, nil]
139
+ # +own+: the session's own level to resolve with (the current one by
140
+ # default; a command asks what a change would resolve to).
141
+ def reasoning_history_explained(native: false, own: reasoning_history_override)
142
+ model_setting(nil) { |model| model.reasoning_history_explained(own: own, native: native) }
143
+ end
144
+
145
+ # What a reasoning_history change from level +from+ to level +to+ costs
146
+ # the effective model's prompt cache, given the session's conversation
147
+ # (ReasoningHistory.cache_cost): :history or nil. A host ChatLoop
148
+ # marked as refusing it may still get :history (one needless ask).
149
+ # @return [Symbol, nil]
150
+ def reasoning_history_cache_cost(from, to)
151
+ model_setting(nil) { |model| model.reasoning_history_cache_cost(from, to, conversation: @session.call&.messages) }
152
+ end
153
+
154
+ # The session's own reasoning_history level, nil when it has none.
155
+ # @return [Symbol, nil]
156
+ def reasoning_history_override = SessionSetting::REASONING_HISTORY.own(@session.call)
157
+
158
+ # Set the session's own reasoning_history level (nil unsets it); the
159
+ # next turn's start resolves it. The caller saves the session.
160
+ # @raise [ArgumentError] with no session yet
161
+ def reasoning_history_override=(level)
162
+ session = @session.call
163
+ raise ArgumentError, "no session yet" unless session
164
+
165
+ SessionSetting::REASONING_HISTORY.set(session, level)
166
+ end
167
+
168
+ # The session's own thinking level, nil when it has none.
169
+ # @return [Symbol, nil]
170
+ def thinking_override = SessionSetting::THINKING.own(@session.call)
171
+
172
+ # Set the session's own thinking level (nil unsets it); the next turn's
173
+ # start resolves it, never a running one (its TurnSettings are fixed).
174
+ # The caller saves the session.
175
+ # @raise [ArgumentError] with no session yet
176
+ def thinking_override=(level)
177
+ session = @session.call
178
+ raise ArgumentError, "no session yet" unless session
179
+
180
+ SessionSetting::THINKING.set(session, level)
181
+ end
182
+
183
+ # The host whose hosts.<name>.models.<id>.served names +served+ for the
184
+ # current model (a gateway's round-robin targets): not a mismatch to
185
+ # warn about. nil when none does.
186
+ # @return [String, nil]
187
+ def served_expected_by(served)
188
+ model_setting(nil) { |model| model.served_expected_by(served) }
189
+ end
190
+
191
+ def served_expected?(served) = !served_expected_by(served).nil?
192
+
193
+ # The effective model's request parameters (hosts: and models:
194
+ # sampling), read per turn: /model can change the model between turns.
195
+ def turn_sampling = model_setting(SamplingSettings::EMPTY, &:sampling)
196
+
197
+ # The effective model's thinking level (Thinking.resolve), read each turn.
198
+ def turn_thinking = model_setting(Thinking::DEFAULT) { |model| thinking_level(model) }
199
+
200
+ # +model+'s ReasoningHistory level, the session's own first; the
201
+ # default when it can't be resolved.
202
+ def reasoning_history_level(model) = model.reasoning_history(own: reasoning_history_override)
203
+
204
+ # +model+'s thinking level (Thinking.resolve), the session's own first.
205
+ def thinking_level(model) = model.thinking(own: thinking_override)
206
+
207
+ # The effective model's models:/hosts: window_tokens, read per turn
208
+ # (after the hooks that may switch the model).
209
+ def turn_window_setting = model_setting(nil, &:window_setting)
210
+
211
+ # The effective model's price on its host (hosts.<name>.models.<id>.price),
212
+ # by the requested id, never the served one; read per turn as the window is.
213
+ def turn_price = model_setting(nil, &:price)
214
+
215
+ # The LLM context strategy the next turn runs under: the session's own
216
+ # (Session#llm_context), else the effective model's (models:/hosts:
217
+ # llm_context_strategy), else llm_context.strategy; read per turn as
218
+ # the window is.
219
+ def turn_llm_context
220
+ llm_context_explained&.resolved
221
+ end
222
+
223
+ # The session's own llm_context values as LLMContextStrategy's
224
+ # arguments ({} without a session or values).
225
+ def llm_context_session_args = @session.call&.llm_context&.resolve_args || {}
226
+ end
227
+ end
@@ -19,7 +19,8 @@ module Samagotchi
19
19
  #
20
20
  # It doesn't save itself: the session keeps each setting in its own field
21
21
  # and file key, and resolves it on its own. A delegate child starts
22
- # without one; a continue and a fork copy their session's (.of).
22
+ # without one (but the toolkit its delegate call named); a continue and a
23
+ # fork copy their session's (.of).
23
24
  SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit) do
24
25
  def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil)
25
26
  super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
@@ -37,7 +37,8 @@ module Samagotchi
37
37
  archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back
38
38
  delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
39
39
  prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
40
- clean [--dry-run] [--days N] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
40
+ clean [--dry-run] [--days N] [--all] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
41
+ --all: every session, not only test and scratch ones (by the age and count limits, as prune)
41
42
  stats ID [--format text|json] # a session's cost, tokens and progress, without running a model turn:
42
43
  a live worker's GET stats, else the snapshot its saved analytics.json rebuilds (never starts a worker)
43
44
  Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)
@@ -107,6 +108,8 @@ module Samagotchi
107
108
  return parsed if parsed.is_a?(Integer)
108
109
 
109
110
  @opts = integers(parsed.options)
111
+ return usage_error(@opts) if @opts.is_a?(String)
112
+
110
113
  case @sub
111
114
  when "list" then list
112
115
  when "stop" then stop(parsed.args)
@@ -121,9 +124,15 @@ module Samagotchi
121
124
  def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi sessions #{@sub}" : "chi sessions"
122
125
  def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE, "stats" => STATS_USAGE }.fetch(@sub, USAGE)
123
126
 
124
- # quirk: a number that isn't one is 0 (--days=abc turns the age limit off)
127
+ # The options with --days, --keep and --limit as integers, or the usage
128
+ # error's message for one that isn't a whole number (--days=abc must not
129
+ # turn the age limit off).
130
+ # @return [Hash, String]
125
131
  def integers(options)
126
- options.to_h { |key, value| [key, INTEGER_OPTIONS.include?(key) ? value.to_i : value] }
132
+ bad = options.find { |key, value| INTEGER_OPTIONS.include?(key) && !value.to_s.match?(/\A-?\d+\z/) }
133
+ return "--#{bad[0]} takes a whole number, got #{bad[1].to_s.inspect}" if bad
134
+
135
+ options.to_h { |key, value| [key, INTEGER_OPTIONS.include?(key) ? Integer(value, 10) : value] }
127
136
  end
128
137
 
129
138
  # The flags a list row carries: a chi scratch session, a test run, an
@@ -4,6 +4,7 @@ require_relative "config"
4
4
  require_relative "log"
5
5
  require_relative "log_path"
6
6
  require_relative "memory_paths"
7
+ require_relative "paths"
7
8
  require_relative "tool_declarations"
8
9
  require_relative "tools/memory"
9
10
  require_relative "muted_memories"
@@ -305,10 +306,11 @@ module Samagotchi
305
306
  # Ordered for the servers' prompt caches (they reuse only an exact token
306
307
  # prefix): what every session shares first, what changes least before
307
308
  # what changes more, and the per-session lines last. The global parts
308
- # (identity, preloads) come before the project's AGENTS.md, the memory
309
- # indexes (a memory write changes them) after both; the model, the
310
- # location and the session close the prompt (Gemma's tool declarations
311
- # still follow them).
309
+ # (identity, preloads) come before the project's AGENTS.md, the
310
+ # toolkit's prompt text after the appended files, the memory indexes (a
311
+ # memory write changes them) after all of them; the model, the location
312
+ # and the session close the prompt (Gemma's tool declarations still
313
+ # follow them).
312
314
  def system_prompt_with_index(base, chat: false, thinking: nil, layers: [])
313
315
  thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
314
316
  indexes = { project: read_memory_index("project"), system: read_memory_index("system") }
@@ -318,7 +320,7 @@ module Samagotchi
318
320
  "System memories:\n#{indexes[:system]}"
319
321
  ].join("\n\n")
320
322
  stable = [thinking_token + base, not_loaded_line(layers), rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
321
- project_specific_description, appended_section, memory_sections]
323
+ project_specific_description, appended_section, kit_prompt_section, memory_sections]
322
324
  stable_text = stable.compact.join("\n")
323
325
  volatile = [current_model, project_location, current_session].compact
324
326
  return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
@@ -359,6 +361,16 @@ module Samagotchi
359
361
  "(/toolkit add <name>)."
360
362
  end
361
363
 
364
+ # The session's toolkit's prompt text (a kit's prompt:, Toolkit::Kit),
365
+ # verbatim, after the appended files and before the memory indexes:
366
+ # sessions on that kit share it, and a memory write doesn't move it.
367
+ # nil on a kit without one and on full: the prompt is what it was
368
+ # before kit prompts.
369
+ def kit_prompt_section
370
+ registry = @tools_lookup.call
371
+ registry.respond_to?(:toolkit) ? registry.toolkit&.prompt : nil
372
+ end
373
+
362
374
  def with_tail_declarations(prompt, chat:, layers:)
363
375
  chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(layers)
364
376
  end
@@ -600,19 +612,12 @@ module Samagotchi
600
612
  home = Dir.home
601
613
  lines << "Home directory: #{home} (write it as ~ or $HOME in commands and paths)" unless home.to_s.empty?
602
614
  lines << "Project memories folder (your notes about the project, not its files; never work or run commands there):"
603
- lines << home_relative(Tools::MemoryRead.memories_dir("project"))
615
+ lines << Paths.tilde(Tools::MemoryRead.memories_dir("project"))
604
616
  lines.join("\n")
605
617
  rescue StandardError
606
618
  nil
607
619
  end
608
620
 
609
- def home_relative(path)
610
- home = Dir.home
611
- path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
612
- rescue ArgumentError
613
- path
614
- end
615
-
616
621
  # The model this session runs on. A model guesses its name from
617
622
  # training (a fine-tune often knows only its base model's), so the line
618
623
  # says to answer from here. It depends only on the effective model, and
@@ -1275,7 +1275,10 @@ module Samagotchi
1275
1275
  elsif %w[model assistant].include?(m[:role].to_s)
1276
1276
  responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
1277
1277
  Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
1278
- @screen.commit(join_tool_line(part, duration_ms: join_duration(records, part)))
1278
+ record = join_record(records, part)
1279
+ duration = record&.dig("duration_ms")
1280
+ @screen.commit(join_tool_line(part, duration_ms: duration.is_a?(Numeric) ? duration : nil,
1281
+ saved_status: record&.dig("status")))
1279
1282
  end
1280
1283
  end
1281
1284
  end
@@ -1307,28 +1310,36 @@ module Samagotchi
1307
1310
  nil
1308
1311
  end
1309
1312
 
1310
- # The duration of +part+'s call: the next record of its tool, in call
1311
- # order, consumed with the ones it skipped. By tool and order, not by
1312
- # iteration: an empty retry spends an iteration that left no message.
1313
- # nil (no duration shown) for a call that left none: it never
1314
- # finished, or a worker before turn ids saved it.
1315
- def join_duration(records, part)
1313
+ # The saved record of +part+'s call (its duration and status): the
1314
+ # next record of its tool, in call order, consumed with the ones it
1315
+ # skipped. By tool and order, not by iteration: an empty retry spends
1316
+ # an iteration that left no message (as the web's timing.js
1317
+ # takeRecord). nil for a call that left none: it never finished, or a
1318
+ # worker before turn ids saved it.
1319
+ def join_record(records, part)
1316
1320
  return nil if part[:output].nil?
1317
1321
 
1318
1322
  at = records.index { |r| r["tool"].to_s == part[:tool].to_s }
1319
1323
  return nil unless at
1320
1324
 
1321
- duration = records[at]["duration_ms"]
1322
- records.shift(at + 1)
1323
- duration.is_a?(Numeric) ? duration : nil
1325
+ records.shift(at + 1).last
1324
1326
  end
1325
1327
 
1326
1328
  # A saved call's row; "no result" for a call the turn never answered
1327
- # (it ended first).
1328
- def join_tool_line(part, duration_ms: nil)
1329
+ # (it ended first). Its status is the saved record's, as live and the
1330
+ # web's reload (a guardrail's "blocked" is no error), else read from
1331
+ # its result.
1332
+ def join_tool_line(part, duration_ms: nil, saved_status: nil)
1329
1333
  output = part[:output]
1330
1334
  command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
1331
- status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool], command: command)
1335
+ status = if output.nil?
1336
+ "no result"
1337
+ elsif !saved_status.to_s.empty?
1338
+ saved_status.to_s
1339
+ else
1340
+ ToolActivity.tool_activity_status(ToolActivity.saved_result_text(output), part[:tool],
1341
+ command: command)
1342
+ end
1332
1343
  activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
1333
1344
  params: part[:params], status: status,
1334
1345
  description: ToolView.description_title(part[:view] || {}) }
@@ -1413,7 +1424,8 @@ module Samagotchi
1413
1424
  # hook's notice, a retry's line), as they were drawn live.
1414
1425
  # Questions are left out: the pending one is asked after the
1415
1426
  # replay, answered ones showed no line.
1416
- when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut, :llm_context_edited
1427
+ when :turn_started then @renderer.call(without_named_images(event, turn[:prompt]))
1428
+ when :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut, :llm_context_edited
1417
1429
  @renderer.call(event)
1418
1430
  end
1419
1431
  end
@@ -1454,7 +1466,16 @@ module Samagotchi
1454
1466
  elsif !(own?(origin[:client_id]) || @shown_enqueued.include?(origin[:enqueued_id]))
1455
1467
  @screen.commit(prompt_line(origin[:client_id], event[:prompt]))
1456
1468
  end
1457
- @renderer.call(event)
1469
+ # Another client's prompt line is up (here or at its enqueue); an
1470
+ # own send has none, so its image lines are its echo.
1471
+ @renderer.call(own?(origin[:client_id]) ? event : without_named_images(event, event[:prompt]))
1472
+ end
1473
+
1474
+ # The turn's start without its image lines when the prompt line shown
1475
+ # for it is the web's "[image: name]" text of a turn of images alone:
1476
+ # that line names them already.
1477
+ def without_named_images(event, shown_prompt)
1478
+ ImageRef.placeholder_only?(shown_prompt) ? event.except(:images) : event
1458
1479
  end
1459
1480
 
1460
1481
  def complete_turn(event)
@@ -602,6 +602,10 @@ module Samagotchi
602
602
  # The files whose text is in the prompt (SystemPrompt#prompt_files).
603
603
  files = prompt_files_text(snapshot[:prompt_files])
604
604
  lines << "prompt files: #{files}" if files
605
+ # The toolkit's prompt text (Engine#stats_snapshot's kit_prompt).
606
+ if (kit = snapshot[:kit_prompt])
607
+ lines << "kit prompt: #{kit[:kit]} (~#{kit[:tokens]} tokens)"
608
+ end
605
609
  llm_context = llm_context_stats_text(snapshot[:llm_context])
606
610
  lines << "llm context: #{llm_context}" if llm_context
607
611
  thinking = setting_stats_text(snapshot[:thinking])