samagotchi 0.71.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/docs/architecture.md +14 -8
  4. data/docs/cli.md +3 -2
  5. data/docs/configuration.md +23 -13
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/anthropic.md +1 -1
  8. data/docs/internals/engine.md +38 -13
  9. data/docs/internals/llm-context-live.md +1 -1
  10. data/docs/internals/prompt-caching.md +6 -0
  11. data/docs/plugins.md +18 -7
  12. data/docs/sessions.md +3 -1
  13. data/docs/testing.md +10 -1
  14. data/lib/samagotchi/bootstrap/config_writer.rb +2 -3
  15. data/lib/samagotchi/bridge.rb +5 -2
  16. data/lib/samagotchi/broadcast/scope_card.rb +2 -2
  17. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -7
  18. data/lib/samagotchi/bundles/claude-peer/plugin.rb +2 -2
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -6
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +2 -2
  21. data/lib/samagotchi/bundles/coordinator/manifest.yml +8 -6
  22. data/lib/samagotchi/bundles/coordinator/plugin.rb +10 -0
  23. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +2 -1
  24. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/config.rb +7 -25
  27. data/lib/samagotchi/context_note.rb +2 -6
  28. data/lib/samagotchi/effective_model.rb +1 -1
  29. data/lib/samagotchi/engine.rb +91 -213
  30. data/lib/samagotchi/engine_peers.rb +2 -0
  31. data/lib/samagotchi/event_routes.rb +83 -0
  32. data/lib/samagotchi/hook_routes.rb +16 -37
  33. data/lib/samagotchi/host_config.rb +1 -1
  34. data/lib/samagotchi/host_keys.rb +0 -2
  35. data/lib/samagotchi/paths.rb +13 -0
  36. data/lib/samagotchi/plugin/api.rb +11 -5
  37. data/lib/samagotchi/plugin/peer.rb +5 -8
  38. data/lib/samagotchi/prompt_files.rb +2 -4
  39. data/lib/samagotchi/session.rb +2 -1
  40. data/lib/samagotchi/session_manager.rb +6 -16
  41. data/lib/samagotchi/session_model_settings.rb +227 -0
  42. data/lib/samagotchi/session_setup.rb +2 -1
  43. data/lib/samagotchi/system_prompt.rb +18 -13
  44. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -14
  45. data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
  46. data/lib/samagotchi/tool_activity.rb +1 -0
  47. data/lib/samagotchi/tool_declarations.rb +4 -0
  48. data/lib/samagotchi/toolkit.rb +55 -24
  49. data/lib/samagotchi/toolkit_binding.rb +7 -5
  50. data/lib/samagotchi/toolkit_command.rb +28 -5
  51. data/lib/samagotchi/tools/builtins.rb +2 -1
  52. data/lib/samagotchi/tools/delegate.rb +32 -6
  53. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  54. data/lib/samagotchi/tools/peers.rb +5 -2
  55. data/lib/samagotchi/tools/register_reminder.rb +1 -0
  56. data/lib/samagotchi/tools/registry.rb +1 -1
  57. data/lib/samagotchi/tools/tool_index.rb +15 -4
  58. data/lib/samagotchi/turn_note.rb +4 -1
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +13 -10
  61. data/lib/samagotchi/web/public/app.js +20 -18
  62. data/lib/samagotchi/web/public/bubbles.js +1 -1
  63. data/lib/samagotchi/web/public/cards_place.js +6 -10
  64. data/lib/samagotchi/web/public/chains.js +1 -1
  65. data/lib/samagotchi/web/public/chip_popover.js +3 -1
  66. data/lib/samagotchi/web/public/composer.js +13 -6
  67. data/lib/samagotchi/web/public/context_chips.js +25 -12
  68. data/lib/samagotchi/web/public/data.js +5 -1
  69. data/lib/samagotchi/web/public/format.js +5 -0
  70. data/lib/samagotchi/web/public/sessions_list.js +2 -3
  71. data/lib/samagotchi/web/public/stage_frame.js +1 -1
  72. data/lib/samagotchi/web/public/stage_view.js +8 -2
  73. data/lib/samagotchi/web/public/stream_handlers.js +6 -3
  74. data/lib/samagotchi/web/public/timing.js +1 -1
  75. data/lib/samagotchi/web/public/toolkit_chip.js +3 -1
  76. data/lib/samagotchi/web/session_hub.rb +8 -0
  77. metadata +3 -1
@@ -16,6 +16,7 @@ require_relative "turn_note"
16
16
  require_relative "model_profile"
17
17
  require_relative "effective_model"
18
18
  require_relative "session_setting"
19
+ require_relative "session_model_settings"
19
20
  require_relative "thinking_notices"
20
21
  require_relative "cancellation_controller"
21
22
  require_relative "context_window"
@@ -49,6 +50,7 @@ require_relative "recap_setup"
49
50
  require_relative "idle_reminders"
50
51
  require_relative "idle_scheduler"
51
52
  require_relative "hooks"
53
+ require_relative "event_routes"
52
54
  require_relative "hook_routes"
