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,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # How a turn ends after #end_turn's step: the normal ending and its
6
+ # after-turn hooks, a stop before the model, the release, what an ending
7
+ # keeps (TurnEndings, forwarded), last_turn, the answer's display.
8
+ # Writes @last_context_status; reads @turn_endings, @turn_state,
9
+ # @steer_cut, @hooks, @session_observer, @client.
10
+ module TurnEndPart
11
+ # How the turn ended, on the session (Session#last_turn): the caller's
12
+ # save puts it in the file the session hub watches. Public for the
13
+ # Worker's turn that failed before it began (no #end_turn ran).
14
+ # @param limit [Integer, nil] the turn ran out of iterations at this
15
+ # limit: "exhausted" => true, "limit" => N, so a wait with nobody to
16
+ # answer the continue offer says so (ReplyWait, ParentReport "limit")
17
+ # @param origin [TurnOrigin] the turn's (TurnOrigin::NONE for no sender)
18
+ # @param event [Hash] the turn's end event (#stop_facts)
19
+ def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
20
+ enqueued_id = origin.enqueued_id
21
+ session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
22
+ origin: origin.source, exhausted: limit ? true : nil, limit: limit,
23
+ enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
24
+ author: origin.participant_author,
25
+ **stop_facts(event))
26
+ end
27
+
28
+ # Why a turn stopped (TurnEndings#stop_facts).
29
+ def stop_facts(event) = @turn_endings.stop_facts(event)
30
+ private :stop_facts
31
+
32
+ def save_quietly(session)
33
+ session.save(state_dir: session_state_dir)
34
+ rescue StandardError => e
35
+ Log.warn(:turn, "turn_end_save_failed", error: e.class.name, msg: e.message.to_s[0, 200])
36
+ nil
37
+ end
38
+ private :save_quietly
39
+
40
+ # Keep what the after_turn hooks presented as the answer's `display`
41
+ # and the refs they attached as its `display_refs` (AnswerRefs, one
42
+ # replace for both), and tell the observers (:answer_display, the
43
+ # display or nil, and how many refs), after the turn_completed they
44
+ # already had: the web re-reads the answer then. A turn_completed
45
+ # with display_pending (after_turn hooks were about to run) always gets
46
+ # one, `display: nil` when nothing was presented, so the web holds the
47
+ # answer's pop until then and never swaps it after. Terminals are not
48
+ # sinks of it; they printed the answer already. The session is saved by
49
+ # the caller, as after every turn.
50
+ def store_answer_display(session, display, refs, pending: false)
51
+ synchronize_events do
52
+ # A plugin that kept event[:refs] and calls it later gets nil: the
53
+ # list is stored now (and the turn is over on every path).
54
+ refs.close!
55
+ messages = Array(session.messages)
56
+ changed = display.changed? && messages.last.equal?(display.target)
57
+ added = refs.added? && messages.last.equal?(refs.target)
58
+ if changed || added
59
+ merged = messages.last.dup
60
+ merged[AnswerDisplay::KEY] = display.text if changed
61
+ merged[AnswerRefs::KEY] = refs.to_a if added
62
+ replace_session_messages(session, messages[0...-1] + [merged])
63
+ event = { type: :answer_display, display: changed ? display.text : nil }
64
+ event[:refs] = refs.count if added
65
+ @session_observer.notify(event)
66
+ elsif pending
67
+ @session_observer.notify({ type: :answer_display, display: nil })
68
+ end
69
+ end
70
+ end
71
+ private :store_answer_display
72
+
73
+ # A turn stopped before the model was asked (#prepare_turn returned
74
+ # false): canceled with nothing kept, like a Ctrl-C there; the result
75
+ # has no conversation, so a UI's checkpoint stands.
76
+ def stopped_before_model(turn)
77
+ reason = turn.controller.reason
78
+ end_turn(turn, "canceled") do |seconds|
79
+ [nil, { type: :turn_canceled, cancellation_reason: reason, cancelled_by: cancelled_by(turn),
80
+ duration_ms: (seconds * 1000).round }]
81
+ end
82
+ LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
83
+ end
84
+ private :stopped_before_model
85
+
86
+ # Who stopped a turn a hook stopped or cut (the controller's detail: the
87
+ # bundle, or the hook's label), for the UIs' "stopped by loop-guard";
88
+ # nil for any other cancel.
89
+ def cancelled_by(turn) = turn.controller.stopped_by
90
+ private :cancelled_by
91
+
92
+ # The normal ending (an answer, an empty answer, a Stop, the iteration
93
+ # limit), then the after-turn hooks.
94
+ def complete_turn(turn, result)
95
+ canceled = result.canceled?
96
+ # after_turn hooks run below and may present the answer (the web
97
+ # holds its pop until it knows).
98
+ display_pending = !canceled && @hooks.any?(:after_turn)
99
+ end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
100
+ kept = kept_messages(turn, result, seconds)
101
+ if canceled
102
+ [kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason, cancelled_by: cancelled_by(turn),
103
+ duration_ms: (seconds * 1000).round }]
104
+ else
105
+ # For a client that attaches later (session_state_snapshot).
106
+ @last_context_status = result.context_status.dup if result.context_status
107
+ [kept, { type: :turn_completed, result: result, turn_summary: turn_summary(result),
108
+ display_pending: display_pending }]
109
+ end
110
+ end
111
+
112
+ # Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
113
+ # ends the turn in #run_turn without it), with a read-only
114
+ # copy of the conversation the turn stored, event[:answer] (the answer's
115
+ # text: the native loop's stored message still holds its thought),
116
+ # event[:present] for
117
+ # a display version of the answer (AnswerDisplay) and event[:refs]
118
+ # for refs on it (AnswerRefs).
119
+ session = turn.session
120
+ audit_objectives_outgoing(turn, result) unless canceled || result.exhausted?
121
+ display = AnswerDisplay.new(session.messages)
122
+ refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
123
+ after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
124
+ messages: hook_messages(session.messages),
125
+ answer: (result.text unless canceled || result.exhausted?) }
126
+ after_turn[:present] = display.presenter(after_turn)
127
+ after_turn[:refs] = refs.adder(after_turn)
128
+ @hooks.fire(:after_turn, after_turn)
129
+ store_answer_display(session, display, refs, pending: display_pending)
130
+
131
+ # Fire :session_end after the turn (turn-level lifecycle), as :after_turn
132
+ @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
133
+ end
134
+ private :complete_turn
135
+
136
+ # What each turn ending keeps and announces is TurnEndings'
137
+ # (#build_turn_endings); these forward to it.
138
+ def kept_messages(turn, result, seconds) = @turn_endings.kept_messages(turn, result, seconds)
139
+ private :kept_messages
140
+ def ctrl_c_messages(turn, interrupt, seconds) = @turn_endings.ctrl_c_messages(turn, interrupt, seconds)
141
+ private :ctrl_c_messages
142
+ def loop_conversation(error) = @turn_endings.loop_conversation(error)
143
+ private :loop_conversation
144
+ def failed_messages(turn, error) = @turn_endings.failed_messages(turn, error)
145
+ private :failed_messages
146
+ def failed_event(error, seconds) = @turn_endings.failed_event(error, seconds)
147
+ private :failed_event
148
+
149
+ # However the turn ended (#run_turn's ensure): the turn flag off, the
150
+ # probe cancel restored, the cancel controller, sink and steers cleared
151
+ # (a steer left is logged as dropped), activity recorded, the
152
+ # turn-scoped hooks cleared.
153
+ def release_turn(probe_cancel_before)
154
+ # A completed turn is activity: release the turn flag and advance the
155
+ # shared inactivity clock so the idle recap detector
156
+ # treats the just-finished turn as activity and re-arms its window.
157
+ left = @turn_state.finish!
158
+ @steer_cut.finished!
159
+ Client.swap_probe_cancel(probe_cancel_before)
160
+ # The next turn's window and served model are asked again: the cache
161
+ # (this host's, process-wide) is dropped here, at the turn's end,
162
+ # rather than at its start, so what this turn read (the profile's own
163
+ # probe included) is not asked for a second time within the turn. A
164
+ # restart with another -c between turns is still noticed: the next
165
+ # turn's first probe goes to the server.
166
+ @client.invalidate_context_window!
167
+ log_dropped_steers(left, "turn_ended")
168
+ record_activity
169
+ # Clear hooks so they remain turn-scoped and never leak into the next turn.
170
+ clear_hooks
171
+ end
172
+ private :release_turn
173
+
174
+ # JSON-safe digest of a finished turn for renderers (TurnEndings#turn_summary).
175
+ # @param result [LLM::ModelResult]
176
+ # @return [Hash]
177
+ def turn_summary(result) = @turn_endings.turn_summary(result)
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # What a turn's steps use before and around the model: its settings,
6
+ # images and vision, the model ref and resumed run start it generates
7
+ # with, the used memories it publishes, and the turn-end warm-up of the
8
+ # next one. Writes @next_turn_waiting; reads @model_settings, @kernel,
9
+ # @prompt_warmup, @thinking_notices, @turn_state.
10
+ module TurnHelpersPart
11
+ # The turn's settings for the kernel (LLM::TurnSettings): vision,
12
+ # sampling and thinking, the thinking resolved before the hooks run.
13
+ # The model name is added in #generate, after the hooks that may switch
14
+ # the model.
15
+ # One resolve for all of them: nothing runs between them that could
16
+ # change what a resolve gives.
17
+ def turn_settings(session)
18
+ model = effective_model
19
+ vision = turn_vision(session, model)
20
+ sampling = model.sampling
21
+ level = @model_settings.thinking_level(model)
22
+ @thinking_notices.turn_started(level, model.target)
23
+ LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: level,
24
+ reasoning_history: @model_settings.reasoning_history_level(model), model_name: nil,
25
+ window_setting: nil, llm_context: nil, price: nil)
26
+ end
27
+ private :turn_settings
28
+
29
+ # @return [PromptWarmup] the turn-end warm-up (specs, measurements)
30
+ attr_reader :prompt_warmup
31
+
32
+ # A caller's check that the next turn starts at once (input queued for
33
+ # it: a worker's inbox) or that none follows (a one-shot run): no
34
+ # warm-up then. nil: neither.
35
+ attr_accessor :next_turn_waiting
36
+
37
+ # After a completed turn, prefill the next turn's prompt up to its user
38
+ # message while the user reads (PromptWarmup, cache.warmup). Only on a
39
+ # local llama.cpp host on the native loop, and not when the next turn's
40
+ # start differs anyway or starts at once: a reminder due, a continue
41
+ # offer (the turn ran out of steps), a carried steer or queued input.
42
+ # Never fails the turn.
43
+ def warm_up_next_turn(turn, result)
44
+ return if result.canceled? || result.resumable?
45
+
46
+ model = effective_model
47
+ client = model.client
48
+ return unless warmup_target?(model, client)
49
+ return if reminders_due? || @turn_state.carried_steers? || @next_turn_waiting&.call
50
+
51
+ messages = ContextNote.with_system_head(turn.session.messages.dup, { role: "system", content: system_prompt(model) })
52
+ # Under the next turn's strategy (a /model switch may have changed it);
53
+ # the last turn's when it can't be resolved.
54
+ prompt, images = @kernel.warmup_prompt(messages, llm_context: @model_settings.turn_llm_context || @kernel.turn_settings&.llm_context)
55
+ return unless prompt
56
+
57
+ started = @prompt_warmup.start(client: client, prompt: prompt, model: @kernel.turn_settings.model_name,
58
+ slot: @kernel.last_slot, images: images)
59
+ @kernel.warmed_up! if started
60
+ rescue StandardError => e
61
+ Log.warn(:model, "warmup_skipped", error: e.class.name, msg: e.message.to_s[0, 200])
62
+ end
63
+ private :warm_up_next_turn
64
+
65
+ # Never a paid or remote host (cache.warmup auto means local only), nor
66
+ # the chat loop (Splash keeps its own cache; llama.cpp's chat path is
67
+ # not measured), nor a client that can't warm up (a spec's stub).
68
+ def warmup_target?(model, client)
69
+ return false if model.chat? || model.entry.remote?
70
+ return false unless client.respond_to?(:warm_up)
71
+
72
+ PromptWarmup.enabled?
73
+ end
74
+ private :warmup_target?
75
+
76
+ # The ref (host:model, as asked) a turn's generations ran on: the model
77
+ # #generate resolved, after the before_turn hooks (which may switch it).
78
+ # SessionMetrics keys its per-model segments on it. nil when it can't be
79
+ # named (the turn's record then has none).
80
+ def generation_model_ref(model)
81
+ model.ref
82
+ rescue StandardError => e
83
+ Log.warn(:turn, "model_ref_failed", error: e.class.name, msg: e.message.to_s[0, 200])
84
+ nil
85
+ end
86
+ private :generation_model_ref
87
+
88
+ # A step-limit Continue's resume_from, when it is inside the messages the
89
+ # run gets (a wrong one only drops reasoning: it is ignored, logged).
90
+ # Entries before it never move between the turns (#turn_messages adds
91
+ # at the tail, and the system head is replaced in place).
92
+ def resumed_run_start(turn, run_start)
93
+ start = turn.resume_from
94
+ return nil if start.nil?
95
+ return start if start.is_a?(Integer) && start.between?(0, run_start)
96
+
97
+ Log.warn(:turn, "resume_start_invalid", resume_from: start, length: run_start)
98
+ nil
99
+ end
100
+ private :resumed_run_start
101
+
102
+ # The turn's LLM context strategy and where it came from, in the turn
103
+ # log: when it runs a layer, or the session set it (none too).
104
+ def log_llm_context(explained)
105
+ return unless explained
106
+ return if explained.resolved.active_layers.empty? && explained.strategy.source != :session
107
+
108
+ summary = explained.summary
109
+ Log.info(:turn, "llm_context", **summary.slice(:strategy, :strategy_source, :apply, :apply_source, :budget_tokens,
110
+ :budget_source).compact)
111
+ end
112
+ private :log_llm_context
113
+
114
+ def publish_used_memories(session, on_event)
115
+ # Persist deduped used memories onto the session for Web + reload.
116
+ begin
117
+ session.used_memory_names = used_memory_names
118
+ rescue StandardError => e
119
+ Log.warn(:memory, "used_memories_store_failed", error: e.class.name, msg: e.message.to_s[0, 200])
120
+ end
121
+ # Notify live observers of the updated memory list (so yellow bar refreshes
122
+ # even without a tool_call event if the preload was the only addition).
123
+ # Only emit when there is something to report to avoid noisy event_count drift.
124
+ return unless used_memory_names.any?
125
+
126
+ begin
127
+ emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
128
+ rescue StandardError => e
129
+ Log.warn(:memory, "used_memories_emit_failed", error: e.class.name, msg: e.message.to_s[0, 200])
130
+ end
131
+ end
132
+ private :publish_used_memories
133
+
134
+ # [refs, nil] for a turn's images, or [[], error] when one can't be used
135
+ # (the turn then fails right after :turn_started).
136
+ def turn_image_refs(session, images)
137
+ return [[], nil] if Array(images).empty?
138
+
139
+ [ImageStore.resolve_all(Session.session_dir(session.id, state_dir: session_state_dir), images), nil]
140
+ rescue ImageStore::Error => e
141
+ [[], e]
142
+ end
143
+
144
+ # The turn's VisionContext: the session's images folder, and whether the
145
+ # effective model can see images, asked only when a request carries one.
146
+ def turn_vision(session, model = effective_model)
147
+ VisionContext.new(session_dir: Session.session_dir(session.id, state_dir: session_state_dir),
148
+ capability: -> { model.vision_support(profile) })
149
+ end
150
+
151
+ # A model known not to see images fails a turn with images up front.
152
+ def refuse_images!(vision)
153
+ return if vision.sendable?
154
+
155
+ host = effective_model.host_name
156
+ raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # Verify and Revise anchors at a turn's end: the opening's stamp, a Revise
6
+ # turn's draft checked, a Verify turn's quote checked against its source
7
+ # session and its verdict recorded. No ivars of its own: it reads the turn,
8
+ # and the sessions it loads through Engine methods.
9
+ module VerifyPart
10
+ # The turn's anchors, stamped on its opening message (the prompt, or a
11
+ # wake turn's note) in the messages it keeps: display metadata, never
12
+ # in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
13
+ # A Verify turn's claim however it ended; a Revise turn's draft only
14
+ # once it completed with an answer (#revision_target). Returns the
15
+ # stamped ReviseAnchor (the end event's revises), or nil.
16
+ def stamp_opening(messages, turn, outcome)
17
+ return unless messages.is_a?(Array)
18
+
19
+ revises = revision_target(messages, turn, outcome)
20
+ fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
21
+ return if fields.empty?
22
+
23
+ opening = messages.reverse.find do |message|
24
+ Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
25
+ end
26
+ return unless opening
27
+
28
+ opening.merge!(fields)
29
+ revises
30
+ end
31
+ private :stamp_opening
32
+
33
+ # The turn a Revise turn revised (D6: "this turn revised that one"):
34
+ # only a completed turn with an answer (AnswerTail) has revised
35
+ # anything. A draft no prompt in the messages carries is no target (a
36
+ # stale page, a rolled-back turn); one a stamped turn already revised
37
+ # is re-targeted to its stack's top, the newest version, which the
38
+ # person meant ("this draft").
39
+ def revision_target(messages, turn, outcome)
40
+ return unless turn.revises && outcome == "completed"
41
+
42
+ slice = AnswerTail.turn_slice(messages, turn.id)
43
+ return unless slice&.any? { |message| AnswerTail.answer?(message) }
44
+
45
+ target = turn.revises.turn_id
46
+ seen = [turn.id]
47
+ until seen.include?(target)
48
+ return reject_revise_anchor(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
49
+
50
+ seen << target
51
+ newer = messages.reverse.find do |message|
52
+ Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
53
+ end
54
+ return ReviseAnchor.new(turn_id: target) unless newer
55
+
56
+ target = (newer[:turn_id] || newer["turn_id"]).to_s
57
+ end
58
+ reject_revise_anchor(turn.revises, :cycle)
59
+ end
60
+ private :revision_target
61
+
62
+ def reject_revise_anchor(anchor, reason)
63
+ Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
64
+ nil
65
+ end
66
+ private :reject_revise_anchor
67
+
68
+ def verified_anchor(session, anchor)
69
+ return unless anchor
70
+
71
+ source = verify_anchor_source_session(session, anchor)
72
+ return unless source
73
+
74
+ turn = AnswerTail.turn_slice(Array(source.messages), anchor.turn_id)
75
+ return reject_verify_anchor(anchor, :turn_missing) unless turn
76
+
77
+ reply = turn.reverse.find { |message| AnswerTail.answer?(message) }
78
+ return reject_verify_anchor(anchor, :reply_missing) unless reply
79
+
80
+ quote_text = verify_comparison_text(anchor.quote)
81
+ quoted = !quote_text.empty? && verify_quote_in?(quote_text, (reply[:content] || reply["content"]).to_s, source)
82
+ return reject_verify_anchor(anchor, :quote_missing) unless quoted
83
+
84
+ anchor
85
+ end
86
+ private :verified_anchor
87
+
88
+ # Whether +quote_text+ is in +answer+ as the model wrote it, or as the
89
+ # pages show it: a page quotes its own text, which reads the session's
90
+ # folder relative, the home folder as ~ and a secret shape as
91
+ # [redacted] (a participant's page gets it so, Redact;
92
+ # the owner's page rewrites its quote the same way, claim_link.js
93
+ # normalizeClaim, so claim links match both pages). +source+ is the
94
+ # session whose folder that is.
95
+ def verify_quote_in?(quote_text, answer, source)
96
+ return true if verify_comparison_text(answer).include?(quote_text)
97
+
98
+ viewer = Redact::Viewer.new(cwd: source.working_directory)
99
+ verify_comparison_text(Redact.text(answer, viewer)).include?(quote_text)
100
+ end
101
+ private :verify_quote_in?
102
+
103
+ def verify_anchor_source_session(session, anchor)
104
+ return session unless session.verify_fresh
105
+
106
+ fresh = session.verify_fresh
107
+ return reject_verify_anchor(anchor, :fresh_anchor_mismatch) unless fresh.anchor == anchor
108
+ return reject_verify_anchor(anchor, :fresh_parent_missing) unless session.parent_id
109
+
110
+ Session.load(session.parent_id, state_dir: session_state_dir)
111
+ rescue ArgumentError, SystemCallError, IOError => e
112
+ Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason: :source_session_unavailable,
113
+ error: e.class.name)
114
+ nil
115
+ end
116
+ private :verify_anchor_source_session
117
+
118
+ def verify_comparison_text(text)
119
+ OutputFormatter.strip_markup(text)
120
+ .gsub(/!\[([^\]]*)\]\([^)]+\)/, '\1')
121
+ .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
122
+ .gsub(/^[ \t]*(?:\#{1,6}[ \t]+|>[ \t]?|[-+*][ \t]+|\d+[.)][ \t]+)/, "")
123
+ .gsub(/(\*\*|__|~~|`+)(.+?)\1/m, '\2')
124
+ .gsub(/(?<!\w)\*(\S(?:.*?\S)?)\*(?!\w)/m, '\1')
125
+ .gsub(/(?<!\w)_(\S(?:.*?\S)?)_(?!\w)/m, '\1')
126
+ .tr("|", " ") # a table row's cells; the page copies them without the pipes
127
+ .gsub(/\s+/, " ")
128
+ .strip
129
+ end
130
+ private :verify_comparison_text
131
+
132
+ def reject_verify_anchor(anchor, reason)
133
+ Log.warn(:turn, "verify_anchor_rejected", turn_id: anchor.turn_id, reason:)
134
+ nil
135
+ end
136
+ private :reject_verify_anchor
137
+
138
+ def record_verdict(turn, outcome)
139
+ return unless outcome == "completed" && turn.verify
140
+
141
+ answer = AnswerTail.find(turn.session.messages, turn_id: turn.id)
142
+ parsed = Verdict.parse(answer && (answer[:content] || answer["content"]))
143
+ return unless parsed
144
+
145
+ record = { anchor: turn.verify.to_h, verdict: parsed.verdict, reason: parsed.reason,
146
+ by: turn.origin.author, check_turn_id: turn.id, at: Time.now.iso8601(3) }
147
+ turn.session.verdicts = turn.session.verdicts + [record]
148
+ record
149
+ end
150
+ private :record_verdict
151
+ end
152
+ end
153
+ end