samagotchi 0.153.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 (205) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -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 +80 -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 +3 -3
  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 +1 -1
  23. data/docs/internals/remote-sessions.md +7 -7
  24. data/docs/internals/web-push.md +1 -1
  25. data/docs/memory.md +2 -2
  26. data/docs/plugins.md +23 -28
  27. data/docs/sessions.md +24 -24
  28. data/docs/sub-agent.md +2 -2
  29. data/docs/system-one.md +1 -1
  30. data/docs/testing.md +16 -6
  31. data/lib/samagotchi/answer_text.rb +34 -0
  32. data/lib/samagotchi/bridge.rb +43 -32
  33. data/lib/samagotchi/bridge_client.rb +10 -9
  34. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  35. data/lib/samagotchi/broadcast/triage.rb +1 -1
  36. data/lib/samagotchi/broadcast_command.rb +5 -5
  37. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  38. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  39. data/lib/samagotchi/bundle_command.rb +2 -2
  40. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  41. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  42. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  44. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  45. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  46. data/lib/samagotchi/child_move.rb +3 -3
  47. data/lib/samagotchi/commands/registry.rb +6 -6
  48. data/lib/samagotchi/composer_route.rb +4 -5
  49. data/lib/samagotchi/config.rb +14 -12
  50. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  51. data/lib/samagotchi/context_candidates.rb +4 -3
  52. data/lib/samagotchi/continue_offer.rb +1 -1
  53. data/lib/samagotchi/debug_log.rb +1 -1
  54. data/lib/samagotchi/degrade.rb +75 -0
  55. data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
  56. data/lib/samagotchi/engine/cards.rb +87 -0
  57. data/lib/samagotchi/engine/context_judge.rb +138 -0
  58. data/lib/samagotchi/engine/events.rb +131 -0
  59. data/lib/samagotchi/engine/guardrails.rb +87 -0
  60. data/lib/samagotchi/engine/hooks.rb +119 -0
  61. data/lib/samagotchi/engine/messages.rb +124 -0
  62. data/lib/samagotchi/engine/model_settings.rb +127 -0
  63. data/lib/samagotchi/engine/models.rb +261 -0
  64. data/lib/samagotchi/engine/objectives.rb +116 -0
  65. data/lib/samagotchi/engine/participants.rb +86 -0
  66. data/lib/samagotchi/engine/plugins.rb +208 -0
  67. data/lib/samagotchi/engine/questions.rb +98 -0
  68. data/lib/samagotchi/engine/recap.rb +130 -0
  69. data/lib/samagotchi/engine/session.rb +131 -0
  70. data/lib/samagotchi/engine/side_requests.rb +135 -0
  71. data/lib/samagotchi/engine/steering.rb +134 -0
  72. data/lib/samagotchi/engine/toolkit.rb +269 -0
  73. data/lib/samagotchi/engine/turn_end.rb +180 -0
  74. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  75. data/lib/samagotchi/engine/verify.rb +153 -0
  76. data/lib/samagotchi/engine.rb +62 -2646
  77. data/lib/samagotchi/grants.rb +575 -0
  78. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  79. data/lib/samagotchi/guardrails/context.rb +2 -3
  80. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  81. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  82. data/lib/samagotchi/hook_routes.rb +3 -3
  83. data/lib/samagotchi/idle_recap.rb +4 -5
  84. data/lib/samagotchi/idle_reminders.rb +3 -4
  85. data/lib/samagotchi/iteration_limit.rb +2 -2
  86. data/lib/samagotchi/kernel_loop.rb +10 -11
  87. data/lib/samagotchi/launch_mode.rb +13 -32
  88. data/lib/samagotchi/level_setting_command.rb +3 -2
  89. data/lib/samagotchi/llm_context_command.rb +3 -2
  90. data/lib/samagotchi/log.rb +7 -7
  91. data/lib/samagotchi/log_line.rb +1 -1
  92. data/lib/samagotchi/log_path.rb +1 -1
  93. data/lib/samagotchi/note_command.rb +1 -1
  94. data/lib/samagotchi/note_delivery.rb +18 -8
  95. data/lib/samagotchi/objectives.rb +24 -3
  96. data/lib/samagotchi/objectives_check.rb +8 -27
  97. data/lib/samagotchi/objectives_command.rb +4 -69
  98. data/lib/samagotchi/objectives_edit.rb +87 -0
  99. data/lib/samagotchi/output_formatter.rb +7 -1
  100. data/lib/samagotchi/owner_lock.rb +7 -1
  101. data/lib/samagotchi/parent_answers_command.rb +5 -4
  102. data/lib/samagotchi/participant_exposure.rb +42 -0
  103. data/lib/samagotchi/participant_mode.rb +1 -1
  104. data/lib/samagotchi/participant_name.rb +1 -1
  105. data/lib/samagotchi/participants.rb +17 -17
  106. data/lib/samagotchi/participants_watch.rb +1 -1
  107. data/lib/samagotchi/pending_input_queue.rb +2 -2
  108. data/lib/samagotchi/plugin/api.rb +3 -2
  109. data/lib/samagotchi/plugin/context.rb +21 -7
  110. data/lib/samagotchi/plugin/peer.rb +2 -1
  111. data/lib/samagotchi/plugin/sessions.rb +6 -4
  112. data/lib/samagotchi/plugin_tasks.rb +1 -2
  113. data/lib/samagotchi/private_files.rb +29 -0
  114. data/lib/samagotchi/project_env.rb +2 -2
  115. data/lib/samagotchi/question_desk.rb +8 -7
  116. data/lib/samagotchi/recap_command.rb +2 -1
  117. data/lib/samagotchi/recap_setup.rb +1 -1
  118. data/lib/samagotchi/recap_store.rb +1 -1
  119. data/lib/samagotchi/redact.rb +86 -0
  120. data/lib/samagotchi/reminder_queue.rb +2 -2
  121. data/lib/samagotchi/remote_attach_command.rb +1 -1
  122. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  123. data/lib/samagotchi/review_start.rb +1 -1
  124. data/lib/samagotchi/run_options.rb +6 -6
  125. data/lib/samagotchi/self_report.rb +42 -39
  126. data/lib/samagotchi/send_command/busy.rb +1 -1
  127. data/lib/samagotchi/send_command.rb +4 -1
  128. data/lib/samagotchi/session.rb +11 -2
  129. data/lib/samagotchi/session_access/contract.rb +3 -3
  130. data/lib/samagotchi/session_access/local.rb +9 -9
  131. data/lib/samagotchi/session_access/remote.rb +17 -7
  132. data/lib/samagotchi/session_access.rb +4 -4
  133. data/lib/samagotchi/session_archive_command.rb +2 -2
  134. data/lib/samagotchi/session_chain.rb +8 -6
  135. data/lib/samagotchi/session_commands.rb +45 -48
  136. data/lib/samagotchi/session_continue.rb +1 -1
  137. data/lib/samagotchi/session_delete_command.rb +4 -4
  138. data/lib/samagotchi/session_manager.rb +59 -36
  139. data/lib/samagotchi/session_metrics.rb +2 -2
  140. data/lib/samagotchi/session_model_settings.rb +3 -2
  141. data/lib/samagotchi/session_participants.rb +1 -1
  142. data/lib/samagotchi/session_retention.rb +3 -3
  143. data/lib/samagotchi/sessions_command.rb +4 -4
  144. data/lib/samagotchi/share_command.rb +10 -10
  145. data/lib/samagotchi/steer.rb +1 -1
  146. data/lib/samagotchi/system_prompt.rb +102 -109
  147. data/lib/samagotchi/task_watch.rb +1 -1
  148. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  149. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  150. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  151. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  152. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  153. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
  154. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  155. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  156. data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
  157. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  158. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  159. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  160. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  161. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  162. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  163. data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
  164. data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
  165. data/lib/samagotchi/terminal_ui.rb +142 -1387
  166. data/lib/samagotchi/title_command.rb +2 -2
  167. data/lib/samagotchi/tool_runner.rb +14 -16
  168. data/lib/samagotchi/toolkit_command.rb +2 -1
  169. data/lib/samagotchi/tools/delegate.rb +3 -1
  170. data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
  171. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  172. data/lib/samagotchi/tools/objectives_tool.rb +2 -2
  173. data/lib/samagotchi/tools/send_note.rb +2 -1
  174. data/lib/samagotchi/turn_endings.rb +2 -2
  175. data/lib/samagotchi/turn_flow.rb +4 -5
  176. data/lib/samagotchi/update_hint.rb +2 -2
  177. data/lib/samagotchi/version.rb +1 -1
  178. data/lib/samagotchi/web/app.rb +39 -50
  179. data/lib/samagotchi/web/participant_access.rb +2 -2
  180. data/lib/samagotchi/web/participant_review.rb +5 -5
  181. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  182. data/lib/samagotchi/web/participant_view.rb +2 -82
  183. data/lib/samagotchi/web/participants_admin.rb +1 -1
  184. data/lib/samagotchi/web/public/block_host.js +35 -8
  185. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  186. data/lib/samagotchi/web/public/chains.js +2 -2
  187. data/lib/samagotchi/web/public/claim_link.js +4 -4
  188. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  189. data/lib/samagotchi/web/public/format.js +3 -3
  190. data/lib/samagotchi/web/push/store.rb +1 -1
  191. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  192. data/lib/samagotchi/web/session_control.rb +12 -8
  193. data/lib/samagotchi/web/session_create.rb +1 -1
  194. data/lib/samagotchi/web/session_hub.rb +2 -2
  195. data/lib/samagotchi/web/session_show.rb +2 -2
  196. data/lib/samagotchi/web/session_summary.rb +1 -1
  197. data/lib/samagotchi/worker.rb +100 -9
  198. data/lib/samagotchi/worker_link.rb +3 -3
  199. data/lib/samagotchi/working_set_command.rb +3 -2
  200. metadata +29 -6
  201. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  202. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  203. data/lib/samagotchi/web/grants.rb +0 -575
  204. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  205. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The tool guardrails (GuardrailWiring, forwarded): who answers an