53
55
  require_relative "guardrails"
54
56
  require_relative "reminder_store"
@@ -141,6 +143,10 @@ module Samagotchi
141
143
  @profile_mutex = Mutex.new
142
144
  typed = model_typed.to_s.strip
143
145
  @model_choice = ModelChoice.new(name: @default_model_name, typed: typed.empty? ? @default_model_name : typed)
146
+ # The session's settings on its effective model (SessionModelSettings),
147
+ # read at use: /model moves the choice, #session= the session.
148
+ @model_settings = SessionModelSettings.new(registry: @host_registry, choice: -> { @model_choice },
149
+ session: -> { @session })
144
150
  # An injected client (specs) stands in for every host's client.
145
151
  @host_registry.client_override = client if client
146
152
  @client = effective_model.client
@@ -159,7 +165,9 @@ module Samagotchi
159
165
  end
160
166
  # What hooks and plugins do beyond reading their event (a notice, a
161
167
  # question, a stop, a steer), routed to the UIs. Built before anything
162
- # that may notify.
168
+ # that may notify. Where a card or a notice goes (EventRoutes) first:
169
+ # the hook routes use it.
170
+ @event_routes = build_event_routes
163
171
  @hook_routes = build_hook_routes
164
172
  # Load hooks from config (plugins) and create the registry; what fails
165
173
  # to load is announced, and a required guardrail's failure denies
@@ -772,9 +780,10 @@ module Samagotchi
772
780
 
773
781
  # Show a card in every UI (docs/plugins.md, Cards): {type: :card, id:,
774
782
  # source:, title:, body:, level:, actions: [{label:, command:}], in_turn:}.
775
- # During a turn it is a turn event (the turn's sink and the observers),
776
- # else it is announced. A card with the id of an earlier one replaces it
777
- # (btw's "thinking…" → the answer).
783
+ # Where it goes (held while the plugins load, an anytime command's, an
784
+ # init task's, a turn event, else announced) is EventRoutes#route's,
785
+ # marked in_turn: (CARD_MARKS). A card with the id of an earlier one
786
+ # replaces it (btw's "thinking…" → the answer).
778
787
  # @param source [String] who shows it (a bundle's name)
779
788
  # @param actions [Array<Hash>] {label:, command:}; a command is a line
780
789
  # the session runs, as typed (D3)
@@ -782,18 +791,7 @@ module Samagotchi
782
791
  # @raise [ArgumentError] a card without a title, or a bad action
783
792
  def show_card(source:, title:, body: "", actions: [], level: :info, id: nil)
784
793
  card = build_card(source: source, title: title, body: body, actions: actions, level: level, id: id)
785
- return @extension_load.hold(card.merge(in_turn: true))[:id] if @extension_load.loading?
786
- return announce_anytime(card.merge(in_turn: false))[:id] if anytime_thread?
787
- # A plugin's init task (chi.init): never a running turn's event.
788
- return announce(card.merge(in_turn: false)) && card[:id] if current_init_task
789
-
790
- in_turn, sink = @turn_state.in_turn_sink
791
- card[:in_turn] = in_turn ? true : false
792
- if in_turn
793
- emit_event(sink, card)
794
- else
795
- announce_or_hold(card)
796
- end
794
+ @event_routes.route(card, **EventRoutes::CARD_MARKS)
797
795
  card[:id]
798
796
  end
799
797
 
@@ -1065,7 +1063,8 @@ module Samagotchi
1065
1063
  # profile come from the effective model's server, so this may make one
1066
1064
  # short /props GET; the cheap #session_state_snapshot never does.
1067
1065
  # price_host: the effective model's host, whose hosts.<name>.models
1068
- # prices the cost estimate names.
1066
+ # prices the cost estimate names. kit_prompt: the next turn's kit
1067
+ # prompt text ({kit:, tokens:}), only when its toolkit has one.
1069
1068
  # @return [Hash] @metrics.snapshot, filled in
1070
1069
  def stats_snapshot
1071
1070
  snapshot = @metrics.snapshot
@@ -1078,6 +1077,8 @@ module Samagotchi
1078
1077
  **setting_chips(SessionSetting::STATS, model),
1079
1078
  prompt_notes: prompt_notes.map(&:to_h), prompt_files: @prompt_builder.prompt_files,
1080
1079
  price_host: model.host_name)
1080
+ kit_prompt = kit_prompt_stats
1081
+ snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
1081
1082
  unless snapshot.dig(:context, :window_tokens)
1082
1083
  window = current_context_window(model)
1083
1084
  if window
@@ -1095,36 +1096,45 @@ module Samagotchi
1095
1096
  snapshot
1096
1097
  end
1097
1098
 
