samagotchi 0.152.0 → 0.154.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +2 -3
  4. data/bin/chi +23 -24
  5. data/docs/architecture.md +30 -33
  6. data/docs/broadcast.md +5 -5
  7. data/docs/bundle-authoring.md +1 -1
  8. data/docs/bundles.md +93 -12
  9. data/docs/cli.md +83 -99
  10. data/docs/configuration.md +46 -40
  11. data/docs/context.md +3 -3
  12. data/docs/desktop.md +1 -1
  13. data/docs/guardrails.md +15 -15
  14. data/docs/hooks.md +13 -12
  15. data/docs/internals/anthropic.md +3 -3
  16. data/docs/internals/attached-context.md +5 -2
  17. data/docs/internals/background-tasks.md +3 -4
  18. data/docs/internals/block-renderers.md +4 -0
  19. data/docs/internals/bridge.md +3 -3
  20. data/docs/internals/context-telemetry.md +1 -1
  21. data/docs/internals/engine.md +42 -12
  22. data/docs/internals/objectives.md +6 -5
  23. data/docs/internals/participants.md +8 -8
  24. data/docs/internals/prompt-caching.md +8 -4
  25. data/docs/internals/remote-sessions.md +7 -7
  26. data/docs/internals/web-frontend.md +20 -17
  27. data/docs/internals/web-push.md +1 -1
  28. data/docs/memory.md +2 -2
  29. data/docs/plugins.md +52 -43
  30. data/docs/releasing.md +1 -1
  31. data/docs/sessions.md +25 -25
  32. data/docs/sub-agent.md +2 -2
  33. data/docs/system-one.md +1 -1
  34. data/docs/testing.md +18 -8
  35. data/lib/samagotchi/answer_text.rb +34 -0
  36. data/lib/samagotchi/bridge.rb +43 -32
  37. data/lib/samagotchi/bridge_client.rb +10 -9
  38. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  39. data/lib/samagotchi/broadcast/triage.rb +1 -1
  40. data/lib/samagotchi/broadcast_command.rb +5 -5
  41. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  42. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  43. data/lib/samagotchi/bundle_command.rb +2 -2
  44. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  45. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  46. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  48. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  49. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  50. data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
  51. data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
  52. data/lib/samagotchi/child_move.rb +3 -3
  53. data/lib/samagotchi/commands/registry.rb +6 -6
  54. data/lib/samagotchi/composer_route.rb +4 -5
  55. data/lib/samagotchi/config.rb +14 -12
  56. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  57. data/lib/samagotchi/context_candidates.rb +4 -3
  58. data/lib/samagotchi/continue_offer.rb +1 -1
  59. data/lib/samagotchi/debug_log.rb +1 -1
  60. data/lib/samagotchi/degrade.rb +75 -0
  61. data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
  62. data/lib/samagotchi/engine/cards.rb +87 -0
  63. data/lib/samagotchi/engine/context_judge.rb +138 -0
  64. data/lib/samagotchi/engine/events.rb +131 -0
  65. data/lib/samagotchi/engine/guardrails.rb +87 -0
  66. data/lib/samagotchi/engine/hooks.rb +119 -0
  67. data/lib/samagotchi/engine/messages.rb +124 -0
  68. data/lib/samagotchi/engine/model_settings.rb +127 -0
  69. data/lib/samagotchi/engine/models.rb +261 -0
  70. data/lib/samagotchi/engine/objectives.rb +116 -0
  71. data/lib/samagotchi/engine/participants.rb +86 -0
  72. data/lib/samagotchi/engine/plugins.rb +208 -0
  73. data/lib/samagotchi/engine/questions.rb +98 -0
  74. data/lib/samagotchi/engine/recap.rb +130 -0
  75. data/lib/samagotchi/engine/session.rb +131 -0
  76. data/lib/samagotchi/engine/side_requests.rb +135 -0
  77. data/lib/samagotchi/engine/steering.rb +134 -0
  78. data/lib/samagotchi/engine/toolkit.rb +269 -0
  79. data/lib/samagotchi/engine/turn_end.rb +180 -0
  80. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  81. data/lib/samagotchi/engine/verify.rb +153 -0
  82. data/lib/samagotchi/engine.rb +63 -2642
  83. data/lib/samagotchi/grants.rb +575 -0
  84. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  85. data/lib/samagotchi/guardrails/context.rb +2 -3
  86. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  87. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  88. data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
  89. data/lib/samagotchi/hook_routes.rb +3 -3
  90. data/lib/samagotchi/idle_recap.rb +4 -5
  91. data/lib/samagotchi/idle_reminders.rb +3 -4
  92. data/lib/samagotchi/iteration_limit.rb +2 -2
  93. data/lib/samagotchi/kernel_loop.rb +10 -11
  94. data/lib/samagotchi/launch_mode.rb +13 -32
  95. data/lib/samagotchi/level_setting_command.rb +3 -2
  96. data/lib/samagotchi/llm_context_command.rb +3 -2
  97. data/lib/samagotchi/llm_context_stale.rb +2 -2
  98. data/lib/samagotchi/log.rb +7 -7
  99. data/lib/samagotchi/log_line.rb +1 -1
  100. data/lib/samagotchi/log_path.rb +1 -1
  101. data/lib/samagotchi/message_parts.rb +3 -2
  102. data/lib/samagotchi/note_command.rb +1 -1
  103. data/lib/samagotchi/note_delivery.rb +18 -8
  104. data/lib/samagotchi/objectives.rb +24 -3
  105. data/lib/samagotchi/objectives_check.rb +8 -27
  106. data/lib/samagotchi/objectives_command.rb +4 -69
  107. data/lib/samagotchi/objectives_edit.rb +87 -0
  108. data/lib/samagotchi/output_formatter.rb +7 -1
  109. data/lib/samagotchi/owner_lock.rb +7 -1
  110. data/lib/samagotchi/parent_answers_command.rb +5 -4
  111. data/lib/samagotchi/participant_exposure.rb +42 -0
  112. data/lib/samagotchi/participant_mode.rb +1 -1
  113. data/lib/samagotchi/participant_name.rb +1 -1
  114. data/lib/samagotchi/participants.rb +17 -17
  115. data/lib/samagotchi/participants_watch.rb +1 -1
  116. data/lib/samagotchi/pending_input_queue.rb +2 -2
  117. data/lib/samagotchi/plugin/api.rb +3 -2
  118. data/lib/samagotchi/plugin/context.rb +21 -7
  119. data/lib/samagotchi/plugin/peer.rb +2 -1
  120. data/lib/samagotchi/plugin/sessions.rb +6 -4
  121. data/lib/samagotchi/plugin_tasks.rb +1 -2
  122. data/lib/samagotchi/private_files.rb +29 -0
  123. data/lib/samagotchi/project_env.rb +2 -2
  124. data/lib/samagotchi/question_desk.rb +8 -7
  125. data/lib/samagotchi/recap_command.rb +2 -1
  126. data/lib/samagotchi/recap_setup.rb +1 -1
  127. data/lib/samagotchi/recap_store.rb +1 -1
  128. data/lib/samagotchi/redact.rb +86 -0
  129. data/lib/samagotchi/reminder_queue.rb +2 -2
  130. data/lib/samagotchi/remote_attach_command.rb +1 -1
  131. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  132. data/lib/samagotchi/review_start.rb +1 -1
  133. data/lib/samagotchi/run_options.rb +6 -6
  134. data/lib/samagotchi/self_report.rb +42 -39
  135. data/lib/samagotchi/send_command/busy.rb +1 -1
  136. data/lib/samagotchi/send_command.rb +4 -1
  137. data/lib/samagotchi/session.rb +11 -2
  138. data/lib/samagotchi/session_access/contract.rb +3 -3
  139. data/lib/samagotchi/session_access/local.rb +9 -9
  140. data/lib/samagotchi/session_access/remote.rb +17 -7
  141. data/lib/samagotchi/session_access.rb +4 -4
  142. data/lib/samagotchi/session_archive_command.rb +2 -2
  143. data/lib/samagotchi/session_chain.rb +8 -6
  144. data/lib/samagotchi/session_commands.rb +45 -48
  145. data/lib/samagotchi/session_continue.rb +1 -1
  146. data/lib/samagotchi/session_delete_command.rb +4 -4
  147. data/lib/samagotchi/session_manager.rb +59 -36
  148. data/lib/samagotchi/session_metrics.rb +2 -2
  149. data/lib/samagotchi/session_model_settings.rb +3 -2
  150. data/lib/samagotchi/session_participants.rb +1 -1
  151. data/lib/samagotchi/session_retention.rb +3 -3
  152. data/lib/samagotchi/sessions_command.rb +4 -4
  153. data/lib/samagotchi/share_command.rb +10 -10
  154. data/lib/samagotchi/steer.rb +1 -1
  155. data/lib/samagotchi/system_prompt.rb +102 -109
  156. data/lib/samagotchi/task_watch.rb +1 -1
  157. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  158. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  159. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  160. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  161. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  162. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
  163. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  164. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  165. data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
  166. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  167. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  168. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  169. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  170. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  171. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  172. data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
  173. data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
  174. data/lib/samagotchi/terminal_ui.rb +142 -1387
  175. data/lib/samagotchi/title_command.rb +2 -2
  176. data/lib/samagotchi/tool_runner.rb +14 -16
  177. data/lib/samagotchi/toolkit.rb +8 -5
  178. data/lib/samagotchi/toolkit_binding.rb +30 -5
  179. data/lib/samagotchi/toolkit_command.rb +2 -1
  180. data/lib/samagotchi/toolkit_report_command.rb +9 -5
  181. data/lib/samagotchi/toolkit_suggest.rb +12 -6
  182. data/lib/samagotchi/tools/builtin_calls.rb +14 -10
  183. data/lib/samagotchi/tools/deferred_tools.rb +2 -2
  184. data/lib/samagotchi/tools/delegate.rb +3 -1
  185. data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
  186. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  187. data/lib/samagotchi/tools/objectives_tool.rb +2 -2
  188. data/lib/samagotchi/tools/registry.rb +44 -20
  189. data/lib/samagotchi/tools/send_note.rb +2 -1
  190. data/lib/samagotchi/tools/tool_index.rb +27 -21
  191. data/lib/samagotchi/turn_endings.rb +2 -2
  192. data/lib/samagotchi/turn_flow.rb +4 -5
  193. data/lib/samagotchi/update_hint.rb +2 -2
  194. data/lib/samagotchi/version.rb +1 -1
  195. data/lib/samagotchi/web/app.rb +39 -50
  196. data/lib/samagotchi/web/participant_access.rb +2 -2
  197. data/lib/samagotchi/web/participant_review.rb +5 -5
  198. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  199. data/lib/samagotchi/web/participant_view.rb +2 -82
  200. data/lib/samagotchi/web/participants_admin.rb +1 -1
  201. data/lib/samagotchi/web/public/app.js +61 -357
  202. data/lib/samagotchi/web/public/block_host.js +35 -8
  203. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  204. data/lib/samagotchi/web/public/chains.js +2 -2
  205. data/lib/samagotchi/web/public/claim_link.js +4 -4
  206. data/lib/samagotchi/web/public/composer_mode.js +110 -0
  207. data/lib/samagotchi/web/public/format.js +3 -3
  208. data/lib/samagotchi/web/public/owner_claim.js +81 -0
  209. data/lib/samagotchi/web/public/owner_verify.js +112 -0
  210. data/lib/samagotchi/web/public/session_fields.js +3 -3
  211. data/lib/samagotchi/web/public/strip_grip.js +3 -3
  212. data/lib/samagotchi/web/public/strip_hidden.js +65 -0
  213. data/lib/samagotchi/web/public/version_notices.js +132 -0
  214. data/lib/samagotchi/web/push/store.rb +1 -1
  215. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  216. data/lib/samagotchi/web/session_control.rb +12 -8
  217. data/lib/samagotchi/web/session_create.rb +1 -1
  218. data/lib/samagotchi/web/session_hub.rb +2 -2
  219. data/lib/samagotchi/web/session_show.rb +2 -2
  220. data/lib/samagotchi/web/session_summary.rb +1 -1
  221. data/lib/samagotchi/worker.rb +106 -9
  222. data/lib/samagotchi/worker_link.rb +3 -3
  223. data/lib/samagotchi/working_set_command.rb +3 -2
  224. metadata +36 -6
  225. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  226. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  227. data/lib/samagotchi/web/grants.rb +0 -575
  228. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  229. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # Ask-user-question: the model's question (its :question_asked hooks),
