samagotchi 0.152.0 → 0.154.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 (229) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +2 -3
  4. data/bin/chi +23 -24
  5. data/docs/architecture.md +30 -33
  6. data/docs/broadcast.md +5 -5
  7. data/docs/bundle-authoring.md +1 -1
  8. data/docs/bundles.md +93 -12
  9. data/docs/cli.md +83 -99
  10. data/docs/configuration.md +46 -40
  11. data/docs/context.md +3 -3
  12. data/docs/desktop.md +1 -1
  13. data/docs/guardrails.md +15 -15
  14. data/docs/hooks.md +13 -12
  15. data/docs/internals/anthropic.md +3 -3
  16. data/docs/internals/attached-context.md +5 -2
  17. data/docs/internals/background-tasks.md +3 -4
  18. data/docs/internals/block-renderers.md +4 -0
  19. data/docs/internals/bridge.md +3 -3
  20. data/docs/internals/context-telemetry.md +1 -1
  21. data/docs/internals/engine.md +42 -12
  22. data/docs/internals/objectives.md +6 -5
  23. data/docs/internals/participants.md +8 -8
  24. data/docs/internals/prompt-caching.md +8 -4
  25. data/docs/internals/remote-sessions.md +7 -7
  26. data/docs/internals/web-frontend.md +20 -17
  27. data/docs/internals/web-push.md +1 -1
  28. data/docs/memory.md +2 -2
  29. data/docs/plugins.md +52 -43
  30. data/docs/releasing.md +1 -1
  31. data/docs/sessions.md +25 -25
  32. data/docs/sub-agent.md +2 -2
  33. data/docs/system-one.md +1 -1
  34. data/docs/testing.md +18 -8
  35. data/lib/samagotchi/answer_text.rb +34 -0
  36. data/lib/samagotchi/bridge.rb +43 -32
  37. data/lib/samagotchi/bridge_client.rb +10 -9
  38. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  39. data/lib/samagotchi/broadcast/triage.rb +1 -1
  40. data/lib/samagotchi/broadcast_command.rb +5 -5
  41. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  42. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  43. data/lib/samagotchi/bundle_command.rb +2 -2
  44. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  45. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  46. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  48. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  49. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  50. data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
  51. data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
  52. data/lib/samagotchi/child_move.rb +3 -3
  53. data/lib/samagotchi/commands/registry.rb +6 -6
  54. data/lib/samagotchi/composer_route.rb +4 -5
  55. data/lib/samagotchi/config.rb +14 -12
  56. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  57. data/lib/samagotchi/context_candidates.rb +4 -3
  58. data/lib/samagotchi/continue_offer.rb +1 -1
  59. data/lib/samagotchi/debug_log.rb +1 -1
  60. data/lib/samagotchi/degrade.rb +75 -0
  61. data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
  62. data/lib/samagotchi/engine/cards.rb +87 -0
  63. data/lib/samagotchi/engine/context_judge.rb +138 -0
  64. data/lib/samagotchi/engine/events.rb +131 -0
  65. data/lib/samagotchi/engine/guardrails.rb +87 -0
  66. data/lib/samagotchi/engine/hooks.rb +119 -0
  67. data/lib/samagotchi/engine/messages.rb +124 -0
  68. data/lib/samagotchi/engine/model_settings.rb +127 -0
  69. data/lib/samagotchi/engine/models.rb +261 -0
  70. data/lib/samagotchi/engine/objectives.rb +116 -0
  71. data/lib/samagotchi/engine/participants.rb +86 -0
  72. data/lib/samagotchi/engine/plugins.rb +208 -0
  73. data/lib/samagotchi/engine/questions.rb +98 -0
  74. data/lib/samagotchi/engine/recap.rb +130 -0
  75. data/lib/samagotchi/engine/session.rb +131 -0
  76. data/lib/samagotchi/engine/side_requests.rb +135 -0
  77. data/lib/samagotchi/engine/steering.rb +134 -0
  78. data/lib/samagotchi/engine/toolkit.rb +269 -0
  79. data/lib/samagotchi/engine/turn_end.rb +180 -0
  80. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  81. data/lib/samagotchi/engine/verify.rb +153 -0
  82. data/lib/samagotchi/engine.rb +63 -2642
  83. data/lib/samagotchi/grants.rb +575 -0
  84. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  85. data/lib/samagotchi/guardrails/context.rb +2 -3
  86. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  87. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  88. data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
  89. data/lib/samagotchi/hook_routes.rb +3 -3
  90. data/lib/samagotchi/idle_recap.rb +4 -5
  91. data/lib/samagotchi/idle_reminders.rb +3 -4
  92. data/lib/samagotchi/iteration_limit.rb +2 -2
  93. data/lib/samagotchi/kernel_loop.rb +10 -11
  94. data/lib/samagotchi/launch_mode.rb +13 -32
  95. data/lib/samagotchi/level_setting_command.rb +3 -2
  96. data/lib/samagotchi/llm_context_command.rb +3 -2
  97. data/lib/samagotchi/llm_context_stale.rb +2 -2
  98. data/lib/samagotchi/log.rb +7 -7
  99. data/lib/samagotchi/log_line.rb +1 -1
  100. data/lib/samagotchi/log_path.rb +1 -1
  101. data/lib/samagotchi/message_parts.rb +3 -2
  102. data/lib/samagotchi/note_command.rb +1 -1
  103. data/lib/samagotchi/note_delivery.rb +18 -8
  104. data/lib/samagotchi/objectives.rb +24 -3
  105. data/lib/samagotchi/objectives_check.rb +8 -27
  106. data/lib/samagotchi/objectives_command.rb +4 -69
  107. data/lib/samagotchi/objectives_edit.rb +87 -0
  108. data/lib/samagotchi/output_formatter.rb +7 -1
  109. data/lib/samagotchi/owner_lock.rb +7 -1
  110. data/lib/samagotchi/parent_answers_command.rb +5 -4
  111. data/lib/samagotchi/participant_exposure.rb +42 -0
  112. data/lib/samagotchi/participant_mode.rb +1 -1
  113. data/lib/samagotchi/participant_name.rb +1 -1
  114. data/lib/samagotchi/participants.rb +17 -17
  115. data/lib/samagotchi/participants_watch.rb +1 -1
  116. data/lib/samagotchi/pending_input_queue.rb +2 -2
  117. data/lib/samagotchi/plugin/api.rb +3 -2
  118. data/lib/samagotchi/plugin/context.rb +21 -7
  119. data/lib/samagotchi/plugin/peer.rb +2 -1
  120. data/lib/samagotchi/plugin/sessions.rb +6 -4
  121. data/lib/samagotchi/plugin_tasks.rb +1 -2
  122. data/lib/samagotchi/private_files.rb +29 -0
  123. data/lib/samagotchi/project_env.rb +2 -2
  124. data/lib/samagotchi/question_desk.rb +8 -7
  125. data/lib/samagotchi/recap_command.rb +2 -1
  126. data/lib/samagotchi/recap_setup.rb +1 -1
  127. data/lib/samagotchi/recap_store.rb +1 -1
  128. data/lib/samagotchi/redact.rb +86 -0
  129. data/lib/samagotchi/reminder_queue.rb +2 -2
  130. data/lib/samagotchi/remote_attach_command.rb +1 -1
  131. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  132. data/lib/samagotchi/review_start.rb +1 -1
  133. data/lib/samagotchi/run_options.rb +6 -6
  134. data/lib/samagotchi/self_report.rb +42 -39
  135. data/lib/samagotchi/send_command/busy.rb +1 -1
  136. data/lib/samagotchi/send_command.rb +4 -1
  137. data/lib/samagotchi/session.rb +11 -2
  138. data/lib/samagotchi/session_access/contract.rb +3 -3
  139. data/lib/samagotchi/session_access/local.rb +9 -9
  140. data/lib/samagotchi/session_access/remote.rb +17 -7
  141. data/lib/samagotchi/session_access.rb +4 -4
  142. data/lib/samagotchi/session_archive_command.rb +2 -2
  143. data/lib/samagotchi/session_chain.rb +8 -6
  144. data/lib/samagotchi/session_commands.rb +45 -48
  145. data/lib/samagotchi/session_continue.rb +1 -1
  146. data/lib/samagotchi/session_delete_command.rb +4 -4
  147. data/lib/samagotchi/session_manager.rb +59 -36
  148. data/lib/samagotchi/session_metrics.rb +2 -2
  149. data/lib/samagotchi/session_model_settings.rb +3 -2
  150. data/lib/samagotchi/session_participants.rb +1 -1
  151. data/lib/samagotchi/session_retention.rb +3 -3
  152. data/lib/samagotchi/sessions_command.rb +4 -4
  153. data/lib/samagotchi/share_command.rb +10 -10
  154. data/lib/samagotchi/steer.rb +1 -1
  155. data/lib/samagotchi/system_prompt.rb +102 -109
  156. data/lib/samagotchi/task_watch.rb +1 -1
  157. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  158. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  159. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  160. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  161. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  162. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
  163. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  164. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  165. data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
  166. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  167. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  168. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  169. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  170. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  171. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  172. data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
  173. data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
  174. data/lib/samagotchi/terminal_ui.rb +142 -1387
  175. data/lib/samagotchi/title_command.rb +2 -2
  176. data/lib/samagotchi/tool_runner.rb +14 -16
  177. data/lib/samagotchi/toolkit.rb +8 -5
  178. data/lib/samagotchi/toolkit_binding.rb +30 -5
  179. data/lib/samagotchi/toolkit_command.rb +2 -1
  180. data/lib/samagotchi/toolkit_report_command.rb +9 -5
  181. data/lib/samagotchi/toolkit_suggest.rb +12 -6
  182. data/lib/samagotchi/tools/builtin_calls.rb +14 -10
  183. data/lib/samagotchi/tools/deferred_tools.rb +2 -2
  184. data/lib/samagotchi/tools/delegate.rb +3 -1
  185. data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
  186. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  187. data/lib/samagotchi/tools/objectives_tool.rb +2 -2
  188. data/lib/samagotchi/tools/registry.rb +44 -20
  189. data/lib/samagotchi/tools/send_note.rb +2 -1
  190. data/lib/samagotchi/tools/tool_index.rb +27 -21
  191. data/lib/samagotchi/turn_endings.rb +2 -2
  192. data/lib/samagotchi/turn_flow.rb +4 -5
  193. data/lib/samagotchi/update_hint.rb +2 -2
  194. data/lib/samagotchi/version.rb +1 -1
  195. data/lib/samagotchi/web/app.rb +39 -50
  196. data/lib/samagotchi/web/participant_access.rb +2 -2
  197. data/lib/samagotchi/web/participant_review.rb +5 -5
  198. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  199. data/lib/samagotchi/web/participant_view.rb +2 -82
  200. data/lib/samagotchi/web/participants_admin.rb +1 -1
  201. data/lib/samagotchi/web/public/app.js +61 -357
  202. data/lib/samagotchi/web/public/block_host.js +35 -8
  203. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  204. data/lib/samagotchi/web/public/chains.js +2 -2
  205. data/lib/samagotchi/web/public/claim_link.js +4 -4
  206. data/lib/samagotchi/web/public/composer_mode.js +110 -0
  207. data/lib/samagotchi/web/public/format.js +3 -3
  208. data/lib/samagotchi/web/public/owner_claim.js +81 -0
  209. data/lib/samagotchi/web/public/owner_verify.js +112 -0
  210. data/lib/samagotchi/web/public/session_fields.js +3 -3
  211. data/lib/samagotchi/web/public/strip_grip.js +3 -3
  212. data/lib/samagotchi/web/public/strip_hidden.js +65 -0
  213. data/lib/samagotchi/web/public/version_notices.js +132 -0
  214. data/lib/samagotchi/web/push/store.rb +1 -1
  215. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  216. data/lib/samagotchi/web/session_control.rb +12 -8
  217. data/lib/samagotchi/web/session_create.rb +1 -1
  218. data/lib/samagotchi/web/session_hub.rb +2 -2
  219. data/lib/samagotchi/web/session_show.rb +2 -2
  220. data/lib/samagotchi/web/session_summary.rb +1 -1
  221. data/lib/samagotchi/worker.rb +106 -9
  222. data/lib/samagotchi/worker_link.rb +3 -3
  223. data/lib/samagotchi/working_set_command.rb +3 -2
  224. metadata +36 -6
  225. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  226. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  227. data/lib/samagotchi/web/grants.rb +0 -575
  228. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  229. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The model: its names and refs, identity and notes, /model's switch (and