1098
- # The status line's ctx from the saved context, for a worker that has run
1099
- # no turn yet (it woke after an idle exit): nil when either count is
1100
- # unknown or context.status is off. Counted as the turns count it:
1101
- # against the effective model's llm_context budget when it has one.
1102
- def saved_context_status(context, model: :current)
1103
- return nil unless context
1099
+ # ── The session's model settings (SessionModelSettings) ──────────────────
1100
+
1101
+ # The session's own thinking, reasoning_history and llm_context values
1102
+ # over the effective model's, the summaries /model and /stats show, and
1103
+ # the saved context's status: SessionModelSettings' (resolved on every
1104
+ # read); these forward.
1105
+ def saved_context_status(context, model: :current) = @model_settings.saved_context_status(context, model: model)
1106
+ def llm_context_explained = @model_settings.llm_context_explained
1107
+ def llm_context_summary = @model_settings.llm_context_summary
1108
+ def llm_context_override = @model_settings.llm_context_override
1109
+ def chat_model? = @model_settings.chat_model?
1110
+ def sampling_summary = @model_settings.sampling_summary
1111
+ def thinking_summary = @model_settings.thinking_summary
1112
+ def steer_cut_after_explained = @model_settings.steer_cut_after_explained
1113
+ def steer_cut_after = @model_settings.steer_cut_after
1114
+ def steer_cut_summary = @model_settings.steer_cut_summary
1115
+ def thinking_explained(own: thinking_override) = @model_settings.thinking_explained(own: own)
1116
+ def reasoning_history_summary = @model_settings.reasoning_history_summary
1117
+ def reasoning_history_cache_cost(from, to) = @model_settings.reasoning_history_cache_cost(from, to)
1118
+ def reasoning_history_override = @model_settings.reasoning_history_override
1119
+ def thinking_override = @model_settings.thinking_override
1120
+ def served_expected_by(served) = @model_settings.served_expected_by(served)
1121
+ def served_expected?(served) = @model_settings.served_expected?(served)
1104
1122
 
1105
- llm_context = model == :current ? turn_llm_context : model&.llm_context_explained(llm_context_session_args)&.resolved
1106
- ContextStatus.new(llm_context: llm_context)
1107
- .display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
1123
+ def reasoning_history_explained(native: false, own: reasoning_history_override)
1124
+ @model_settings.reasoning_history_explained(native: native, own: own)
1108
1125
  end
1109
1126
 
1110
- # #turn_llm_context with where each value came from
1111
- # (LLMContextStrategy.explain), nil when it can't be resolved.
1112
- # @return [LLMContextStrategy::Explained, nil]
1113
- def llm_context_explained
1114
- model_setting(nil) { |model| model.llm_context_explained(llm_context_session_args) }
1127
+ # Set the session's own reasoning_history or thinking level (nil unsets
1128
+ # it): SessionModelSettings'. The caller saves the session.
1129
+ # @raise [ArgumentError] with no session yet
1130
+ def reasoning_history_override=(level)
1131
+ @model_settings.reasoning_history_override = level
1115
1132
  end
1116
1133
 
1117
- # #llm_context_explained's summary (Explained#summary), nil when it
1118
- # can't be resolved: /stats, the snapshot, the web's info bar.
1119
- # @return [Hash, nil]
1120
- def llm_context_summary
1121
- llm_context_explained&.summary(llm_context_override)
1134
+ def thinking_override=(level)
1135
+ @model_settings.thinking_override = level
1122
1136
  end
1123
1137
 
1124
- # The session's own llm_context values, nil when it has none.
1125
- # @return [LLMContextOverride, nil]
1126
- def llm_context_override = SessionSetting::LLM_CONTEXT.own(@session)
1127
-
1128
1138
  # Set the session's own llm_context values (an empty override unsets
1129
1139
  # them); the next turn's start resolves them, never a running one (its
1130
1140
  # TurnSettings are fixed). The caller saves the session.
@@ -1137,102 +1147,11 @@ module Samagotchi
1137
1147
  @last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
1138
1148
  end
1139
1149
 
