samagotchi 0.153.0 → 0.154.1

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 (226) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -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/bundles.md +9 -12
  8. data/docs/cli.md +82 -96
  9. data/docs/configuration.md +27 -26
  10. data/docs/context.md +3 -3
  11. data/docs/desktop.md +1 -1
  12. data/docs/guardrails.md +8 -10
  13. data/docs/hooks.md +9 -9
  14. data/docs/internals/attached-context.md +2 -2
  15. data/docs/internals/background-tasks.md +3 -4
  16. data/docs/internals/block-renderers.md +4 -0
  17. data/docs/internals/bridge.md +21 -8
  18. data/docs/internals/context-telemetry.md +1 -1
  19. data/docs/internals/engine.md +33 -7
  20. data/docs/internals/objectives.md +6 -5
  21. data/docs/internals/participants.md +7 -7
  22. data/docs/internals/prompt-caching.md +4 -2
  23. data/docs/internals/remote-sessions.md +7 -7
  24. data/docs/internals/web-frontend.md +2 -2
  25. data/docs/internals/web-push.md +1 -1
  26. data/docs/memory.md +2 -2
  27. data/docs/plugins.md +23 -28
  28. data/docs/sessions.md +24 -24
  29. data/docs/sub-agent.md +2 -2
  30. data/docs/system-one.md +1 -1
  31. data/docs/testing.md +31 -6
  32. data/lib/samagotchi/answer_text.rb +33 -0
  33. data/lib/samagotchi/bridge/card_places.rb +56 -7
  34. data/lib/samagotchi/bridge/card_store.rb +24 -9
  35. data/lib/samagotchi/bridge.rb +52 -32
  36. data/lib/samagotchi/bridge_client.rb +19 -14
  37. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  38. data/lib/samagotchi/broadcast/triage.rb +1 -1
  39. data/lib/samagotchi/broadcast_command.rb +5 -5
  40. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  42. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  43. data/lib/samagotchi/bundle_command.rb +2 -2
  44. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  45. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  46. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  47. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  48. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  50. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  51. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  52. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  53. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  54. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  55. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  56. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  57. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  58. data/lib/samagotchi/child_move.rb +3 -3
  59. data/lib/samagotchi/commands/registry.rb +6 -6
  60. data/lib/samagotchi/composer_route.rb +4 -5
  61. data/lib/samagotchi/config.rb +14 -12
  62. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  63. data/lib/samagotchi/context_candidates.rb +4 -3
  64. data/lib/samagotchi/continue_offer.rb +1 -1
  65. data/lib/samagotchi/debug_log.rb +1 -1
  66. data/lib/samagotchi/degrade.rb +75 -0
  67. data/lib/samagotchi/desktop/macos/ChiRunner.swift +3 -3
  68. data/lib/samagotchi/engine/cards.rb +87 -0
  69. data/lib/samagotchi/engine/context_judge.rb +138 -0
  70. data/lib/samagotchi/engine/events.rb +131 -0
  71. data/lib/samagotchi/engine/guardrails.rb +87 -0
  72. data/lib/samagotchi/engine/hooks.rb +119 -0
  73. data/lib/samagotchi/engine/messages.rb +124 -0
  74. data/lib/samagotchi/engine/model_settings.rb +127 -0
  75. data/lib/samagotchi/engine/models.rb +261 -0
  76. data/lib/samagotchi/engine/objectives.rb +116 -0
  77. data/lib/samagotchi/engine/participants.rb +86 -0
  78. data/lib/samagotchi/engine/plugins.rb +208 -0
  79. data/lib/samagotchi/engine/questions.rb +98 -0
  80. data/lib/samagotchi/engine/recap.rb +130 -0
  81. data/lib/samagotchi/engine/session.rb +131 -0
  82. data/lib/samagotchi/engine/side_requests.rb +135 -0
  83. data/lib/samagotchi/engine/steering.rb +134 -0
  84. data/lib/samagotchi/engine/toolkit.rb +269 -0
  85. data/lib/samagotchi/engine/turn_end.rb +180 -0
  86. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  87. data/lib/samagotchi/engine/verify.rb +153 -0
  88. data/lib/samagotchi/engine.rb +62 -2646
  89. data/lib/samagotchi/engine_snapshots.rb +9 -8
  90. data/lib/samagotchi/grants.rb +575 -0
  91. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  92. data/lib/samagotchi/guardrails/context.rb +2 -3
  93. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  94. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  95. data/lib/samagotchi/hook_routes.rb +3 -3
  96. data/lib/samagotchi/idle_recap.rb +7 -6
  97. data/lib/samagotchi/idle_reminders.rb +3 -4
  98. data/lib/samagotchi/iteration_limit.rb +2 -2
  99. data/lib/samagotchi/kernel_loop.rb +13 -13
  100. data/lib/samagotchi/launch_mode.rb +13 -32
  101. data/lib/samagotchi/level_setting_command.rb +3 -2
  102. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  103. data/lib/samagotchi/llm_context_command.rb +3 -2
  104. data/lib/samagotchi/log.rb +7 -7
  105. data/lib/samagotchi/log_line.rb +1 -1
  106. data/lib/samagotchi/log_path.rb +1 -1
  107. data/lib/samagotchi/note_command.rb +1 -1
  108. data/lib/samagotchi/note_delivery.rb +18 -8
  109. data/lib/samagotchi/objectives.rb +24 -3
  110. data/lib/samagotchi/objectives_check.rb +8 -27
  111. data/lib/samagotchi/objectives_command.rb +4 -69
  112. data/lib/samagotchi/objectives_edit.rb +87 -0
  113. data/lib/samagotchi/output_formatter.rb +7 -1
  114. data/lib/samagotchi/owner_lock.rb +7 -1
  115. data/lib/samagotchi/parent_answers_command.rb +5 -4
  116. data/lib/samagotchi/participant_exposure.rb +42 -0
  117. data/lib/samagotchi/participant_mode.rb +1 -1
  118. data/lib/samagotchi/participant_name.rb +1 -1
  119. data/lib/samagotchi/participants.rb +17 -17
  120. data/lib/samagotchi/participants_watch.rb +1 -1
  121. data/lib/samagotchi/plugin/api.rb +3 -2
  122. data/lib/samagotchi/plugin/context.rb +21 -7
  123. data/lib/samagotchi/plugin/peer.rb +2 -1
  124. data/lib/samagotchi/plugin/sessions.rb +6 -4
  125. data/lib/samagotchi/plugin_tasks.rb +1 -2
  126. data/lib/samagotchi/private_files.rb +29 -0
  127. data/lib/samagotchi/project_env.rb +2 -2
  128. data/lib/samagotchi/question_desk.rb +8 -7
  129. data/lib/samagotchi/recap_command.rb +2 -1
  130. data/lib/samagotchi/recap_setup.rb +1 -1
  131. data/lib/samagotchi/recap_store.rb +1 -1
  132. data/lib/samagotchi/redact.rb +86 -0
  133. data/lib/samagotchi/reminder_queue.rb +2 -2
  134. data/lib/samagotchi/remote_attach_command.rb +1 -1
  135. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  136. data/lib/samagotchi/review_start.rb +1 -1
  137. data/lib/samagotchi/run_options.rb +6 -6
  138. data/lib/samagotchi/self_report.rb +42 -39
  139. data/lib/samagotchi/send_command/busy.rb +1 -1
  140. data/lib/samagotchi/send_command.rb +4 -1
  141. data/lib/samagotchi/session.rb +11 -2
  142. data/lib/samagotchi/session_access/contract.rb +3 -3
  143. data/lib/samagotchi/session_access/local.rb +9 -9
  144. data/lib/samagotchi/session_access/remote.rb +17 -7
  145. data/lib/samagotchi/session_access.rb +4 -4
  146. data/lib/samagotchi/session_archive_command.rb +2 -2
  147. data/lib/samagotchi/session_chain.rb +8 -6
  148. data/lib/samagotchi/session_commands.rb +45 -48
  149. data/lib/samagotchi/session_continue.rb +1 -1
  150. data/lib/samagotchi/session_delete_command.rb +4 -4
  151. data/lib/samagotchi/session_manager.rb +67 -49
  152. data/lib/samagotchi/session_metrics.rb +2 -2
  153. data/lib/samagotchi/session_model_settings.rb +3 -2
  154. data/lib/samagotchi/session_participants.rb +1 -1
  155. data/lib/samagotchi/session_retention.rb +27 -7
  156. data/lib/samagotchi/sessions_command.rb +4 -4
  157. data/lib/samagotchi/share_command.rb +10 -10
  158. data/lib/samagotchi/steer.rb +13 -2
  159. data/lib/samagotchi/stream_event.rb +22 -0
  160. data/lib/samagotchi/system_prompt.rb +154 -145
  161. data/lib/samagotchi/task_watch.rb +1 -1
  162. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  163. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  164. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  165. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  166. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  167. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -11
  168. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  169. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  170. data/lib/samagotchi/terminal_ui/input_support.rb +17 -22
  171. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  172. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  173. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  174. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  175. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  176. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  177. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  178. data/lib/samagotchi/terminal_ui/reline_seam.rb +2 -12
  179. data/lib/samagotchi/terminal_ui/status_row.rb +6 -14
  180. data/lib/samagotchi/terminal_ui.rb +142 -1387
  181. data/lib/samagotchi/title_command.rb +2 -2
  182. data/lib/samagotchi/tool_runner.rb +15 -16
  183. data/lib/samagotchi/toolkit_command.rb +2 -1
  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/send_note.rb +2 -1
  189. data/lib/samagotchi/turn_endings.rb +2 -2
  190. data/lib/samagotchi/turn_flow.rb +11 -7
  191. data/lib/samagotchi/update_hint.rb +2 -2
  192. data/lib/samagotchi/version.rb +1 -1
  193. data/lib/samagotchi/web/app.rb +42 -51
  194. data/lib/samagotchi/web/message_display.rb +7 -0
  195. data/lib/samagotchi/web/participant_access.rb +2 -2
  196. data/lib/samagotchi/web/participant_review.rb +5 -5
  197. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  198. data/lib/samagotchi/web/participant_view.rb +2 -82
  199. data/lib/samagotchi/web/participants_admin.rb +1 -1
  200. data/lib/samagotchi/web/public/block_host.js +35 -8
  201. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  202. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  203. data/lib/samagotchi/web/public/cards_place.js +5 -3
  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 +1 -1
  207. data/lib/samagotchi/web/public/format.js +4 -3
  208. data/lib/samagotchi/web/public/turn_html.js +8 -4
  209. data/lib/samagotchi/web/push/store.rb +1 -1
  210. data/lib/samagotchi/web/session_access_routes.rb +13 -4
  211. data/lib/samagotchi/web/session_control.rb +12 -8
  212. data/lib/samagotchi/web/session_create.rb +1 -1
  213. data/lib/samagotchi/web/session_hub.rb +2 -2
  214. data/lib/samagotchi/web/session_show.rb +6 -2
  215. data/lib/samagotchi/web/session_summary.rb +1 -1
  216. data/lib/samagotchi/worker.rb +103 -9
  217. data/lib/samagotchi/worker_link.rb +3 -3
  218. data/lib/samagotchi/working_set_command.rb +3 -2
  219. data/lib/samagotchi.rb +0 -1
  220. metadata +31 -7
  221. data/lib/samagotchi/pending_input_queue.rb +0 -55
  222. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  223. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  224. data/lib/samagotchi/web/grants.rb +0 -575
  225. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  226. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The session messages API: out-of-turn edits to the current session's