6
+ # approval, the rules and approvals, their state dir, and the load
7
+ # warnings the first turn announces. Writes @state_dir,
8
+ # @guardrail_failures_announced; reads @guardrail_wiring,
9
+ # @guardrail_failures, @plugin_failures, @hook_routes.
10
+ module GuardrailsPart
11
+ # ── Guardrails ─────────────────────────────────────────────────────────────
12
+
13
+ # Who can answer an approval: :worker or :non_interactive (the
14
+ # default, so a bare Engine denies instead of waiting for nobody). Set
15
+ # by the host (TerminalUI, Worker).
16
+ def interface
17
+ @guardrail_wiring.interface
18
+ end
19
+
20
+ def interface=(value)
21
+ @guardrail_wiring.interface = value
22
+ end
23
+
24
+ def guardrail_state_dir=(state_dir)
25
+ # Also where list_sessions and send_note look for other sessions.
26
+ @state_dir = state_dir
27
+ @guardrail_wiring.state_dir = state_dir
28
+ end
29
+
30
+ # The YAML rules, read again when a file changed (GuardrailWiring#rules).
31
+ # @return [Guardrails::Rules]
32
+ def guardrail_rules
33
+ @guardrail_wiring.rules
34
+ end
35
+
36
+ # @return [Guardrails::LoadFailures]
37
+ attr_reader :guardrail_failures
38
+
39
+ # @return [Guardrails::Approvals]
40
+ def guardrail_approvals
41
+ @guardrail_wiring.approvals
42
+ end
43
+
44
+ # @return [Guardrails::LoadFailures] the plugins that failed to load
45
+ attr_reader :plugin_failures
46
+
47
+ # Once per Engine, on its first turn: what failed to load, so every UI
48
+ # (the attached TUI, the web, a one-shot's stderr) shows it: the guardrails, then the plugins
49
+ # (label: "plugins"; the UIs say guardrails without one), an orphaned
50
+ # project memory folder (label: "memory"), then the notices and cards plugins showed as they loaded.
51
+ def announce_guardrail_failures(on_event)
52
+ return if @guardrail_failures_announced
53
+
54
+ @guardrail_failures_announced = true
55
+ message = @guardrail_failures.message
56
+ emit_event(on_event, { type: :guardrail_warning, message: message }) if message
57
+ plugins = @plugin_failures.message
58
+ emit_event(on_event, { type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
59
+ memory = MemoryOrphans.message
60
+ emit_event(on_event, { type: :guardrail_warning, message: memory, label: "memory" }) if memory
61
+ @extension_load.held_events.each { |event| emit_event(on_event, event) }
62
+ end
63
+ private :announce_guardrail_failures
64
+
65
+ # The warning the first turn announced (nil before it, or with nothing
66
+ # failed), for a UI that joins later (Bridge#snapshot).
67
+ def guardrail_warning
68
+ @guardrail_failures.message if @guardrail_failures_announced
69
+ end
70
+
71
+ # The plugins' load warning the first turn announced, likewise, and what
72
+ # their failed chi.on handlers told (HookRoutes#plugin_hook_failed).
73
+ def plugin_warning
74
+ loaded = @plugin_failures.message if @guardrail_failures_announced
75
+ [loaded, @hook_routes.hook_failures_message].compact.join("; ").then { |text| text unless text.empty? }
76
+ end
77
+
78
+ # Ask the user to approve a call the gate voted `ask` on
79
+ # (GuardrailWiring#request_approval).
80
+ # @param verdict [Guardrails::Verdict]
81
+ # @return [Guardrails::Verdict]
82
+ def request_approval(verdict)
83
+ @guardrail_wiring.request_approval(verdict)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # Hooks and reminders: turn-scoped hooks (register, clear), the hook
6
+ # runtime's notify/ask/stop (HookRoutes, forwarded), the copy of the
7
+ # conversation a hook reads, and the due reminders. Reads @hooks,
8
+ # @hook_routes, @reminder_queue, @reminder_store.
9
+ module HooksPart
10
+ # @return [Array<String>] reminder names queued for a reminder turn
11
+ def due_reminder_names
12
+ @reminder_queue.pending_names
13
+ end
14
+
15
+ # Queue reminder names for a reminder turn (IdleReminders callback).
16
+ def note_due_reminders(names)
17
+ @reminder_queue.note_pending(names)
18
+ end
19
+
20
+ def clear_due_reminder_names!
21
+ @reminder_queue.clear_pending!
22
+ end
23
+
24
+ # ── Hooks API ──────────────────────────────────────────────────────────────
25
+
26
+ # Register a hook callback for a named lifecycle event.
27
+ #
28
+ # Hooks are turn-scoped: they are automatically cleared after each
29
+ # `run_turn` call so that a single turn's hooks do not leak into the next.
30
+ #
31
+ # A hook that raises tells the user once, as a bundle's does
32
+ # (HookRoutes#plugin_hook_failed); the registry logs each failure and goes on.
33
+ #
34
+ # @param name [Symbol] one of the hook names (see Hooks module)
35
+ # @param block [Proc] receives an event hash (may mutate in place)
36
+ # @return [void]
37
+ def register_hook(name, &block)
38
+ return @hooks.register(name) unless block
39
+
40
+ @hooks.register(name) do |event|
41
+ yield(event)
42
+ rescue StandardError => e
43
+ @hook_routes.plugin_hook_failed("#{Hooks::Registry::TURN_HOOK_LABEL} (#{name})", "#{e.class}: #{e.message}")
44
+ raise
45
+ end
46
+ end
47
+
48
+ # Unregister a previously registered hook.
49
+ # @param name [Symbol]
50
+ # @return [Boolean] true if it was removed, false if not found
51
+ def unregister_hook(name)
52
+ @hooks.unregister(name)
53
+ end
54
+
55
+ # Clear all registered hooks. Called automatically at the end of each
56
+ # `run_turn` to keep hooks turn-scoped.
57
+ # @return [void]
58
+ def clear_hooks
59
+ @hooks.clear_all
60
+ end
61
+
62
+ # ── Reminders API ────────────────────────────────────────────────────────────
63
+ #
64
+ # Each turn injects the reminders due then (#turn_messages,
65
+ # ReminderQueue#inject!). Between turns the idle tick (IdleReminders)
66
+ # finds them due and its callback queues their names
67
+ # (#note_due_reminders); the worker runs a reminder turn
68
+ # (a continue turn) for them.
69
+
70
+ # @return [Boolean] whether any reminder is due now (the store's view,
71
+ # which a turn would inject), regardless of the queued names
72
+ def reminders_due?
73
+ @reminder_queue.due?
74
+ end
75
+
76
+ # @return [ReminderStore] the reminder store for inspection
77
+ attr_reader :reminder_store
78
+
79
+ # The conversation as a hook may read it: a frozen array of copied
80
+ # messages, so a hook cannot change what the turn sends or stores.
81
+ def hook_messages(messages)
82
+ AnswerDisplay.strip_all(messages).map(&:dup).freeze
83
+ end
84
+ private :hook_messages
85
+
86
+ # ── The hook runtime (HookRoutes) ───────────────────────────────────────
87
+
88
+ # What a hook (Hooks::Runtime) and a plugin (Plugin::Host) can do beyond
89
+ # reading its event, routed to the UIs by HookRoutes; these forward.
90
+ def hook_runtime = @hook_routes.runtime
91
+ private :hook_runtime
92
+
93
+ def hook_source(hook) = @hook_routes.source(hook)
94
+ private :hook_source
95
+
96
+ # One line to the user (:hook_notice), HookRoutes#notify.
97
+ def hook_notify(text, level, hook, fallback_for: nil, extra: {})
98
+ @hook_routes.notify(text, level, hook, fallback_for: fallback_for, extra: extra)
99
+ end
100
+ private :hook_notify
101
+
102
+ def guardrail_notify(message) = @hook_routes.guardrail_notify(message)
103
+ private :guardrail_notify
104
+
105
+ # @param question [HookQuestion]
106
+ # @return [Hash, nil] {selected:, freeform:, selected_indices:}
107
+ def hook_ask_user(question) = @hook_routes.ask_user(question)
108
+ private :hook_ask_user
109
+
110
+ # @return [Boolean] true when a running turn was cancelled now
111
+ def hook_stop_turn(reason, hook) = @hook_routes.stop_turn(reason, hook)
112
+ private :hook_stop_turn
113
+
114
+ # @return [Boolean] true when a streaming generation was cut now
115
+ def hook_stop_generation(reason, hook) = @hook_routes.stop_generation(reason, hook)
116
+ private :hook_stop_generation
117
+ end
118
+ end
119
+ end
@@ -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