1140
- # The effective model is on a chat host (api: openai or anthropic), whose loop uses
1141
- # no prompt profile.
1142
- def chat_model? = effective_model.chat?
1143
-
1144
- # "temperature=0.6 (hosts.work)" for /model: the effective model's
1145
- # configured sampling, nil when none.
1146
- def sampling_summary = model_setting(nil, &:sampling_summary)
1147
-
1148
- # "off (models: qwen)", "low (session)" for /model: the effective
1149
- # model's thinking level and where it came from, nil when none is set.
1150
- def thinking_summary
1151
- explained = thinking_explained
1152
- explained&.source ? explained.label : nil
1153
- end
1154
-
1155
- # The seconds a cut waits for the effective model to stream only
1156
- # thinking (SteerCutAfter.resolve), resolved on every call, as
1157
- # turn_sampling; on any error, the global and its name.
1158
- # @return [Array(Integer, String|nil)]
1159
- def steer_cut_after_explained
1160
- model_setting([SteerCut.cut_after, "steer.cut_after"], &:steer_cut_after_explained)
1161
- end
1162
-
1163
- # #steer_cut_after_explained's seconds: what SteerCut#cut_for_steer
1164
- # reads, per host and model.
1165
- def steer_cut_after = steer_cut_after_explained.first
1166
-
1167
- # "after 60 s (models: qwen)", "never (hosts.splash)" for /model: the
1168
- # effective model's cut wait and where it came from, nil for the
1169
- # default.
1170
- def steer_cut_summary
1171
- seconds, source = steer_cut_after_explained
1172
- source ? SteerCutAfter.label(seconds, source) : nil
1173
- end
1174
-
1175
- # The effective model's thinking level with where it came from and the
1176
- # session's own (SettingExplained), as the next turn resolves it;
1177
- # nil when it can't be resolved.
1178
- # @return [SettingExplained, nil]
1179
- # +own+: the session's own level to resolve with (the current one by
1180
- # default; /thinking asks what a change would resolve to).
1181
- def thinking_explained(own: thinking_override)
1182
- model_setting(nil) { |model| model.thinking_explained(own: own) }
1183
- end
1184
-
1185
- # "drop (models: qwen)" for /model: the effective chat model's
1186
- # reasoning_history level and where it came from; nil when it is the
1187
- # default, the model runs on the native loop (which ignores it) or it
1188
- # can't be resolved.
1189
- def reasoning_history_summary
1190
- model_setting(nil) { |model| model.reasoning_history_summary(own: reasoning_history_override) }
1191
- end
1192
-
1193
- # The effective chat model's reasoning_history level with where it came
1194
- # from and the session's own (SettingExplained), as the next turn
1195
- # resolves it; nil on a native host (the native loop ignores it; native:
1196
- # true resolves it there too, for /reasoning-history) or when it can't
1197
- # be resolved.
1198
- # @return [SettingExplained, nil]
1199
- # +own+: the session's own level to resolve with (the current one by
1200
- # default; a command asks what a change would resolve to).
1201
- def reasoning_history_explained(native: false, own: reasoning_history_override)
1202
- model_setting(nil) { |model| model.reasoning_history_explained(own: own, native: native) }
1203
- end
1204
-
1205
- # What a reasoning_history change from level +from+ to level +to+ costs
1206
- # the effective model's prompt cache, given the session's conversation
1207
- # (ReasoningHistory.cache_cost): :history or nil. A host ChatLoop
1208
- # marked as refusing it may still get :history (one needless ask).
1209
- # @return [Symbol, nil]
1210
- def reasoning_history_cache_cost(from, to)
1211
- model_setting(nil) { |model| model.reasoning_history_cache_cost(from, to, conversation: @session&.messages) }
1212
- end
1213
-
1214
1150
  # Whether the session has a model reply (a cache to restart at all).
1215
1151
  def history?
1216
1152
  Array(@session&.messages).any? { |entry| entry[:role].to_s == "model" }
1217
1153
  end
1218
1154
 
1219
- # The session's own reasoning_history level, nil when it has none.
1220
- # @return [Symbol, nil]
1221
- def reasoning_history_override = SessionSetting::REASONING_HISTORY.own(@session)
1222
-
1223
- # Set the session's own reasoning_history level (nil unsets it); the
1224
- # next turn's start resolves it. The caller saves the session.
1225
- # @raise [ArgumentError] with no session yet
1226
- def reasoning_history_override=(level)
1227
- raise ArgumentError, "no session yet" unless @session
1228
-
1229
- SessionSetting::REASONING_HISTORY.set(@session, level)
1230
- end
1231
-
1232
- # The session's own thinking level, nil when it has none.
1233
- # @return [Symbol, nil]
1234
- def thinking_override = SessionSetting::THINKING.own(@session)
1235
-
1236
1155
  # What a thinking level change costs the effective model's prompt
1237
1156
  # cache (Thinking.cache_cost): :full, :provider or :tail.
1238
1157
  # @return [Symbol]
@@ -1240,16 +1159,6 @@ module Samagotchi
1240
1159
  effective_model.thinking_cache_cost { profile }
1241
1160
  end
1242
1161
 
1243
- # Set the session's own thinking level (nil unsets it); the next turn's
1244
- # start resolves it, never a running one (its TurnSettings are fixed).
1245
- # The caller saves the session.
1246
- # @raise [ArgumentError] with no session yet
1247
- def thinking_override=(level)
1248
- raise ArgumentError, "no session yet" unless @session
1249
-
1250
- SessionSetting::THINKING.set(@session, level)
1251
- end
1252
-
1253
1162
  # The model the server serves for the current model, and the name asked
1254
1163
  # for: what the last generation of that name reported, else llama.cpp's
1255
1164
  # model_alias (/props, one short cached probe; not with probe: false),
@@ -1259,16 +1168,6 @@ module Samagotchi
1259
1168
  served_model_for(@metrics.served_report, model: probe ? effective_model : nil)
1260
1169
  end
1261
1170
 
1262
- # The host whose hosts.<name>.models.<id>.served names +served+ for the
1263
- # current model (a gateway's round-robin targets): not a mismatch to
1264
- # warn about. nil when none does.
1265
- # @return [String, nil]
1266
- def served_expected_by(served)
1267
- model_setting(nil) { |model| model.served_expected_by(served) }
1268
- end
1269
-
1270
- def served_expected?(served) = !served_expected_by(served).nil?
1271
-
1272
1171
  # Read-only snapshot of the engine's view of the current session plus the