6
+ # the context it shows after), the profile (ModelBinding's, forwarded) and
7
+ # the loop it runs on. Writes @client, @default_model_name,
8
+ # @last_context_status, @chat_backend; reads @model_binding, @host_registry,
9
+ # @kernel, @metrics, @prompt_builder.
10
+ module ModelsPart
11
+ # The backend for the next turn: the loop the effective model's host speaks.
12
+ def backend(model = effective_model)
13
+ backend_for(model)
14
+ end
15
+
16
+ # The model as given (maybe an alias; HostRegistry#resolve applies it).
17
+ def effective_model_name = @model_binding.choice.name
18
+
19
+ # The model as typed at start or at the last /model.
20
+ def typed_model_name = @model_binding.choice.typed
21
+
22
+ # The resolved ref a session stores for the effective model (ModelRef#ref:
23
+ # the alias applied, "host:id" when it names a host).
24
+ def effective_model_ref
25
+ model_ref_for(effective_model_name)
26
+ end
27
+
28
+ # The effective model as the system prompt names it (SystemPrompt::ModelIdentity):
29
+ # the ref, its host, the overlay key and, for a local llama.cpp host
30
+ # only, the model its /props names when that differs. Never a new
31
+ # request: only the /props answer profile resolution already cached.
32
+ def model_identity
33
+ model = effective_model
34
+ entry = model.entry
35
+ host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
36
+ SystemPrompt::ModelIdentity.new(ref: model.ref, host: host, key: @model_binding.model_key,
37
+ served: model.prompt_served, fallback_key: @model_binding.fallback_model_key)
38
+ end
39
+
40
+ # The model notes for the effective model (ModelNotes): matched on its
41
+ # bare id and key, the session's mutes left out.
42
+ # @return [Array<ModelNotes::Note>]
43
+ def model_notes
44
+ ModelNotes.for(name: guardrail_model_name, key: @model_binding.model_key, fallback_key: @model_binding.fallback_model_key,
45
+ muted: @muted_memory_names)
46
+ end
47
+
48
+ # The model notes the session's prompt carried at its last build
49
+ # (Session#prompt_notes): a resumed session's or a woken worker's saved
50
+ # ones until this process builds its prompt; a fresh session (no build
51
+ # yet, no messages) has none saved, so the effective model's prompt
52
+ # would load them: the notes #model_notes names, as without a session.
53
+ # The three readers (/model, /stats, the web) share this.
54
+ # @return [Array<PromptNote>]
55
+ def prompt_notes
56
+ return model_notes.map { |note| PromptNote.from_note(note) } unless @session
57
+
58
+ saved = @session.prompt_notes
59
+ return saved.dup if !@prompt_builder.prompt_notes.nil? || !saved.empty? || @session.messages.to_a.any?
60
+
61
+ model_notes.map { |note| PromptNote.from_note(note) }
62
+ rescue StandardError => e
63
+ Log.warn(:model, "prompt_notes_failed", error: e.class.name, msg: e.message.to_s[0, 200])
64
+ []
65
+ end
66
+
67
+ # Records +notes+ (ModelNotes::Note) on the session as the ones its
68
+ # prompt carries; saved with it.
69
+ def record_prompt_notes(notes)
70
+ @session.prompt_notes = Array(notes).map { |note| PromptNote.from_note(note) } if @session && notes
71
+ end
72
+ private :record_prompt_notes
73
+
74
+ # A failed turn's one line (the error's #summary). A host's failure
75
+ # under an alias says what the alias became (HostRegistry#alias_note):
76
+ # "host main" alone doesn't tell --model splash's user it was splash.
77
+ # @return [String]
78
+ def failure_summary(error)
79
+ summary = error.respond_to?(:summary) ? error.summary : error.message
80
+ return summary unless error.is_a?(LLM::ProviderError) && error.host
81
+
82
+ note = @host_registry.alias_note(effective_model_name)
83
+ note && note.end_with?("on host #{error.host}") ? "#{summary} (#{note})" : summary
84
+ rescue StandardError => e
85
+ EffectiveModel::Unresolved.fallback(:alias_note_failed, effective_model_name, e, summary)
86
+ end
87
+
88
+ # +name+'s resolved ref (ModelRef#ref).
89
+ def model_ref_for(name)
90
+ @host_registry.model_ref(name).ref
91
+ end
92
+
93
+ # The ref #switch_model! would switch +model_name+ to, checked the way it
94
+ # checks one (UnknownHost, MissingModel), without switching.
95
+ # @return [String]
96
+ def model_switch_ref(model_name)
97
+ model_ref_for(ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries))
98
+ end
99
+
100
+ # Writes the effective model to +session+: the resolved ref, and the
101
+ # name as typed when that differs (model_typed: an alias, for the
102
+ # models: lookup after a resume).
103
+ def store_model!(session)
104
+ ref = effective_model_ref
105
+ session.model_name = ref
106
+ typed = typed_model_name
107
+ session.model_typed = typed.to_s == ref ? nil : typed
108
+ session
109
+ end
110
+
111
+ # The effective model's key (ModelOverlay.key_for its bare name): memory
112
+ # overlays, guardrail rules' models:.
113
+ # @return [String, nil]
114
+ def model_key = @model_binding.model_key
115
+
116
+ # The prompt profile for the effective model (see #profile_resolution).
117
+ # @return [ModelProfile]
118
+ def profile = profile_resolution.profile
119
+
120
+ # Which profile the effective model gets and where that came from,
121
+ # kept with the model choice it was resolved for
122
+ # (ModelBinding#profile_resolution).
123
+ # @return [ModelProfile::Resolution]
124
+ def profile_resolution = @model_binding.profile_resolution
125
+
126
+ # The model id sent for +full_ref+ (its alias applied, the host prefix off).
127
+ def bare_model_name(full_ref)
128
+ @host_registry.bare_name(full_ref)
129
+ end
130
+
131
+ # The memory overlay keys follow the model sent (ModelBinding#sync_model_key!).
132
+ def sync_model_key! = @model_binding.sync_model_key!
133
+ private :sync_model_key!
134
+
135
+ # The effective model's bare name for the guardrails' models: rules;
136
+ # nil without a model name.
137
+ def guardrail_model_name
138
+ model = effective_model_name
139
+ name = model && bare_model_name(model)
140
+ name.to_s.strip.empty? ? nil : name
141
+ end
142
+
143
+ # Point the kernel (and a chat backend) at the effective model's host
144
+ # (after /model, --model, resume).
145
+ def sync_kernel_client!(model = effective_model)
146
+ @client = model.client
147
+ @kernel.client = model.client if @kernel.client != model.client
148
+ backend_for(model)
149
+ end
150
+
151
+ # ── Model switching ────────────────────────────────────────────────────────
152
+
153
+ # @param typed [String, nil] the name the model was typed as when
154
+ # +model_name+ is a stored ref (a resumed session's model_typed)
155
+ # @return [String] the resolved ref the session stores (ModelRef#ref)
156
+ def switch_model!(model_name, persist_default: false, typed: nil)
157
+ # The alias is applied where the model is resolved (HostRegistry#resolve),
158
+ # once; checked here: an unknown host, an alias naming another host.
159
+ resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
160
+ choice = ModelChoice.new(name: resolved, typed: typed.to_s.strip.empty? ? model_name : typed.to_s.strip)
161
+ @model_binding.switch!(choice)
162
+ sync_model_key!
163
+ @prompt_builder.reset!
164
+ # The notes the new model's prompt loads at its next build, so /model,
165
+ # /stats and the web name them before the next turn does.
166
+ record_prompt_notes(model_notes) if @session
167
+ sync_kernel_client!
168
+ @client.invalidate_context_window!
169
+ @metrics.forget_model_reports!
170
+ # The last turn's ctx % was the old model's window's
171
+ # (#announce_switched_context! sends the new one's).
172
+ @last_context_status = nil
173
+ ref = effective_model_ref
174
+ if persist_default
175
+ ConfigFile.write_default_model!(ref)
176
+ @default_model_name = ref
177
+ end
178
+ ref
179
+ end
180
+
181
+ # After /model: how full the context is on the new model's window,
182
+ # before its first turn: the last turn's count (the old model's
183
+ # tokenizer's, replaced at the next generation) against the new model's
184
+ # window (EffectiveModel#context_window, ContextWindow.resolve: the
185
+ # running server first, one /props GET that a remote chat host is
186
+ # spared; else the host's cached model list; else the configured
187
+ # window) and the
188
+ # session's budget. Sent as a :context_status event (the web's ctx
189
+ # meter, the attached TUI's status row) with context_window_tokens, and
190
+ # kept for a client that joins later (#session_state_snapshot; the
191
+ # window in the metrics' context too, which a page counts from). A window
192
+ # that can't be resolved sends usage nil: the UIs clear the % until the
193
+ # next generation. Nothing before the session's first counted turn.
194
+ # +configured_only+: the configured window only, never asking a server
195
+ # (EffectiveModel#configured_context_window): a loaded session's (#session=).
196
+ # @return [Hash, nil] the event sent
197
+ def announce_switched_context!(configured_only: false)
198
+ used = @metrics.snapshot.dig(:context, :used_tokens)
199
+ return nil unless used.is_a?(Integer)
200
+
201
+ model = effective_model
202
+ window = configured_only ? model.configured_context_window : current_context_window(model)
203
+ display = window && saved_context_status({ used_tokens: used, window_tokens: window.tokens })
204
+ @last_context_status = display&.dup
205
+ @metrics.note_context_window(window.tokens, window.source) if window
206
+ usage = display && { estimated_pct: display[:est_pct], estimated_used_tokens: used, window_tokens: window.tokens,
207
+ window_source: window.source.to_s, source: "switch" }
208
+ event = { type: :context_status, usage: usage, bucket: display&.dig(:bucket),
209
+ context_window_tokens: window&.tokens, context_window_source: window&.source&.to_s }
210
+ @session_observer.notify(event)
211
+ event
212
+ rescue StandardError => e
213
+ Log.warn(:model, "switched_context_failed", error: e.class.name, msg: e.message.to_s[0, 200])
214
+ nil
215
+ end
216
+
217
+ # Whether the last turn that named its model ran on another one than
218
+ # the effective model; false before any (or when the ref can't be told).
219
+ def model_switched_since_last_turn?
220
+ last = @metrics.last_model_ref
221
+ !last.nil? && last != effective_model_ref
222
+ rescue StandardError => e
223
+ Log.warn(:model, "switch_check_failed", error: e.class.name, msg: e.message.to_s[0, 200])
224
+ false
225
+ end
226
+ private :model_switched_since_last_turn?
227
+
228
+ private
229
+
230
+ # Pick the loop for +model+ (EffectiveModel); the chat loop is pointed
231
+ # at its host's adapter (one per host, kept for its cached model list).
232
+ def backend_for(model)
233
+ return @native_backend unless model.chat?
234
+
235
+ @chat_backend_mutex.synchronize do
236
+ @chat_backend ||= LLM::ChatLoop.new(kernel: @kernel, stable_length: ->(text) { @prompt_builder.stable_length(text) })
237
+ @chat_backend.adapter = @host_registry.adapter_for(model.entry)
238
+ @chat_backend.session_id = session&.id
239
+ @chat_backend
240
+ end
241
+ end
242
+
243
+ # The window as +model+'s loop would see it (EffectiveModel#context_window).
244
+ def current_context_window(model) = model.context_window
245
+
246
+ # The effective model now, resolved on each call, never kept
247
+ # (SessionModelSettings#effective_model). Raises as HostRegistry#resolve does.
248
+ # @return [EffectiveModel]
249
+ def effective_model(choice = @model_binding.choice) = @model_settings.effective_model(choice)
250
+
251
+ # ── Prompt profile ─────────────────────────────────────────────────────────
252
+
253
+ # The profile's resolve, keep, apply and retry are ModelBinding's
254
+ # (#build_model_binding); these forward to it.
255
+ def resolve_profile(choice) = @model_binding.resolve_profile(choice)
256
+ def store_profile(choice, resolution) = @model_binding.store_profile(choice, resolution)
257
+ def apply_profile(resolution) = @model_binding.apply_profile(resolution)
258
+ def refresh_profile! = @model_binding.refresh_profile!
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The session's objectives: their view announced when it changed, Check
6
+ # this's side request, the model's proposals kept as cards until the turn
7
+ # is over, System 1's audit of them (ObjectivesAudit). Writes
8
+ # @objectives_announced, @objectives_audit; reads @objective_proposals
9
+ # under @objective_proposals_mutex, @session, @side_judgments.
10
+ module ObjectivesPart
11
+ # The session's objectives as the page draws them (Objectives.view), when
12
+ # that differs from the last announced: after an edit (its file written,
13
+ # pending), once a list note is in, at a turn's end (its moves) and after
14
+ # a rollback. A session with no list announces nothing. It only informs:
15
+ # a failure is logged, never the turn's.
16
+ def announce_objectives(session = @session)
17
+ return unless session
18
+
19
+ synchronize_events do
20
+ view = Objectives.view(session.id, session.messages, state_dir: session_state_dir, running: live_conversation&.dup)
21
+ next if view.nil? || view == @objectives_announced
22
+
23
+ @objectives_announced = view
24
+ announce({ type: :objectives_updated, session_id: session.id, **view })
25
+ end
26
+ rescue StandardError => e
27
+ Log.warn(:turn, "objectives_announce_failed", error: e.class.name, msg: e.message)
28
+ end
29
+
30
+ # Check this (ObjectivesCheck)'s one side request. Its usage is in the
31
+ # session's side line. Raises as ctx.ask_model's request does.
32
+ # @param request [Array<Hash>] chat messages
33
+ # @param model [String, nil] a configured model; nil: the session's
34
+ # @return [String] the answer
35
+ def ask_objective_check(request, model:)
36
+ ask_side_model(request, timeout: ObjectivesCheck::TIMEOUT_SECONDS, max_tokens: ObjectivesCheck::MAX_TOKENS,
37
+ cancel_controller: nil, model: model, source: ObjectivesCheck::SOURCE)
38
+ end
39
+
40
+ # A change the model proposes (the objectives tool), kept until the turn
41
+ # is over, as #context_choice's cards are: a card shown then is a quiet one, between turns, with its Apply kept
42
+ # over a restart (CardStore), not a running turn's that says it waits on the
43
+ # user. The same change twice in a turn is one card.
44
+ # @return [Boolean] false past Objectives::PROPOSALS_PER_TURN
45
+ def propose_objective(proposal)
46
+ @objective_proposals_mutex.synchronize do
47
+ next true if @objective_proposals.any? { |p| p.line == proposal.line }
48
+ next false if @objective_proposals.size >= Objectives::PROPOSALS_PER_TURN
49
+
50
+ @objective_proposals << proposal
51
+ true
52
+ end
53
+ end
54
+
55
+ def show_objective_proposals
56
+ drain_objective_proposals.each do |proposal|
57
+ id = "objectives-#{SecureRandom.hex(4)}"
58
+ show_card(source: Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
59
+ actions: [{ label: "Apply", command: proposal.action_line(id) },
60
+ { label: "Dismiss", command: "/objectives --card #{id} dismiss" }])
61
+ end
62
+ rescue StandardError => e
63
+ Log.warn(:turn, "objectives_proposal_failed", error: e.class.name, msg: e.message)
64
+ end
65
+ private :show_objective_proposals
66
+
67
+ # A turn that did not complete (canceled, failed) takes its proposals
68
+ # off the buffer without showing them: their Apply card would sit after a
69
+ # turn whose `objectives propose` call is not in the history (a rollback
70
+ # after Ctrl-C drops it), and leaving them in the buffer would show them
71
+ # at the end of the next turn.
72
+ def discard_objective_proposals(turn)
73
+ dropped = drain_objective_proposals
74
+ Log.info(:turn, "objectives_proposals_dropped", count: dropped.size, outcome: turn.outcome.to_s) unless dropped.empty?
75
+ end
76
+ private :discard_objective_proposals
77
+
78
+ def drain_objective_proposals
79
+ @objective_proposals_mutex.synchronize { @objective_proposals.slice!(0..) }
80
+ end
81
+ private :drain_objective_proposals
82
+
83
+ private
84
+
85
+ # System 1 on the objectives, in audit (ObjectivesAudit): the owner's turns
86
+ # only, and for a message a person's: a reminder's or a delegate's prompt isn't one.
87
+ def audit_objectives_incoming(turn)
88
+ return unless turn.prompt.is_a?(String) && turn.origin.human? && OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
89
+
90
+ objectives_audit.incoming(turn.session, turn.prompt)
91
+ end
92
+ private :audit_objectives_incoming
93
+
94
+ def audit_objectives_outgoing(turn, result)
95
+ return unless OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
96
+
97
+ objectives_audit.outgoing(turn.session, result.text)
98
+ end
99
+ private :audit_objectives_outgoing
100
+
101
+ # With saving off nothing would come of an ask, so none is made.
102
+ def objectives_audit
103
+ @objectives_audit ||= ObjectivesAudit.new(
104
+ side: @side_judgments, state_dir: -> { session_state_dir },
105
+ client_for: lambda do |judgment, session|
106
+ next nil unless system_one_runs.on?
107
+
108
+ system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: ObjectivesAudit::MODE, tag: :system_one),
109
+ judgment, session)
110
+ end
111
+ )
112
+ end
113
+ private :objectives_audit
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The session's participants: the running turn's author, a pair turn's
6
+ # stop check, who came or went, the system prompt's participants line and
7
+ # a participant's author fields. Writes @participant_stop,
8
+ # @participants_line; reads @participants, @participants_watch,
9
+ # @guardrail_wiring.
10
+ module ParticipantsPart
11
+ # The running turn's author when a participant wrote it, else nil
12
+ # (ctx.turn_author, the hooks' author).
13
+ # @return [String, nil]
14
+ def turn_author = turn_running? ? @guardrail_wiring.turn_author : nil
15
+
16
+ # Whether a participant's pair turn must stop, read before each of its
17
+ # generations (#guard_participant_spend): author ("p:<id>") → why (their
18
+ # link ended, or they passed their cap), nil while it may go on. The
19
+ # Worker sets it (Worker#participant_turn_stop); without one (a one-shot)
20
+ # nothing is checked mid-turn.
21
+ attr_writer :participant_stop
22
+
23
+ # Before each generation (KernelLoop#generation_guard): a pair turn whose
24
+ # participant's link ended, or whose spend (Engine#metrics: their
25
+ # finished turns, this one's finished generations, their side requests)
26
+ # passed their cap, stops on the plugins' stop path (cost-cap's,
27
+ # work-cap's), with its notice. A generation's own cost is known only
28
+ # once it ends, so a turn can pass the cap by its last generation. The
29
+ # turn-start check is the Worker's (Worker#participant_refusal); a
30
+ # review turn only reads, and keeps to it.
31
+ def guard_participant_spend
32
+ author = turn_author
33
+ return if author.nil? || @participant_stop.nil?
34
+ return unless @guardrail_wiring.turn_mode == ParticipantMode::PAIR
35
+
36
+ reason = @participant_stop.call(author) or return
37
+ hook_stop_turn(reason, PARTICIPANTS_HOOK)
38
+ end
39
+ private :guard_participant_spend
40
+
41
+ # A participant joined (invited) or left (revoked, expired) since the
42
+ # last look: :participants_changed to the observers (ParticipantsWatch;
43
+ # the worker looks on its idle tick). The first look announces nobody.
44
+ # @return [ParticipantsWatch::Change, nil]
45
+ def announce_participants
46
+ change = @participants_watch.changes or return nil
47
+
48
+ announce(change.to_event)
49
+ change
50
+ end
51
+
52
+ # Reads the session's participants at a turn's start; the first to join,
53
+ # the first invited to act (a pair participant: the line's words) and
54
+ # the last to leave rebuild the system prompt (its participants line),
55
+ # once each: a turn in between sends the same prefix.
56
+ def refresh_participants!(session)
57
+ line = @participants.refresh!(session).prompt_mode(session)
58
+ return if line == @participants_line
59
+
60
+ @participants_line = line
61
+ @prompt_builder.reset!
62
+ end
63
+ private :refresh_participants!
64
+
65
+ # The author fields a message +author+ wrote carries (Steer.author_fields:
66
+ # author:, and author_name: from participants.json), {} for the owner.
67
+ # The worker's drain names a participant's merged lines with it, and
68
+ # its running save a participant's prompt.
69
+ # @param author [String, nil] a turn's or a line's author (TurnOrigin#author)
70
+ # @param session [Session, nil] the session it is for (the turn's)
71
+ def author_fields(author, session: @session)
72
+ return {} unless Steer.participant_author?(author)
73
+
74
+ Steer.author_fields(author, @participants.name_for(author, session: session))
75
+ end
76
+
77
+ # A participant's opening message keeps the rules it was delivered under
78
+ # (ParticipantMode), stamped before any request carries it, so its header
79
+ # never changes when the session's mode does.
80
+ def participant_mode_fields(author)
81
+ mode = Steer.participant_author?(author) && @guardrail_wiring.turn_mode
82
+ mode ? { participant_mode: mode.to_s } : {}
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The Engine as the plugins' host: loading them (Plugin::Registries), their
6
+ # init tasks and staged tool sets (PluginTasks), Plugin::Host's view of
7
+ # the session, ctx.messages and ctx.http's cancel. Writes nothing of its
8
+ # own; reads @plugin_tasks, @extension_load, @hook_routes, @tools,
9
+ # @tool_index, @toolkit_binding, @prompt_builder, @session.
10
+ module PluginsPart
11
+ # Add a plugin's init task (Plugin::Api#init at commit); it starts with
12
+ # #start_init_tasks!.
13
+ def add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed: nil, &)
14
+ @plugin_tasks.add(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
15
+ quiet: quiet, timeout: timeout, failed: failed, &)
16
+ end
17
+
18
+ # Start the init tasks not started yet, each on its own thread
19
+ # (PluginTasks#start!).
20
+ def start_init_tasks!
21
+ @plugin_tasks.start!
22
+ end
23
+
24
+ # The running init tasks a UI shows (not the quiet ones), for a UI that
25
+ # joins while they run (Bridge#snapshot, with the event log held).
26
+ # @return [Array<Hash>] {bundle:, id:, label:}
27
+ def init_tasks
28
+ @plugin_tasks.running
29
+ end
30
+
31
+ # Wait for the running init tasks that provide tools (PluginTasks#await).
32
+ # @return [Boolean] whether it waited
33
+ def await_init_tasks(controller = nil, on_event = nil)
34
+ @plugin_tasks.await(controller, on_event)
35
+ end
36
+
37
+ # The init task this thread runs, or nil.
38
+ def current_init_task = @plugin_tasks.current
39
+ private :current_init_task
40
+
41
+ private
42
+
43
+ def load_plugins
44
+ host = plugin_host
45
+ registries = Plugin::Registries.new(
46
+ commands: @command_registry, tools: @tools, hooks: @hooks,
47
+ context_for: lambda { |bundle, settings, label|
48
+ Plugin::Context.new(bundle: bundle, label: label, settings: settings, host: host)
49
+ },
50
+ tools_changed: -> { tools_changed! },
51
+ services: @services,
52
+ stage_tools: ->(bundle, specs, context) { stage_tools(bundle, specs, context) },
53
+ init: lambda { |bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
54
+ add_init_task(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
55
+ quiet: quiet, timeout: timeout, failed: failed, &block)
56
+ },
57
+ toolkits: @toolkit_binding.plugin_kits,
58
+ tool_index: @tool_index,
59
+ peer_sources: @peer_sources,
60
+ prompt_sections: @prompt_sections,
61
+ prompt_changed: -> { prompt_changed! },
62
+ judgments: @judgments,
63
+ hook_failed: ->(hook, failed) { @hook_routes.plugin_hook_failed(hook, failed) }
64
+ )
65
+ # What plugins show while they load (an MCP server that didn't
66
+ # start) waits for the first turn, beside the load warnings: no UI
67
+ # is there yet, and the Engine isn't built.
68
+ @extension_load.load_plugins(registries, failures: @plugin_failures) do
69
+ warn_undeclared_judgments
70
+ warn_bundle_settings
71
+ end
72
+ end
73
+
74
+ # config.yml bundles: names and keys that do nothing (BundleSettingsCheck),
75
+ # held like the judgments' line, for the same reason. Logged by every
76
+ # Engine; shown by the first to find them (BundleSettingsCheck.unshown).
77
+ def warn_bundle_settings
78
+ lines = BundleSettingsCheck.warnings(@extension_load.bundle_settings)
79
+ lines.each { |line| Log.warn(:config, "bundle_setting_unknown", msg: line) }
80
+ BundleSettingsCheck.unshown(lines).each { |line| hook_notify(line, :warn, "config") }
81
+ end
82
+ private :warn_bundle_settings
83
+
84
+ # Held with the plugins' own notices, so every UI gets it: a worker's
85
+ # stderr goes nowhere.
86
+ def warn_undeclared_judgments
87
+ @judgments.undeclared.each do |name|
88
+ Log.warn(:config, "judgment_undeclared", judgment: name)
89
+ hook_notify("system_one.judgments.#{name}: no loaded bundle declares it, so it does nothing", :warn, "config")
90
+ end
91
+ end
92
+ private :warn_undeclared_judgments
93
+
94
+ # Keep a plugin's new tool set (chi.replace_tools, from any thread)
95
+ # for the turn thread (PluginTasks#stage_tools).
96
+ def stage_tools(bundle, specs, context)
97
+ @plugin_tasks.stage_tools(bundle, specs, context)
98
+ end
99
+ private :stage_tools
100
+
101
+ # Apply the staged tool sets, on the turn thread, before the turn's
102
+ # system prompt is built (PluginTasks#apply_staged_tools!). A changed
103
+ # set may change the chi group the search tool's description counts
104
+ # (a plugin tool the toolkit leaves out).
105
+ # @return [Boolean] whether the tools changed
106
+ def apply_staged_tools!
107
+ changed = @plugin_tasks.apply_staged_tools!
108
+ @tool_index.refresh! if changed
109
+ changed
110
+ end
111
+ public :apply_staged_tools!
112
+
113
+ # The tools changed (a plugin's chi.tools_changed!): the system prompts,
114
+ # which declare them, are built again on the next turn.
115
+ def tools_changed!
116
+ prompt_changed!
117
+ end
118
+
119
+ # A plugin's prompt section would give other text now
120
+ # (chi.prompt_changed!): the system prompts are built again at their
121
+ # next build (the next turn's start; a running turn keeps the prompt it
122
+ # started with). One cache restart: the plugin's command asked first
123
+ # (Plugin::Context#cache_confirm).
124
+ def prompt_changed!
125
+ @prompt_builder&.reset!
126
+ end
127
+ public :prompt_changed!
128
+
129
+ # What a Plugin::Context reads and calls: the session now, and the
130
+ # hook runtime's notify and ask_user.
131
+ def plugin_host
132
+ Plugin::Host.new(
133
+ session_id: -> { @session&.id },
134
+ cwd: -> { @session&.working_directory },
135
+ messages: -> { plugin_messages },
136
+ messages_partial: -> { turn_running? && @running_turn_messages.nil? },
137
+ turn_running: -> { turn_running? },
138
+ notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
139
+ ask_user: ->(question) { hook_ask_user(question) },
140
+ # An init task's is its own (a Ctrl-C ends a turn, not the task).
141
+ cancelled: lambda {
142
+ (task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
143
+ },
144
+ card: ->(**card) { show_card(**card) },
145
+ steer: ->(text, label) { steer(text, source: hook_source(label)) },
146
+ stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
147
+ stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
148
+ toolkit: -> { @toolkit_binding.name },
149
+ declared_tools: -> { toolkit_explained.declared.map(&:name) },
150
+ use_toolkit: ->(name) { use_toolkit(name) },
151
+ load_tools: ->(names, bundle) { load_tools(names, bundle) },
152
+ cancel_controller: ->(handler: false) { plugin_cancel_controller(handler: handler) },
153
+ ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
154
+ ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
155
+ model: model, source: source)
156
+ },
157
+ model_name: -> { effective_model_ref },
158
+ model_key: -> { @model_binding.model_key },
159
+ # The names a models: map looks the model up under; [] when it doesn't resolve.
160
+ model_names: lambda do
161
+ effective_model.names
162
+ rescue StandardError
163
+ []
164
+ end,
165
+ state_dir: -> { session_state_dir },
166
+ scratch: -> { @scratch },
167
+ setup: -> { SessionSetup.of(@session) },
168
+ # The files whose text the built system prompt holds; [] until a
169
+ # build and after a rebuild that drops them (SystemPrompt#prompt_files
170
+ # returns [] itself: the || keeps a nil builder safe).
171
+ prompt_files: -> { @prompt_builder&.prompt_files || [] },
172
+ frontend: -> { interface },
173
+ cost: -> { @metrics.cost },
174
+ usage: -> { @metrics.usage },
175
+ turn_author: -> { turn_author },
176
+ # A plugin's prompt section is in the stable part: a full re-read.
177
+ cache_confirm: ->(line, changed) { CacheConfirm.ask(line: line, cost: :full, changed: changed, engine: self) },
178
+ candidate_proposed: ->(_candidate) { judge_late_candidates },
179
+ judgments: -> { @judgments }
180
+ )
181
+ end
182
+
183
+ # The controller a plugin's ctx.http call without cancel: stops with
184
+ # (docs/plugins.md): an init task's own, else the running turn's for the
185
+ # turn's own work until the turn has ended (TurnState#own_controller); nil
186
+ # for an anytime command, a service or a thread of the plugin's.
187
+ def plugin_cancel_controller(handler:)
188
+ (task = current_init_task) ? task.cancel : @turn_state.own_controller(handler: handler)
189
+ end
190
+ private :plugin_cancel_controller
191
+
192
+ # A plugin's ctx.messages: the conversation without the system prompt
193
+ # (a session saved by an older chi's REPL may start with it), and,
194
+ # while a turn runs, the turn so far from the Bridge (plan O1). Read
195
+ # with the event log held, so a turn is in exactly one of the two.
196
+ def plugin_messages
197
+ synchronize_events do
198
+ messages = AnswerDisplay.strip_all(messages_checkpoint)
199
+ first = messages.first
200
+ messages = messages.drop(1) if first && first[:role].to_s == "system" && first[:kind].to_s.empty?
201
+ running = turn_running? && @running_turn_messages ? Array(@running_turn_messages.call) : []
202
+ messages + running
203
+ end
204
+ end
205
+ private :plugin_messages
206
+ end
207
+ end
208
+ end