6
+ # conversation (replace, append, a context note, rollback), its
7
+ # checkpoints and last answer, and the running turn's live conversation.
8
+ # Reads @session and @kernel; every edit goes through
9
+ # #replace_session_messages.
10
+ module MessagesPart
11
+ # The strategy the working set is built under now, as LLMContextView
12
+ # takes it: the running turn's, else the next turn's (#emit_working_set).
13
+ # /working-set's ops act under it.
14
+ # @return [Symbol, Array<Symbol>]
15
+ def working_set_strategy
16
+ working_set_llm_context&.strategy || LLMContextView::NONE
17
+ end
18
+
19
+ # Replace the current session's conversation, between turns (a
20
+ # /working-set op's copy: #clone_messages, then its edits).
21
+ # @param messages [Array<Hash>]
22
+ # @return [Array<Hash>] the session's messages
23
+ def replace_messages(messages)
24
+ raise ArgumentError, "no current session" unless @session
25
+
26
+ replace_session_messages(@session, messages)
27
+ end
28
+
29
+ # Add a context note to the session's conversation, between turns: a
30
+ # tail system message the next turn sees, announced as :context_added.
31
+ # The array is replaced, not mutated, and the append and the event
32
+ # happen under the event lock, so a joining UI sees both or neither.
33
+ # The caller saves the session.
34
+ # @param note [Hash] SessionInbox.read_note's shape
35
+ # @return [Hash, nil] the message, or nil when the conversation already
36
+ # holds this note (a note file re-read after a crash)
37
+ def add_context_note(session, note)
38
+ synchronize_events do
39
+ next nil if Array(session.messages).any? { |m| m[:note_id] == note[:note_id] }
40
+
41
+ message = ContextNote.message(**note)
42
+ entry = note[:objectives] && Objectives.entry(note[:objectives], before: Objectives.build(session.messages))
43
+ replace_session_messages(session, Array(session.messages) + [message])
44
+ announce({ type: :context_added, session_id: session.id, note_id: note[:note_id], source: note[:source],
45
+ label: ContextNote.label_of(message), from_session: note[:from_session],
46
+ from_cwd: note[:from_cwd], text: note[:text], context_source: note[:context_source],
47
+ created_at: note[:created_at], objectives: entry }.compact)
48
+ announce_objectives(session) if entry
49
+ objectives_audit.note(session, note)
50
+ message
51
+ end
52
+ end
53
+
54
+ # ── Session messages API ───────────────────────────────────────────────
55
+ #
56
+ # Out-of-turn edits to the current session's conversation (`!cmd` output,
57
+ # rollback after Ctrl-C). Each replaces the array rather than mutating it,
58
+ # so the recap's lock-free snapshot never sees a half-applied edit.
59
+
60
+ # The answer a UI shows last (AnswerTail), a copy of that one message
61
+ # only: walked back from the end, never a #messages_checkpoint of the
62
+ # whole conversation (the Bridge's GET tail). +turn_id+: that turn's
63
+ # answer (AnswerTail.find); unknown or nil: the newest.
64
+ # @return [Hash, nil]
65
+ def last_answer_message(turn_id: nil)
66
+ AnswerTail.find(@session&.messages, turn_id: turn_id)&.dup
67
+ end
68
+
69
+ # @return [Array<Hash>] a copy of the current session's messages to hand
70
+ # back to #rollback_to later
71
+ def messages_checkpoint
72
+ clone_messages(@session&.messages)
73
+ end
74
+
75
+ # The running turn's conversation as its loop builds it (both loops
76
+ # register it on the kernel's llm_context_turn): the same array the loop
77
+ # appends to, not a copy, so read it on the turn's thread (the pending
78
+ # input drain, at an iteration boundary: TurnCheckpoint), or from another
79
+ # only as a dup, read only (/objectives). nil when no
80
+ # turn runs (llm_context_turn is never cleared, and still holds the
81
+ # last turn's then).
82
+ # @return [Array<Hash>, nil]
83
+ def live_conversation
84
+ return nil unless turn_running? && @kernel.respond_to?(:llm_context_turn)
85
+
86
+ @kernel.llm_context_turn&.conversation
87
+ end
88
+
89
+ # Append messages to the current session's conversation.
90
+ # @param messages [Array<Hash>]
91
+ # @return [Array<Hash>] the session's messages
92
+ def append_messages(messages)
93
+ raise ArgumentError, "no current session" unless @session
94
+
95
+ replace_session_messages(@session, Array(@session.messages) + clone_messages(messages))
96
+ end
97
+
98
+ # Restore the current session's conversation to a #messages_checkpoint.
99
+ # Between turns the working set is announced when that changed it (a
100
+ # !rollback or a canceled continue dropping the turn's tool calls, the
101
+ # set emptied too), so the page doesn't keep the dropped items until
102
+ # the next request.
103
+ # @param checkpoint [Array<Hash>]
104
+ # @return [Array<Hash>] the session's messages
105
+ def rollback_to(checkpoint)
106
+ raise ArgumentError, "no current session" unless @session
107
+
108
+ messages = replace_session_messages(@session, clone_messages(checkpoint))
109
+ unless turn_running?
110
+ announce_working_set
111
+ announce_objectives
112
+ end
113
+ messages
114
+ end
115
+
116
+ # Clone a messages array (shallow dup of each element).
117
+ # @param messages [Array<Hash>]
118
+ # @return [Array<Hash>]
119
+ def clone_messages(messages)
120
+ Array(messages).map(&:dup)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The session's settings on its effective model (SessionModelSettings) and
6
+ # the UIs' snapshots (EngineSnapshots), forwarded: /model's and /stats'
7
+ # summaries, the overrides a session saves, what a turn reads. Writes
8
+ # @last_context_status; reads @model_settings, @engine_snapshots, @metrics.
9
+ module ModelSettingsPart
10
+ # The metrics for /stats, which may make one short /props GET
11
+ # (EngineSnapshots#stats_snapshot).
12
+ # @return [Hash] @metrics.snapshot, filled in
13
+ def stats_snapshot = @engine_snapshots.stats_snapshot
14
+
15
+ # ── The session's model settings (SessionModelSettings) ──────────────────
16
+
17
+ # The session's own thinking, reasoning_history and llm_context values
18
+ # over the effective model's, the summaries /model and /stats show, and
19
+ # the saved context's status: SessionModelSettings' (resolved on every
20
+ # read); these forward.
21
+ def saved_context_status(context, model: :current) = @model_settings.saved_context_status(context, model: model)
22
+ def llm_context_explained(own: :session) = @model_settings.llm_context_explained(own: own)
23
+ def llm_context_summary = @model_settings.llm_context_summary
24
+ def llm_context_override = @model_settings.llm_context_override
25
+ def chat_model? = @model_settings.chat_model?
26
+ def sampling_summary = @model_settings.sampling_summary
27
+ def thinking_summary = @model_settings.thinking_summary
28
+ def steer_cut_after_explained = @model_settings.steer_cut_after_explained
29
+ def steer_cut_after = @model_settings.steer_cut_after
30
+ def steer_cut_summary = @model_settings.steer_cut_summary
31
+ def thinking_explained(own: thinking_override) = @model_settings.thinking_explained(own: own)
32
+ def reasoning_history_summary = @model_settings.reasoning_history_summary
33
+ def reasoning_history_cache_cost(from, to) = @model_settings.reasoning_history_cache_cost(from, to)
34
+ def reasoning_history_override = @model_settings.reasoning_history_override
35
+ def thinking_override = @model_settings.thinking_override
36
+ def served_expected_by(served) = @model_settings.served_expected_by(served)
37
+ def served_expected?(served) = @model_settings.served_expected?(served)
38
+
39
+ def reasoning_history_explained(native: false, own: reasoning_history_override)
40
+ @model_settings.reasoning_history_explained(native: native, own: own)
41
+ end
42
+
43
+ # Set the session's own reasoning_history or thinking level (nil unsets
44
+ # it): SessionModelSettings'. The caller saves the session.
45
+ # @raise [ArgumentError] with no session yet
46
+ def reasoning_history_override=(level)
47
+ @model_settings.reasoning_history_override = level
48
+ end
49
+
50
+ def thinking_override=(level)
51
+ @model_settings.thinking_override = level
52
+ end
53
+
54
+ # Set the session's own llm_context values (an empty override unsets
55
+ # them); the next turn's start resolves them, never a running one (its
56
+ # TurnSettings are fixed). The caller saves the session.
57
+ # @raise [ArgumentError] with no session yet
58
+ def llm_context_override=(override)
59
+ raise ArgumentError, "no session yet" unless @session
60
+
61
+ SessionSetting::LLM_CONTEXT.set(@session, override)
62
+ # The status a later client reads counts against the new budget.
63
+ @last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
64
+ end
65
+
66
+ # Whether the session has a model reply (a cache to restart at all).
67
+ def history?
68
+ Array(@session&.messages).any? { |entry| entry[:role].to_s == "model" }
69
+ end
70
+
71
+ # The latest known context size used by a request, for a cache-restart
72
+ # confirmation; nil before any request reports a count.
73
+ # @return [Integer, nil]
74
+ def cache_restart_tokens
75
+ used = @metrics.snapshot.dig(:context, :used_tokens)
76
+ used if used.is_a?(Integer)
77
+ end
78
+
79
+ # Whether a cache restart can invalidate preserved thinking blocks.
80
+ def cache_restart_anthropic?
81
+ effective_model.entry&.api.to_s == "anthropic"
82
+ end
83
+
84
+ # What a thinking level change costs the effective model's prompt
85
+ # cache (Thinking.cache_cost): :full, :provider or :tail.
86
+ # @return [Symbol]
87
+ def thinking_cache_cost
88
+ effective_model.thinking_cache_cost { profile }
89
+ end
90
+
91
+ # The model the server serves for the current model, and the name asked
92
+ # for: what the last generation of that name reported, else llama.cpp's
93
+ # model_alias (/props, one short cached probe; not with probe: false),
94
+ # else [nil, nil].
95
+ # @return [Array(String, String), Array(nil, nil)]
96
+ def served_model(probe: true)
97
+ @engine_snapshots.served_model_for(@metrics.served_report, model: probe ? effective_model : nil)
98
+ end
99
+
100
+ # Read-only snapshot of the engine's view of the current session plus the
101
+ # live event sequence; cheap, never probes, never raises
102
+ # (EngineSnapshots#session_state_snapshot has its keys).
103
+ # @return [Hash]
104
+ def session_state_snapshot = @engine_snapshots.session_state_snapshot
105
+
106
+ # The effective model's sampling and thinking level as the next turn
107
+ # reads them (SessionModelSettings).
108
+ def turn_sampling = @model_settings.turn_sampling
109
+ def turn_thinking = @model_settings.turn_thinking
110
+
111
+ private
112
+
113
+ # The effective model's window_tokens and price, read per turn
114
+ # (SessionModelSettings).
115
+ def turn_window_setting = @model_settings.turn_window_setting
116
+ def turn_price = @model_settings.turn_price
117
+
118
+ # The snapshots' helpers are EngineSnapshots' (#build_engine_snapshots);
119
+ # these forward to it.
120
+ def served_model_for(...) = @engine_snapshots.served_model_for(...)
121
+ def kit_prompt_stats = @engine_snapshots.kit_prompt_stats
122
+
123
+ # +settings+' chips (EngineSnapshots#setting_chips).
124
+ def setting_chips(settings, model) = @engine_snapshots.setting_chips(settings, model)
125
+ end
126
+ end
127
+ end
@@ -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