1273
1172
  # live event sequence. Cheap primitive used by the bridge's reconnect-too-
1274
1173
  # old reset marker and the GET /session/:id/state read surface. Orthogonal
@@ -1299,7 +1198,7 @@ module Samagotchi
1299
1198
  # One resolve for the model's fields; nil when it doesn't resolve (the
1300
1199
  # Bridge's /state and reset frames rely on this never raising): those
1301
1200
  # fields are nil then, as their own rescues always gave.
1302
- model = model_setting(nil) { |resolved| resolved }
1201
+ model = @model_settings.model_setting(nil) { |resolved| resolved }
1303
1202
  {
1304
1203
  status: @session&.status,
1305
1204
  message_count: (@session&.messages || []).size,
@@ -1587,17 +1486,26 @@ module Samagotchi
1587
1486
  def hook_stop_generation(reason, hook) = @hook_routes.stop_generation(reason, hook)
1588
1487
  private :hook_stop_generation
1589
1488
 
1590
- # The hook routes (see #initialize for when).
1591
- def build_hook_routes
1592
- HookRoutes.new(
1489
+ # Where cards and notices go (see #initialize for when). Each lookup
1490
+ # calls the Engine method the routes used before EventRoutes, at use.
1491
+ def build_event_routes
1492
+ EventRoutes.new(
1593
1493
  extension_load: -> { @extension_load },
1594
1494
  in_turn_sink: -> { @turn_state.in_turn_sink },
1595
1495
  anytime_thread: -> { anytime_thread? },
1596
- announce_anytime: ->(event) { announce_anytime(event) },
1597
1496
  init_task: -> { current_init_task },
1598
1497
  announce: ->(event) { announce(event) },
1498
+ announce_anytime: ->(event) { announce_anytime(event) },
1599
1499
  announce_or_hold: ->(event) { announce_or_hold(event) },
1600
- emit: ->(sink, event) { emit_event(sink, event) },
1500
+ emit: ->(sink, event) { emit_event(sink, event) }
1501
+ )
1502
+ end
1503
+ private :build_event_routes
1504
+
1505
+ # The hook routes (see #initialize for when).
1506
+ def build_hook_routes
1507
+ HookRoutes.new(
1508
+ event_routes: @event_routes,
1601
1509
  question_desk: -> { @question_desk },
1602
1510
  cancel_controller: -> { active_cancel_controller },
1603
1511
  steer: ->(text, source) { steer(text, source: source) }
@@ -1682,8 +1590,8 @@ module Samagotchi
1682
1590
  def system_prompt(model = nil)
1683
1591
  model ||= effective_model
1684
1592
  chat = model.chat?
1685
- level = chat ? nil : thinking_level(model)
1686
- prompt = @prompt_builder.build(chat: chat, thinking: level, layers: model.llm_context_layers(llm_context_session_args))
1593
+ level = chat ? nil : @model_settings.thinking_level(model)
1594
+ prompt = @prompt_builder.build(chat: chat, thinking: level, layers: model.llm_context_layers(@model_settings.llm_context_session_args))
1687
1595
  # The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
1688
1596
  @metrics.memory_index = @prompt_builder.memory_index
1689
1597
  # The model notes it carries: the session records them (/stats, /model, the web).
@@ -2027,10 +1935,10 @@ module Samagotchi
2027
1935
  model = effective_model
2028
1936
  vision = turn_vision(session, model)
2029
1937
  sampling = model.sampling
2030
- level = thinking_level(model)
1938
+ level = @model_settings.thinking_level(model)
2031
1939
  @thinking_notices.turn_started(level, model.target)
2032
1940
  LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: level,
2033
- reasoning_history: reasoning_history_level(model), model_name: nil,
1941
+ reasoning_history: @model_settings.reasoning_history_level(model), model_name: nil,
2034
1942
  window_setting: nil, llm_context: nil, price: nil)
2035
1943
  end
2036
1944
  private :turn_settings
@@ -2060,7 +1968,7 @@ module Samagotchi
2060
1968
  messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(model) })
2061
1969
  # Under the next turn's strategy (a /model switch may have changed it);
2062
1970
  # the last turn's when it can't be resolved.
2063
- prompt, images = @kernel.warmup_prompt(messages, llm_context: turn_llm_context || @kernel.turn_settings&.llm_context)
1971
+ prompt, images = @kernel.warmup_prompt(messages, llm_context: @model_settings.turn_llm_context || @kernel.turn_settings&.llm_context)
2064
1972
  return unless prompt
2065
1973
 
2066
1974
  started = @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
@@ -2126,7 +2034,7 @@ module Samagotchi
2126
2034
  # The turn's settings, set once here. The chat loop dispatches tools
2127
2035
  # through the kernel without its #run: tag those dumps with this
2128
2036
  # turn's model, not the last native one.