6
+ # and opening, posting, answering and cancelling questions
7
+ # (QuestionDesk, forwarded). Reads @question_desk, @hooks, @kernel,
8
+ # @session.
9
+ module QuestionsPart
10
+ # ── Ask-user-question (structured qualification) ──────────────────────────
11
+
12
+ # @return [PendingQuestion, nil] the question pending now
13
+ def pending_question
14
+ @question_desk.pending
15
+ end
16
+
17
+ # Ask the model's question (ask_user_question): the kernel's
18
+ # question_handler, called on the turn thread with the payload
19
+ # Tools::AskUserQuestion.validate made (QuestionDesk#request), after
20
+ # the :question_asked hooks (QuestionAsked) gave the step its refs; a
21
+ # --non-interactive run has no one to ask, so they don't fire there.
22
+ # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
23
+ # @return [String] normalized answer JSON
24
+ def request_question(payload)
25
+ payload = question_asked(QuestionAsked::KIND_QUESTION, payload) unless interface == :non_interactive
26
+ @question_desk.request(payload)
27
+ end
28
+
29
+ # The :question_asked hooks for a question of the model's about to
30
+ # open: the fields with the asking step's refs (QuestionAsked.fire).
31
+ # @return [Hash]
32
+ def question_asked(kind, fields)
33
+ QuestionAsked.fire(hooks: @hooks, conversation: live_conversation, kind: kind, fields: fields,
34
+ cwd: @session&.working_directory, synchronize: ->(&block) { synchronize_events(&block) },
35
+ visible: ->(content) { question_step_text(content) })
36
+ end
37
+ private :question_asked
38
+
39
+ # A step's text as the asking slot shows it: the native loop's raw
40
+ # response without its thoughts and tool-call bodies (a chat step has
41
+ # neither), then the markup's tokens (OutputFormatter.strip_markup).
42
+ def question_step_text(content)
43
+ parser = @kernel.parser
44
+ OutputFormatter.strip_markup(parser.strip_tool_calls(parser.strip_thought(content)))
45
+ end
46
+ private :question_step_text
47
+
48
+ # Open a question for the UIs and wait for its answer
49
+ # (QuestionDesk#open_question: BLOCKS until answered or cancelled).
50
+ # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
51
+ # (as a Hash or as keywords)
52
+ # @param watch [#call, nil] closes the question with the reason it returns
53
+ # @return [QuestionAnswer, Hash, String] the answer, or {error:, …}, or a
54
+ # sync handler's text
55
+ def open_question(fields = nil, watch: nil, **more)
56
+ @question_desk.open_question({ **fields.to_h, **more }, watch: watch)
57
+ end
58
+
59
+ # Publish a standing question that blocks nobody (QuestionDesk#post: a
60
+ # worker's step-limit question).
61
+ # @return [PendingQuestion, nil] the pending question, nil while another is open
62
+ def post_question(fields, on_answer:, on_superseded_close: nil)
63
+ @question_desk.post(fields, on_answer: on_answer, on_superseded_close: on_superseded_close)
64
+ end
65
+
66
+ # Withdraw the standing question (QuestionDesk#withdraw).
67
+ # @return [Boolean]
68
+ def withdraw_question(reason, id: nil)
69
+ @question_desk.withdraw(reason, id: id)
70
+ end
71
+
72
+ # Answer the pending question (called from UI thread; QuestionDesk#answer).
73
+ # @return [QuestionAnswer]
74
+ def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
75
+ @question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id,
76
+ parent_agent: parent_agent)
77
+ end
78
+
79
+ # Mark the pending question as relayed, or clear the mark
80
+ # (QuestionDesk#annotate).
81
+ # @return [Boolean]
82
+ def annotate_question(id, relayed_to:, reason: nil)
83
+ @question_desk.annotate(id, relayed_to: relayed_to, reason: reason)
84
+ end
85
+
86
+ def set_question_sync_handler(&block)
87
+ @question_desk.sync_handler = block
88
+ end
89
+
90
+ # Cancel the pending question (QuestionDesk#cancel).
91
+ # @return [Boolean] whether it was cancelled
92
+ # @raise [QuestionDesk::NotDismissable] +id+ names a standing question
93
+ def cancel_question(reason = "user", id: nil)
94
+ @question_desk.cancel(reason, id: id)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The idle recap (IdleRecap) and the shared idle scheduler: the recap job
6
+ # rebuilt under the bound session's own recap values, /recap's override,
7
+ # the saved recap. Writes @recap, @recap_awaiting_continue; reads
8
+ # @recap_built_for, @idle_scheduler, @session.
9
+ module RecapPart
10
+ # Snapshot the current session messages as a JSON string for the idle
11
+ # recap. The array is never mutated in place (a turn or a note replaces
12
+ # it), so the reference read here is a consistent snapshot; a dup'd
13
+ # copy is serialized. Never mutates session.messages.
14
+ # @return [String] JSON array of the messages
15
+ def messages_json_for_recap
16
+ snapshot = @session&.messages
17
+ return "[]" if snapshot.nil?
18
+
19
+ JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
20
+ end
21
+
22
+ # Emit a :recap_ready event (additive slot) carrying the generated recap
23
+ # and the generation id an observer uses to reject an invalidated recap.
24
+ # +covered+ counts the session messages it summarizes; +created_at+ is
25
+ # the saved recap's (UTC ISO; the UIs show its local time).
26
+ def emit_recap(recap:, generation:, covered: nil, created_at: nil)
27
+ event = { type: :recap_ready, recap: recap, generation: generation, covered: covered }
28
+ event[:created_at] = created_at if created_at
29
+ @session_observer.notify(event)
30
+ end
31
+
32
+ # @return [IdleRecap, nil] the idle recap detector, or nil when disabled
33
+ attr_reader :recap
34
+
35
+ # Whether a continue offer waits (the Worker's or the one-shot's TurnFlow),
36
+ # for the recap job: this one and any rebuilt one (IdleRecap#awaiting_continue=).
37
+ def recap_awaiting_continue=(callable)
38
+ @recap_awaiting_continue = callable
39
+ @recap&.awaiting_continue = callable
40
+ end
41
+
42
+ # The bound session's own recap.* values ({} for none).
43
+ def session_recap_values
44
+ (@session&.settings&.core || {}).select { |key, _| key.start_with?("recap.") }
45
+ end
46
+ private :session_recap_values
47
+
48
+ # A new recap job when the bound session's own recap values differ from
49
+ # the ones the current job was built under (none at start): the old one
50
+ # is dropped (an attempt in flight goes stale) and the scheduler swaps
51
+ # it, keeping its one thread. Binding the same session again changes
52
+ # nothing.
53
+ def rebuild_recap!
54
+ return if session_recap_values == @recap_built_for
55
+
56
+ old = @recap
57
+ old&.invalidate!
58
+ @recap = build_recap
59
+ @idle_scheduler&.replace_job(old, @recap)
60
+ end
61
+
62
+ # /recap on|off|default: the bound session's own recap.enabled (true,
63
+ # false; nil removes it, so config.yml's applies again), and the recap
64
+ # job rebuilt under it at once. It doesn't save: the command says
65
+ # changed: [:recap] and its host saves (Worker#run_command).
66
+ # @param enabled [Boolean, nil]
67
+ # @raise [ArgumentError] no session is bound
68
+ def recap_override=(enabled)
69
+ raise ArgumentError, "no session" unless @session
70
+
71
+ core = @session.settings.core.dup
72
+ enabled.nil? ? core.delete(RECAP_ENABLED_KEY) : core[RECAP_ENABLED_KEY] = enabled
73
+ @session.settings = @session.settings.with(core: core)
74
+ rebuild_recap!
75
+ end
76
+
77
+ # The bound session's own recap.enabled (/recap on|off, --set), or nil.
78
+ # @return [Boolean, nil]
79
+ def recap_override = @session&.settings&.core&.fetch(RECAP_ENABLED_KEY, nil)
80
+
81
+ # Write the recap now, before the session is left (IdleRecap#write_now).
82
+ # @return [String, nil] the recap written, nil when none was (recap off,
83
+ # nothing new, an error, the timeout)
84
+ def write_recap_now(on_start: nil)
85
+ @recap&.write_now(on_start: on_start)
86
+ end
87
+
88
+ # The longest #write_recap_now waits (recap.timeout), nil when no recap
89
+ # is set up.
90
+ # @return [Float, nil]
91
+ def recap_timeout = @recap&.timeout
92
+
93
+ # Ask for a recap now (/recap), without waiting for the idle window.
94
+ # @return [Symbol] IdleRecap#request_now's answer, or :off
95
+ def request_recap
96
+ @recap ? @recap.request_now : :off
97
+ end
98
+
99
+ # The recap saved with the current session (recap.json), and how many
100
+ # user turns came after it (0: it is current).
101
+ # @return [Hash, nil] {text:, covered:, turns_since:, created_at:}
102
+ def saved_recap
103
+ state = @recap&.state
104
+ return nil unless state
105
+
106
+ messages = @session&.messages || []
107
+ covered = state[:covered].to_i
108
+ turns_since = Array(messages).drop(covered).count { |m| Steer.turn_opening?(m) }
109
+ { text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
110
+ end
111
+
112
+ # Start the shared idle scheduler (reminders + optional recap). The recap
113
+ # job is only registered when recap is configured; the reminders job is
114
+ # always present. SessionManager workers call it so reminders can
115
+ # trigger turns; a one-shot never starts it and polls nothing.
116
+ def start_idle
117
+ @idle_scheduler&.start
118
+ self
119
+ end
120
+
121
+ # Stop the shared idle scheduler (a worker as it leaves). +join+: wait for a tick under way (IdleScheduler#stop); a
122
+ # caller holding #synchronize_events passes false, since a recap's emit
123
+ # in that tick waits for the same lock.
124
+ def stop_idle(join: true)
125
+ @idle_scheduler&.stop(join: join)
126
+ self
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The bound session and its prompt: #session= (its recap, metrics and
6
+ # memories), its state dir, the relay's view, and the system prompt with
7
+ # the memory names it holds. Writes @session, @session_state_dir,
8
+ # @running_turn_messages; reads @prompt_builder, @metrics, @used_memories,
9
+ # @question_desk, @state_dir.
10
+ module SessionPart
11
+ # @return [Array<String>] deduped used memory names (thread-safe copy)
12
+ def used_memory_names
13
+ @used_memories.names
14
+ end
15
+
16
+ # @return [Array<String>] the memories hidden from this session (normalized names)
17
+ def muted_memory_names
18
+ @muted_memory_names.dup
19
+ end
20
+
21
+ # @return [Array<String>] the names the session preloads (config baseline
22
+ # + --memory, minus mutes), known before the prompt is built, unlike
23
+ # #activated_memory_names
24
+ def preloaded_memory_names
25
+ @prompt_builder.preloaded_memory_names
26
+ end
27
+
28
+ # Where sessions live (a session's images/ are under it); a worker sets
29
+ # its own.
30
+ attr_writer :session_state_dir
31
+
32
+ # The metrics load the session's saved records from its state dir, once.
33
+ def bind_metrics(session)
34
+ return unless session&.id
35
+
36
+ @metrics.state_dir = session_state_dir
37
+ @metrics.session_id = session.id
38
+ end
39
+
40
+ def session_state_dir = @session_state_dir || Session.default_state_dir
41
+
42
+ # The running turn's metrics so far, saved as a turn a worker died in
43
+ # would leave them (SessionMetrics#persist's running_turn): a worker's
44
+ # turn checkpoint (TurnCheckpoint) calls it when it writes.
45
+ # @return [Boolean] whether they were saved
46
+ def checkpoint_metrics = @metrics.persist(state_dir: session_state_dir, running_turn: true)
47
+
48
+ # What a Bridge adds to a plugin's ctx.messages while a turn runs (the
49
+ # turn so far, as messages); nil without a Bridge (a one-shot).
50
+ attr_writer :running_turn_messages
51
+
52
+ # The system prompt for a target's loop (default: the effective model's):
53
+ # the chat loop's leaves out the raw-prompt tool text, since its tools go
54
+ # as schemas. Built once per loop (and again after a model switch) so the
55
+ # prompt prefix, and the server's KV cache for it, stay stable.
56
+ # @param model [EffectiveModel, nil]
57
+ # @return [String]
58
+ # The native prompt depends on the thinking level too (Gemma's token):
59
+ # a level change builds it again. A build that
60
+ # misses the cache names the model and the level through its own
61
+ # lambdas (#model_identity, #turn_thinking), which resolve again.
62
+ def system_prompt(model = nil)
63
+ model ||= effective_model
64
+ chat = model.chat?
65
+ level = chat ? nil : @model_settings.thinking_level(model)
66
+ prompt = @prompt_builder.build(chat: chat, thinking: level, layers: model.llm_context_layers(@model_settings.llm_context_session_args))
67
+ # The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
68
+ @metrics.memory_index = @prompt_builder.memory_index
69
+ # The model notes it carries: the session records them (/stats, /model, the web).
70
+ record_prompt_notes(@prompt_builder.prompt_notes)
71
+ prompt
72
+ end
73
+
74
+ # The base prompt (specs, plugins' declarations).
75
+ def assist_system_prompt(chat: false)
76
+ @prompt_builder.base(chat: chat)
77
+ end
78
+
79
+ # The --memory names activated while building the prompt; the TerminalUI
80
+ # mirrors them into its status line.
81
+ def activated_memory_names
82
+ @prompt_builder.activated_memory_names
83
+ end
84
+
85
+ # @return [Session] current session (Engine owns create/resume)
86
+ attr_reader :session
87
+
88
+ # This worker's relays of its delegates' approvals (memory only).
89
+ # @return [RelayDesk]
90
+ attr_reader :relay_desk
91
+
92
+ # The relay's view of this Engine, or nil when it can't host a relay: a
93
+ # worker's Engine only (its Bridge is what a child verifies against;
94
+ # --non-interactive has no one to ask), never with a sync handler (no
95
+ # wait loop, no watch).
96
+ # @return [RelayPeer, nil]
97
+ def relay_peer
98
+ return nil unless interface == :worker && !@question_desk.sync_handler?
99
+
100
+ RelayPeer.new(self)
101
+ end
102
+
103
+ # @return [String] the state dir holding this Engine's sessions
104
+ def peer_state_dir = @state_dir
105
+
106
+ # Set the current session outside a turn (the worker and the one-shot
107
+ # do, before the first turn, so the messages API and recap see it)
108
+ def session=(session)
109
+ @session = session
110
+ # One session per worker or one-shot process: its records carry this sid.
111
+ Log.session_id = session.id if session&.id
112
+ # Its own recap values (chi --set recap.enabled=false) rebuild the job.
113
+ rebuild_recap!
114
+ # A woken worker's /stats and status line count the turns before it.
115
+ bind_metrics(session)
116
+ @used_memories.absorb(session)
117
+ # Loaded on another model than its last turn ran on (a --resume
118
+ # one-shot's --model, a worker woken after a /model with no turn since):
119
+ # the saved ctx % was that model's window's. Drop it and count it as
120
+ # /model does, on a configured window only: this runs before a
121
+ # worker's Bridge serves, so no /props GET or model list here (a slow
122
+ # or hung host would hold up the worker's start). No configured
123
+ # window: no % (ctx ?) until the first generation reports one.
124
+ return unless model_switched_since_last_turn?
125
+
126
+ @metrics.forget_model_reports!
127
+ announce_switched_context!(configured_only: true)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # Side requests: a plugin's ctx.ask_model and Check this on their own
6
+ # IdleClient, their usage in the session's side line, and System 1's
7
+ # recording of judged asks. Writes @side_client/@side_client_key (under
8
+ # @side_client_mutex); reads @metrics, @session, @host_registry.
9
+ module SideRequestsPart
10
+ # Count one side request (a recap, a plugin's ctx.ask_model: IdleClient's
11
+ # on_usage) in the session's side line, apart from its turns' totals.
12
+ # Folded and announced as :side_usage under the event lock, so the side
13
+ # blocks reach the UIs in fold order (the lock order event lock →
14
+ # metrics mutex is the existing one); then, between turns, saved to
15
+ # analytics.json (a running turn's end or checkpoint saves it), unless
16
+ # the session is gone (no session file: a late recap thread must not
17
+ # recreate a deleted session's folder; the folder itself may not exist
18
+ # yet before a first turn). Safe from any thread: the recap's, a
19
+ # plugin's, the turn's.
20
+ # A side request counts against the participant who caused it
21
+ # (#side_author), its usage's own author when it names one.
22
+ # @param usage [SideUsage]
23
+ # @return [void]
24
+ def note_side_usage(usage)
25
+ usage = usage.with(author: side_author(usage.kind)) if usage.author.nil?
26
+ synchronize_events do
27
+ block = @metrics.add_side(usage)
28
+ @session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
29
+ end
30
+ sid = @metrics.session_id
31
+ return if sid.nil? || turn_running?
32
+ return unless File.exist?(Session.session_file(sid, state_dir: session_state_dir))
33
+
34
+ @metrics.persist(state_dir: session_state_dir)
35
+ end
36
+
37
+ # Who caused a side request of +kind+ now: the running turn's author (a
38
+ # plugin's ctx.ask_model in it), the last turn's for the idle recap
39
+ # (it recaps that turn); nil, the owner's, for anything else (an owner
40
+ # command's ask_model between turns).
41
+ # @return [String, nil]
42
+ def side_author(kind)
43
+ # Check this is only ever the owner's, whichever turn runs as it ends.
44
+ return nil if kind.to_s == ObjectivesCheck::SOURCE
45
+ return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
46
+
47
+ @guardrail_wiring.turn_author
48
+ end
49
+
50
+ private
51
+
52
+ # +client+ (nil for none) saving its asks as +judgment+'s runs. Anything
53
+ # going wrong here leaves the client as it was: a run is never worth a
54
+ # judgment.
55
+ #
56
+ # A scratch session's asks aren't saved; a participant's turn is never
57
+ # judged, so it never comes here.
58
+ # @param mode [String, nil] the use's mode, for its runs (Recording.wrap)
59
+ def system_one_recording(client, judgment, session, mode: nil)
60
+ return client if client.nil? || session&.scratch
61
+
62
+ context = SystemOne::Recording::Context.of(session, session_model: session && effective_model_name)
63
+ SystemOne::Recording.wrap(client, judgment: judgment, runs: system_one_runs, context: context, recorded_mode: mode)
64
+ rescue StandardError => e
65
+ Log.warn(:system_one, "system_one_recording_failed", error: e.class.name, msg: e.message.to_s[0, 200])
66
+ client
67
+ end
68
+ private :system_one_recording
69
+
70
+ # Where this Engine's runs are saved; read each time, as the settings are.
71
+ def system_one_runs = SystemOne::Runs.for_sessions_dir(session_state_dir)
72
+ private :system_one_runs
73
+
74
+ # ctx.ask_model's request: the session's current model on its host, as
75
+ # a turn resolves them (a /model switch counts), or a configured +model+
76
+ # (a host:id ref, an alias, a bare id one of a host's models), through its own
77
+ # IdleClient, so it shares nothing with the turn's backend. The client
78
+ # is kept while the target and timeout stay (what it learned, e.g. a
79
+ # refused thinking-off, holds for the next request); the session's id
80
+ # goes with the request. Its usage counts in the session's side line
81
+ # (#note_side_usage) under +source+, the asking bundle's name.
82
+ # @return [String] the answer
83
+ def ask_side_model(request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil)
84
+ target = model.nil? ? session_model_recap_target : configured_side_target(model)
85
+ client = side_model_client(target, timeout)
86
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
87
+ answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id,
88
+ kind: source.to_s.empty? ? "ask" : source.to_s, on_usage: method(:note_side_usage))
89
+ Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
90
+ ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
91
+ answer.text
92
+ end
93
+ private :ask_side_model
94
+
95
+ # The target of ctx.ask_model(model:): +model+ as a turn resolves it,
96
+ # once it is known to name something configured. IdleTarget.of_model
97
+ # never raises (an unknown host falls to the default host, an unknown id
98
+ # to the default entry), so a typo would silently ask another model.
99
+ # @raise [ArgumentError] +model+ names no configured host and is no
100
+ # host's model (cached, declared or saved lists; nothing is asked)
101
+ def configured_side_target(model)
102
+ ref = @host_registry.model_ref(model)
103
+ unless ref.host_name || ref.alias_name || @host_registry.hosts_with_model(ref.id).any?
104
+ raise ArgumentError, "ask_model: unknown model #{model.to_s.inspect} (use a configured host:id, an alias, " \
105
+ "or a model id one of your hosts lists)"
106
+ end
107
+
108
+ IdleTarget.of_model(@host_registry, model)
109
+ end
110
+ private :configured_side_target
111
+
112
+ # The IdleClient for +target+ at +timeout+: the last one when both are
113
+ # the same, else a new one.
114
+ def side_model_client(target, timeout)
115
+ @side_client_mutex.synchronize do
116
+ key = [target, timeout]
117
+ unless @side_client && @side_client_key == key
118
+ @side_client = IdleClient.for(target, timeout: timeout)
119
+ @side_client_key = key
120
+ end
121
+ @side_client
122
+ end
123
+ end
124
+ private :side_model_client
125
+
126
+ # The session's current model as a recap target: its host's OpenAI API
127
+ # (native llama.cpp hosts serve /v1/chat/completions too), key variable
128
+ # and bare model name, as a turn resolves them.
129
+ # @return [IdleTarget]
130
+ def session_model_recap_target
131
+ IdleTarget.of_model(@host_registry, effective_model_name)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The running turn as other threads see it: steering into it (now or
6
+ # at the next turn), the drains, the cut, the activity clock and the
7
+ # cancel. Reads @turn_state and @steer_cut; writes nothing of its own.
8
+ module SteeringPart
9
+ # Record that activity happened (user input or a completed turn): the
10
+ # idle jobs' seam (TurnState#record_activity). Each call advances both
11
+ # the last-activity timestamp and the activity sequence.
12
+ # @param now [Float, nil] injectable monotonic time (defaults to now)
13
+ def record_activity(now = nil)
14
+ @turn_state.record_activity(now)
15
+ end
16
+
17
+ # @return [Float] monotonic seconds of the last recorded activity
18
+ def last_activity_at
19
+ @turn_state.last_activity_at
20
+ end
21
+
22
+ # @return [Integer] monotonically-increasing activity counter (advanced by
23
+ # #record_activity; lets the idle detector summarize once per idle window)
24
+ def activity_seq
25
+ @turn_state.activity_seq
26
+ end
27
+
28
+ # @return [Boolean] true while a turn is in flight (the idle jobs never
29
+ # fire, nor render, while the model is generating)
30
+ def turn_running?
31
+ @turn_state.running?
32
+ end
33
+
34
+ # Put +text+ into the running turn, as a UI's steering does: it joins the
35
+ # conversation at the loop's next iteration boundary as its own user
36
+ # message (kind: "steer", source:). Callable from any thread; never
37
+ # blocks. With no turn running it does nothing (it never starts one).
38
+ # True means queued, not merged: at the after-answer boundary, and when
39
+ # the turn ends first, it is dropped (logged).
40
+ # @return [Boolean] whether it was queued
41
+ def steer(text, source:)
42
+ @turn_state.steer(text, source: source)
43
+ end
44
+
45
+ # A message for the running turn from +source+ cuts the generation
46
+ # streaming only thinking (SteerCut#cut_for_steer). Call it after the
47
+ # message is queued, with the #input_epoch read before queueing it.
48
+ # @return [Boolean] whether a generation was cut now
49
+ def cut_for_steer(source, epoch: nil) = @steer_cut.cut_for_steer(source, epoch: epoch)
50
+
51
+ # The drains that took input so far: read it before queueing a message
52
+ # for #cut_for_steer.
53
+ def input_epoch = @steer_cut.input_epoch
54
+
55
+ # The running generation's phase (SteerCut#generation_snapshot): what
56
+ # Bridge#snapshot puts in current_turn.generation.
57
+ def generation_snapshot = @steer_cut.generation_snapshot
58
+
59
+ # Like #steer, for the turn that begins next (a continue turn answered
60
+ # with a text): it joins that turn at its first boundary. Callable from
61
+ # any thread; a turn that never begins keeps it for the next one, unless
62
+ # #drop_carried_steers drops it.
63
+ # @return [Boolean] whether it was queued (false: blank)
64
+ def steer_next_turn(text, source:)
65
+ @turn_state.steer_next_turn(text, source: source)
66
+ end
67
+
68
+ # Drop the #steer_next_turn steers no turn began with (the continue turn
69
+ # failed before it began), logged; a no-op once a turn took them.
70
+ def drop_carried_steers
71
+ log_dropped_steers(@turn_state.drop_carried_steers, "not_begun")
72
+ end
73
+
74
+ # The drain a turn's loop gets: the caller's lines (a UI's steering; nil
75
+ # in a --non-interactive run) and the plugin steers. At the after-answer
76
+ # boundary the steers are dropped: the model answered, and a nudge would
77
+ # only restart the turn. The steer part fails on its own, never taking
78
+ # the user's lines with it. Items are a String (a user line), a
79
+ # Steer::Line (a worker's input line with its sender) or a steer Hash.
80
+ def turn_drain(pending_input)
81
+ lambda do |at_answer: false|
82
+ lines = pending_input ? Array(pending_input.call) : []
83
+ # The input went to the model: a message that waited to cut is in.
84
+ @steer_cut.delivered! unless lines.empty?
85
+ lines + take_steers(at_answer)
86
+ end
87
+ end
88
+ private :turn_drain
89
+
90
+ def take_steers(drop)
91
+ steers = @turn_state.take_steers
92
+ return steers unless drop
93
+
94
+ log_dropped_steers(steers, "answered")
95
+ []
96
+ rescue StandardError => e
97
+ Log.warn(:turn, "take_steers_failed", error: e.class.name, msg: e.message.to_s[0, 200])
98
+ []
99
+ end
100
+ private :take_steers
101
+
102
+ def log_dropped_steers(steers, why)
103
+ steers.each { |steer| Log.info(:turn, "steer_dropped", source: steer[:source], why: why, chars: steer[:text].length) }
104
+ end
105
+ private :log_dropped_steers
106
+
107
+ # @return [CancellationController, nil] active turn's cancellation controller
108
+ def active_cancel_controller
109
+ @turn_state.controller
110
+ end
111
+
112
+ # Whether a message a person sent waits for the running turn's next
113
+ # boundary (run_turn's input_waiting: probe): a task_wait or delegate wait
114
+ # returns on it, an execute hands its command over early
115
+ # (PeerView#input_waiting?). False outside a turn, for a turn given no
116
+ # probe, and when the probe raises (warned once a turn; the tool never
117
+ # fails for it).
118
+ def input_waiting?
119
+ probe = @turn_state.input_waiting
120
+ !!probe&.call
121
+ rescue StandardError => e
122
+ Log.warn(:turn, "input_waiting_failed", error: e.class.name, msg: e.message) if @turn_state.input_waiting_failed!
123
+ false
124
+ end
125
+
126
+ # Cancel the currently running turn, if any.
127
+ # @param reason [Symbol] cancellation reason
128
+ # @return [Boolean] whether a cancellation was triggered
129
+ def cancel_current_turn!(reason = :manual)
130
+ @turn_state.cancel!(reason)
131
+ end
132
+ end
133
+ end
134
+ end