2129
- explained = model.llm_context_explained(llm_context_session_args)
2037
+ explained = model.llm_context_explained(@model_settings.llm_context_session_args)
2130
2038
  @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: model.window_setting,
2131
2039
  llm_context: explained&.resolved, price: model.price)
2132
2040
  log_llm_context(explained)
@@ -2355,19 +2263,10 @@ module Samagotchi
2355
2263
  capability: -> { model.vision_support(profile) })
2356
2264
  end
2357
2265
 
2358
- # The effective model's request parameters (hosts: and models:
2359
- # sampling), read per turn: /model can change the model between turns.
2360
- def turn_sampling = model_setting(SamplingSettings::EMPTY, &:sampling)
2361
-
2362
- # The effective model's thinking level (Thinking.resolve), read each turn.
2363
- def turn_thinking = model_setting(Thinking::DEFAULT) { |model| thinking_level(model) }
2364
-
2365
- # +model+'s ReasoningHistory level, the session's own first; the
2366
- # default when it can't be resolved.
2367
- def reasoning_history_level(model) = model.reasoning_history(own: reasoning_history_override)
2368
-
2369
- # +model+'s thinking level (Thinking.resolve), the session's own first.
2370
- def thinking_level(model) = model.thinking(own: thinking_override)
2266
+ # The effective model's sampling and thinking level as the next turn
2267
+ # reads them (SessionModelSettings).
2268
+ def turn_sampling = @model_settings.turn_sampling
2269
+ def turn_thinking = @model_settings.turn_thinking
2371
2270
 
2372
2271
  # A model known not to see images fails a turn with images up front.
2373
2272
  def refuse_images!(vision)
@@ -2778,24 +2677,10 @@ module Samagotchi
2778
2677
  emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
2779
2678
  end
2780
2679
 
2781
- # The effective model's models:/hosts: window_tokens, read per turn
2782
- # (after the hooks that may switch the model).
2783
- def turn_window_setting = model_setting(nil, &:window_setting)
2784
-
2785
- # The effective model's price on its host (hosts.<name>.models.<id>.price),
2786
- # by the requested id, never the served one; read per turn as the window is.
2787
- def turn_price = model_setting(nil, &:price)
2788
-
2789
- # The LLM context strategy the next turn runs under: the session's own
2790
- # (Session#llm_context), else the effective model's (models:/hosts:
2791
- # llm_context_strategy), else llm_context.strategy; read per turn as
2792
- # the window is.
2793
- def turn_llm_context
2794
- llm_context_explained&.resolved
2795
- end
2796
-
2797
- def llm_context_session_args = @session&.llm_context&.resolve_args || {}
2798
- private :llm_context_session_args
2680
+ # The effective model's window_tokens and price, read per turn
2681
+ # (SessionModelSettings).
2682
+ def turn_window_setting = @model_settings.turn_window_setting
2683
+ def turn_price = @model_settings.turn_price
2799
2684
 
2800
2685
  # See #served_model. A report for another name (before a /model switch)
2801
2686
  # doesn't count. Without a model (EffectiveModel), no probe. A remote
@@ -2813,6 +2698,15 @@ module Samagotchi
2813
2698
  [nil, nil]
2814
2699
  end
2815
2700
 
2701
+ # The next turn's kit prompt text for /stats: its kit and its estimated
2702
+ # tokens (chars / 4), nil when its toolkit has none.
2703
+ def kit_prompt_stats
2704
+ resolved = toolkit_explained.resolved
2705
+ return nil unless resolved.prompt
2706
+
2707
+ { kit: resolved.name, tokens: (resolved.prompt.length / TokenUsage::CHARS_PER_TOKEN).ceil }
2708
+ end
2709
+
2816
2710
  # The window as +model+'s loop would see it (EffectiveModel#context_window).
2817
2711
  def current_context_window(model) = model.context_window
2818
2712
 
@@ -2822,26 +2716,10 @@ module Samagotchi
2822
2716
  settings.to_h { |setting| [setting.key, setting.model? ? setting.chip_for(model, @session) : toolkit_summary] }
2823
2717
  end
2824
2718
 
2825
- # The effective model now (EffectiveModel.of): resolved on each call,
2826
- # never kept (config.yml, a model listing and another process's saved
2827
- # lists all move a resolve). Raises as HostRegistry#resolve does.
2719
+ # The effective model now, resolved on each call, never kept
2720
+ # (SessionModelSettings#effective_model). Raises as HostRegistry#resolve does.
2828
2721
  # @return [EffectiveModel]
2829
- def effective_model(choice = @model_choice) = EffectiveModel.of(@host_registry, choice)
2830
-
2831
- # The block's answer for the effective model, or +fallback+ when the
2832
- # model doesn't resolve: the readers whose rescue always covered the
2833
- # resolve too. Each setting's own failure is the EffectiveModel's.
2834
- def model_setting(fallback)
2835
- # One read: the log line names the model that failed, even if a
2836
- # /model switched it meanwhile.
2837
- choice = @model_choice
2838
- model = begin
2839
- effective_model(choice)
2840
- rescue StandardError => e
2841
- return EffectiveModel::Unresolved.fallback(:effective_model_unresolved, choice.name, e, fallback)
2842
- end
2843
- yield model
2844
- end
2722
+ def effective_model(choice = @model_choice) = @model_settings.effective_model(choice)
2845
2723
 
2846
2724
  # ── Prompt profile ─────────────────────────────────────────────────────────
2847
2725
 
@@ -20,6 +20,8 @@ module Samagotchi
20
20
  def model_ref = engine.effective_model_ref
21
21
  def thinking = engine.thinking_override
22
22
  def reasoning_history = engine.reasoning_history_override
23
+ # The kits a delegate may start its child on (Engine#toolkit_kits).
24
+ def toolkit_names = engine.toolkit_kits.keys
23
25
  # The peer's name when +name+ is a source a peer bundle declared
24
26
  # (PeerSources), for context_read's header.
25
27
  def peer_label(name) = engine.peer_sources&.label_for(name)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Where a card or a notice goes, by what this thread is doing (Engine#show_card,
5
+ # HookRoutes#notify, HookRoutes#guardrail_notify):
6
+ #
7
+ # 1. the plugins load (ExtensionLoad#loading?): held, announced after the load
8
+ # (Engine#announce_load_events!);
9
+ # 2. an anytime command's thread (Engine#running_anytime): announced at once,
10
+ # marked anytime: true;
11
+ # 3. a plugin's init task (chi.init): announced, never a running turn's event;
12
+ # 4. a running turn: the turn's event (its sink and the observers);
13
+ # 5. else announced, or held for Engine#holding_announcements' caller.
14
+ #
15
+ # #route takes all five; #route_by_turn only 4 and 5 (a guardrails line).
16
+ # Each caller says what the event is marked with on each route (CARD_MARKS,
17
+ # NOTICE_MARKS). What it needs from the Engine comes through lookups, called
18
+ # at use.
19
+ class EventRoutes
20
+ # A card says whether it came in a turn (in_turn:); a held one, true.
21
+ CARD_MARKS = { held: { in_turn: true }.freeze, in_turn: { in_turn: true }.freeze,
22
+ between: { in_turn: false }.freeze }.freeze
23
+ # A notice is marked only outside a turn (between_turns: true).
24
+ NOTICE_MARKS = { held: {}.freeze, in_turn: {}.freeze, between: { between_turns: true }.freeze }.freeze
25
+
26
+ # @param extension_load [#call] → ExtensionLoad (#loading?, #hold)
27
+ # @param in_turn_sink [#call] → [Boolean, sink] (TurnState#in_turn_sink)
28
+ # @param anytime_thread [#call] → whether this thread runs an anytime command
29
+ # @param init_task [#call] → the init task running on this thread, nil
30
+ # @param announce [#call] (event) → announces an event
31
+ # @param announce_anytime [#call] (event) → announces an anytime command's event
32
+ # @param announce_or_hold [#call] (event) → announces it, or holds it for the caller
33
+ # @param emit [#call] (sink, event) → emits to a turn's sink + observers
34
+ def initialize(extension_load:, in_turn_sink:, anytime_thread:, init_task:, announce:, announce_anytime:,
35
+ announce_or_hold:, emit:)
36
+ @extension_load = extension_load
37
+ @in_turn_sink = in_turn_sink
38
+ @anytime_thread = anytime_thread
39
+ @init_task = init_task
40
+ @announce = announce
41
+ @announce_anytime = announce_anytime
42
+ @announce_or_hold = announce_or_hold
43
+ @emit = emit
44
+ end
45
+
46
+ # Route +event+ by the five routes, first match wins.
47
+ # @param held [Hash] merged into the event held while the plugins load
48
+ # @param in_turn [Hash] merged into a running turn's event
49
+ # @param between [Hash] merged into an event announced outside a turn (routes 2, 3, 5)
50
+ # @return [Symbol] the route taken: :held, :anytime, :init, :in_turn or :between
51
+ def route(event, held:, in_turn:, between:)
52
+ extension_load = @extension_load.call
53
+ if extension_load.loading?
54
+ extension_load.hold(event.merge(held))
55
+ return :held
56
+ end
57
+ if @anytime_thread.call
58
+ @announce_anytime.call(event.merge(between))
59
+ return :anytime
60
+ end
61
+ if @init_task.call
62
+ @announce.call(event.merge(between))
63
+ return :init
64
+ end
65
+ route_by_turn(event, in_turn: in_turn, between: between)
66
+ end
67
+
68
+ # Route +event+ by routes 4 and 5 only: a running turn's event, else
69
+ # announced or held for the caller. Never held while the plugins load,
70
+ # never marked anytime (a guardrails line).
71
+ # @return [Symbol] :in_turn or :between
72
+ def route_by_turn(event, in_turn: {}, between: {})
73
+ running, sink = @in_turn_sink.call
74
+ if running
75
+ @emit.call(sink, event.merge(in_turn))
76
+ :in_turn
77
+ else
78
+ @announce_or_hold.call(event.merge(between))
79
+ :between
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "event_routes"
3
4
  require_relative "hooks"
4
5
  require_relative "log"
5
6
 
@@ -8,33 +9,21 @@ module Samagotchi
8
9
  # reading its event (Hooks::Runtime, Plugin::Host): a notice (#notify),
9
10
  # a question (#ask_user), stopping the turn or cutting its generation,
10
11
  # and a steer; also the guardrails' line after load (#guardrail_notify).
12
+ # Where a notice goes (held, anytime, init task, turn, between turns) is
13
+ # EventRoutes'.
11
14
  #
12
- # What it needs from the Engine comes through lookups, called at use.
15
+ # What it needs from the Engine comes through lookups, called at use, and
16
+ # the Engine's EventRoutes.
13
17
  class HookRoutes
14
18
  # What a notice may stand in for (#notify's fallback_for:).
15
19
  NOTICE_FALLBACKS = %i[display editor].freeze
16
20
 
17
- # @param extension_load [#call] → ExtensionLoad (#loading?, #hold)
18
- # @param in_turn_sink [#call] → [Boolean, sink] (TurnState#in_turn_sink)
19
- # @param anytime_thread [#call] → whether this thread runs an anytime command
20
- # @param announce_anytime [#call] (event) → announces an anytime command's event
21
- # @param init_task [#call] → the init task running on this thread, nil
22
- # @param announce [#call] (event) → announces an event
23
- # @param announce_or_hold [#call] (event) → announces it, or holds it for the caller
24
- # @param emit [#call] (sink, event) → emits to a turn's sink + observers
21
+ # @param event_routes [EventRoutes] where a notice goes
25
22
  # @param question_desk [#call] → QuestionDesk
26
23
  # @param cancel_controller [#call] → the running turn's CancellationController, nil
27
24
  # @param steer [#call] (text, source) → steers the running turn
28
- def initialize(extension_load:, in_turn_sink:, anytime_thread:, announce_anytime:, init_task:, announce:,
29
- announce_or_hold:, emit:, question_desk:, cancel_controller:, steer:)
30
- @extension_load = extension_load
31
- @in_turn_sink = in_turn_sink
32
- @anytime_thread = anytime_thread
33
- @announce_anytime = announce_anytime
34
- @init_task = init_task
35
- @announce = announce
36
- @announce_or_hold = announce_or_hold
37
- @emit = emit
25
+ def initialize(event_routes:, question_desk:, cancel_controller:, steer:)
26
+ @event_routes = event_routes
38
27
  @question_desk = question_desk
39
28
  @cancel_controller = cancel_controller
40
29
  @steer = steer
@@ -63,7 +52,9 @@ module Samagotchi
63
52
  # One line to the user (:hook_notice). During a turn it is a turn
64
53
  # event: the turn's sink (the REPL) and the observers (bridge, log).
65
54
  # Outside one (a plugin's command at the prompt) it is announced with
66
- # between_turns: true, which every UI shows as cards are shown.
55
+ # between_turns: true, which every UI shows as cards are shown. While the
56
+ # plugins load it is held; on an anytime command's thread or an init task
57
+ # it is announced (EventRoutes#route).
67
58
  #
68
59
  # +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
69
60
  # :display, the answer's display (AnswerDisplay), for a UI that doesn't
@@ -77,28 +68,16 @@ module Samagotchi
77
68
  level = :info unless %i[info warn].include?(level)
78
69
  notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level,
79
70
  fallback_for: notice_fallback(fallback_for, hook) }.compact.merge(extra)
80
- extension_load = @extension_load.call
81
- return extension_load.hold(notice) && nil if extension_load.loading?
82
-
83
- in_turn, sink = @in_turn_sink.call
84
- if @anytime_thread.call
85
- @announce_anytime.call(notice.merge(between_turns: true))
86
- elsif @init_task.call
87
- @announce.call(notice.merge(between_turns: true))
88
- elsif in_turn
89
- @emit.call(sink, notice)
90
- else
91
- @announce_or_hold.call(notice.merge(between_turns: true))
92
- end
71
+ @event_routes.route(notice, **EventRoutes::NOTICE_MARKS)
93
72
  nil
94
73
  end
95
74
 
96
75
  # A guardrails line to the user after load (:guardrail_warning, as the
97
- # load failures show): a running turn's event, else announced.
76
+ # load failures show): a running turn's event, else announced or held for
77
+ # the caller (EventRoutes#route_by_turn: never held while the plugins
78
+ # load, never an anytime command's, unmarked).
98
79
  def guardrail_notify(message)
99
- event = { type: :guardrail_warning, message: message.to_s }
100
- in_turn, sink = @in_turn_sink.call
101
- in_turn ? @emit.call(sink, event) : @announce_or_hold.call(event)
80
+ @event_routes.route_by_turn({ type: :guardrail_warning, message: message.to_s })
102
81
  nil
103
82
  end
104
83