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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # Cards and anytime commands: #show_card (routed by EventRoutes), the
6
+ # anytime and session-command thread marks, the anytime threads #shutdown
7
+ # waits for. Reads @event_routes, @anytime_threads under @lifecycle_mutex.
8
+ module CardsPart
9
+ # ── Cards ───────────────────────────────────────────────────────────────
10
+
11
+ # Show a card in every UI (docs/plugins.md, Cards): {type: :card, id:,
12
+ # source:, title:, body:, level:, actions: [{label:, command:}], in_turn:}.
13
+ # Where it goes (held while the plugins load, an anytime command's, an
14
+ # init task's, a turn event, else announced) is EventRoutes#route's,
15
+ # marked in_turn: (CARD_MARKS). A card with the id of an earlier one
16
+ # replaces it (btw's "thinking…" → the answer).
17
+ # @param source [String] who shows it (a bundle's name)
18
+ # @param actions [Array<Hash>] {label:, command:}; a command is a line
19
+ # the session runs, as typed (D3)
20
+ # @return [String] the card's id
21
+ # @raise [ArgumentError] a card without a title, or a bad action
22
+ def show_card(source:, title:, body: "", actions: [], level: :info, id: nil)
23
+ card = Plugin::Card.build(source: source, title: title, body: body, actions: actions, level: level, id: id)
24
+ command = Thread.current[running_command_key]
25
+ card[:command] = command if command
26
+ @event_routes.route(card, **EventRoutes::CARD_MARKS)
27
+ card[:id]
28
+ end
29
+
30
+ # Run an anytime command's block (D8): the cards and notices it shows on
31
+ # this thread are announced at once, marked anytime: true, and are
32
+ # never a running turn's events. They belong to the command, not the
33
+ # turn, and a UI shows them after the command's own line (a web
34
+ # command bubble drawn at its command_queued), even mid-turn.
35
+ # @return the block's value
36
+ def running_anytime
37
+ key = :"samagotchi_anytime_#{object_id}"
38
+ outer = Thread.current[key]
39
+ Thread.current[key] = true
40
+ yield
41
+ ensure
42
+ Thread.current[key] = outer
43
+ end
44
+
45
+ # Run a session command's dispatch: a card shown on this thread says
46
+ # it's that command's (:card's command:), for a reload that no longer has
47
+ # the command's line above it (Bridge::CardStore). A thread of its own
48
+ # isn't the command, nor a card shown after it.
49
+ # @param name [String] the command, "/short"
50
+ # @return the block's value
51
+ def running_command(name)
52
+ outer = Thread.current[running_command_key]
53
+ Thread.current[running_command_key] = name
54
+ yield
55
+ ensure
56
+ Thread.current[running_command_key] = outer
57
+ end
58
+
59
+ # Whether an anytime command (/btw) still runs on its thread.
60
+ def anytime_running?
61
+ @lifecycle_mutex.synchronize { @anytime_threads.any?(&:alive?) }
62
+ end
63
+
64
+ # Start an anytime command on its own thread (D8), one #shutdown waits
65
+ # for, so its command_ran is announced before the process leaves.
66
+ # @return [Thread]
67
+ def spawn_anytime(&)
68
+ thread = Thread.new(&)
69
+ @lifecycle_mutex.synchronize do
70
+ @anytime_threads.select!(&:alive?)
71
+ @anytime_threads << thread
72
+ end
73
+ thread
74
+ end
75
+
76
+ def anytime_thread? = Thread.current[:"samagotchi_anytime_#{object_id}"] == true
77
+ private :anytime_thread?
78
+
79
+ def announce_anytime(event)
80
+ event = event.merge(anytime: true)
81
+ announce(event)
82
+ event
83
+ end
84
+ private :announce_anytime
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The attached-context candidate judge (ContextJudge): its pass at an
6
+ # owner turn's start and for a late candidate, the asks it starts, and
7
+ # the cards it shows after the turn. Reads @context_cards_after_turn
8
+ # under @context_cards_mutex, @side_judgments, @host_registry, @session.
9
+ module ContextJudgePart
10
+ private
11
+
12
+ # The candidate judge (ContextJudge) at an owner turn's start, after
13
+ # the toolkit's ask settled: the session's candidates' status changes
14
+ # are logged (ContextCandidatesLog#look) and, unless
15
+ # context.candidates.mode is off, the proposed candidates due an ask
16
+ # are asked about the owner's prompts so far, this one included. Never
17
+ # a participant's turn, a review session or a Continue (no new prompt).
18
+ def start_context_judge(turn)
19
+ session = turn.session
20
+ return unless turn.prompt.is_a?(String) && OwnerPrompt.turn?(session: session, author: turn.origin.author)
21
+
22
+ context_judge_pass(session, ContextCandidates.owner_texts(session.messages) + [turn.prompt], phase: "turn")
23
+ end
24
+ private :start_context_judge
25
+
26
+ # A late proposal no owner prompt mentions (Plugin::Host
27
+ # #candidate_proposed: github-pr's init task runs beside the first
28
+ # turn): judged on the owner's prompts so far, the running turn's too.
29
+ def judge_late_candidates
30
+ session = @session
31
+ return if session.nil? || session.review?
32
+
33
+ texts = ContextCandidates.owner_texts(plugin_messages)
34
+ context_judge_pass(session, texts, phase: "late") unless texts.empty?
35
+ end
36
+ private :judge_late_candidates
37
+
38
+ # One look at +session+'s candidates; the asks go to SideJudgments'
39
+ # :context kind, one thread asking each due candidate in turn.
40
+ def context_judge_pass(session, texts, phase:)
41
+ return if session.id.nil? || session.scratch
42
+
43
+ rows = ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: session_state_dir)
44
+ return if rows.empty?
45
+
46
+ log = ContextCandidatesLog.log_for(session_state_dir)
47
+ records = ContextCandidatesLog.records_for(log, session.id)
48
+ ContextCandidatesLog.look(log, session.id, rows, records) if phase == "turn"
49
+ settings = ContextJudge::Settings.from_config
50
+ return if settings.mode == "off"
51
+
52
+ jobs = ContextJudge.jobs(rows, records, session_id: session.id, texts: texts, settings: settings, phase: phase,
53
+ parent_attached: context_parent_attached(session))
54
+ return if jobs.empty?
55
+
56
+ client = system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :context),
57
+ ContextJudge::JUDGMENT, session, mode: settings.mode)
58
+ return unless client
59
+
60
+ start_context_asks(client, jobs, log, settings, context_choice(session, log, settings))
61
+ rescue StandardError => e
62
+ ContextJudge.warn(e)
63
+ end
64
+ private :context_judge_pass
65
+
66
+ # @return [Thread, nil] the asks' thread, nil when none started
67
+ def start_context_asks(client, jobs, log, settings, choice)
68
+ if @side_judgments.in_flight?(:context)
69
+ Log.info(:context, "context_judge_in_flight_skipped", session_id: jobs.first.session_id)
70
+ return nil
71
+ end
72
+
73
+ @side_judgments.start(:context, warn: ContextJudge.method(:warn)) do
74
+ jobs.each { |job| choice.call(job, ContextJudge.ask(client: client, job: job, log: log, settings: settings)) }
75
+ end
76
+ end
77
+ private :start_context_asks
78
+
79
+ # What an answer does on the asks' thread (ContextCandidateChoice
80
+ # #after_judged: on mode attaches or cards; audit nothing). Taken on
81
+ # the caller's thread. The attach only writes the session's folder: its
82
+ # note comes with the next between-turn delivery, never into a running
83
+ # turn. A card shows only while this Engine is still on that session,
84
+ # and not in a running turn (it isn't the turn's: no "waiting for you"
85
+ # on it), but once it has ended.
86
+ # @return [#call] (job, fields)
87
+ def context_choice(session, log, settings)
88
+ session_id = session.id
89
+ project_root = session.project_root
90
+ state_dir = session_state_dir
91
+ show = lambda do |**card|
92
+ current = @session
93
+ next nil unless current.nil? || current.id == session_id
94
+
95
+ @context_cards_mutex.synchronize do
96
+ next @context_cards_after_turn << card if turn_running?
97
+
98
+ show_card(**card)
99
+ end
100
+ end
101
+ lambda do |job, fields|
102
+ ContextCandidateChoice.after_judged(job: job, fields: fields, settings: settings, project_root: project_root,
103
+ state_dir: state_dir, log: log, show_card: show)
104
+ end
105
+ end
106
+ private :context_choice
107
+
108
+ # The cards #context_choice held while a turn ran (#run_turn's ensure,
109
+ # after the turn flag is released).
110
+ def show_context_cards_after_turn
111
+ cards = @context_cards_mutex.synchronize { @context_cards_after_turn.slice!(0..) }
112
+ cards.each { |card| show_card(**card) }
113
+ rescue StandardError => e
114
+ ContextJudge.warn(e)
115
+ end
116
+ private :show_context_cards_after_turn
117
+
118
+ # A delegate child's parent's sources, by title (else name): the
119
+ # judge's started_from. nil for any other session (a chi send --new
120
+ # session has no parent; a fork isn't a delegate).
121
+ def context_parent_attached(session)
122
+ return nil unless session.delegate? && session.parent_id
123
+
124
+ parent_root = if Session.exist?(session.parent_id, state_dir: session_state_dir)
125
+ Session.load(session.parent_id, state_dir: session_state_dir).project_root
126
+ else
127
+ session.project_root
128
+ end
129
+ ContextSources.attached(session.parent_id, project_root: parent_root, state_dir: session_state_dir)
130
+ .map { |attached| attached.source.title || attached.name }
131
+ rescue StandardError => e
132
+ Log.warn(:context, "context_parent_attached_failed", error: e.class.name, msg: e.message.to_s[0, 200])
133
+ []
134
+ end
135
+ private :context_parent_attached
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The event log: persistent observers, #announce (ANNOUNCEABLE_EVENTS),
6
+ # the event lock, held announcements, the load events, and the stream
7
+ # and emit helpers (EventFanout's, forwarded). Writes
8
+ # @guardrail_failures_announced; reads @session_observer, @event_fanout,
9
+ # @extension_load, @guardrail_failures, @plugin_failures.
10
+ module EventsPart
11
+ # Subscribe a persistent observer to engine events.
12
+ #
13
+ # Unlike the turn-scoped `on_event:` sink, a subscribed observer keeps
14
+ # receiving events across every `run_turn` call on this Engine. Each
15
+ # delivery carries a locally-monotonic `event_seq`. The returned handle can
16
+ # be used to unsubscribe later.
17
+ # @param observer [#call] receives event hashes (with `event_seq:` merged in)
18
+ # @return [Samagotchi::SessionObserver::SubscribedObserver] handle to unsubscribe
19
+ def subscribe(observer:)
20
+ @session_observer.subscribe(observer: observer)
21
+ end
22
+
23
+ # Unsubscribe a previously-registered observer.
24
+ # @param handle [Samagotchi::SessionObserver::SubscribedObserver]
25
+ # @return [Boolean] whether the observer was removed (nil/unknown never raises)
26
+ def unsubscribe(handle:)
27
+ @session_observer.unsubscribe(handle: handle)
28
+ end
29
+
30
+ # @return [Integer] total engine events emitted so far (locally monotonic)
31
+ def event_count
32
+ @session_observer.event_count
33
+ end
34
+
35
+ # Put a transport-level event into the ordered event log. Unlike turn
36
+ # events it reaches only persistent observers (no turn sink, no memory
37
+ # capture).
38
+ # @param event [Hash] with :type in ANNOUNCEABLE_EVENTS
39
+ # @raise [ArgumentError] for any other type
40
+ def announce(event)
41
+ unless ANNOUNCEABLE_EVENTS.include?(event[:type])
42
+ raise ArgumentError, "cannot announce #{event[:type].inspect} (allowed: #{ANNOUNCEABLE_EVENTS.join(", ")})"
43
+ end
44
+
45
+ @session_observer.notify(event)
46
+ end
47
+
48
+ # Run the block with the event log held: no event is numbered or
49
+ # delivered meanwhile, and events the block emits keep their order.
50
+ def synchronize_events(&)
51
+ @session_observer.synchronize(&)
52
+ end
53
+
54
+ # The working set's :working_set_updated, announced between turns
55
+ # (after a command changed what the next request sends), when it
56
+ # differs from the last one emitted (#emit_working_set). Under the
57
+ # event lock, so it is ordered with the change's own events.
58
+ def announce_working_set(session = @session)
59
+ return unless session
60
+
61
+ synchronize_events { emit_working_set(nil, session, session.messages) }
62
+ end
63
+
64
+ # ── Load events ───────────────────────────────────────────────────────
65
+
66
+ # Announce what failed to load and what plugins showed while loading,
67
+ # once, as soon as the owner can show it (the worker's Bridge is up):
68
+ # the guardrail and plugin warnings, an orphaned
69
+ # project memory folder (label: "memory", MemoryOrphans), then the
70
+ # held notices (between_turns) and cards (in_turn: false, so late
71
+ # joiners get them). Without this call the first turn announces them
72
+ # (#announce_guardrail_failures).
73
+ def announce_load_events!
74
+ synchronize_events do
75
+ next if @guardrail_failures_announced
76
+
77
+ @guardrail_failures_announced = true
78
+ message = @guardrail_failures.message
79
+ announce({ type: :guardrail_warning, message: message }) if message
80
+ plugins = @plugin_failures.message
81
+ announce({ type: :guardrail_warning, message: plugins, label: "plugins" }) if plugins
82
+ memory = MemoryOrphans.message
83
+ announce({ type: :guardrail_warning, message: memory, label: "memory" }) if memory
84
+ @extension_load.held_events.each do |event|
85
+ announce(event[:type] == :card ? event.merge(in_turn: false) : event.merge(between_turns: true))
86
+ end
87
+ end
88
+ nil
89
+ end
90
+
91
+ # Run the block holding back the cards and between-turns notices it
92
+ # announces on this thread, so the caller announces them after its own
93
+ # event (a worker's command: its command_ran first, so a UI prints the
94
+ # command's output before its cards).
95
+ # @return [Array(Object, Array<Hash>)] the block's value and the held events
96
+ def holding_announcements
97
+ key = :"samagotchi_held_#{object_id}"
98
+ outer = Thread.current[key]
99
+ Thread.current[key] = []
100
+ value = yield
101
+ [value, Thread.current[key]]
102
+ ensure
103
+ Thread.current[key] = outer
104
+ end
105
+
106
+ # Announce +event+ now, or keep it for #holding_announcements' caller.
107
+ def announce_or_hold(event)
108
+ held = Thread.current[:"samagotchi_held_#{object_id}"]
109
+ held ? held << event : announce(event)
110
+ end
111
+ private :announce_or_hold
112
+
113
+ private
114
+
115
+ # ── Event helpers ──────────────────────────────────────────────────────────
116
+
117
+ # The stream handler, the working set and the event fan-out are
118
+ # EventFanout's (#build_event_fanout); these forward to it.
119
+ def build_stream_event_handler(...) = @event_fanout.build_stream_event_handler(...)
120
+ def emit_working_set(...) = @event_fanout.emit_working_set(...)
121
+ private :emit_working_set
122
+ def working_set_llm_context = @event_fanout.working_set_llm_context
123
+ private :working_set_llm_context
124
+ def with_generation_report(event) = @event_fanout.with_generation_report(event)
125
+ private :with_generation_report
126
+ def stream_watch(cancel_controller) = @event_fanout.stream_watch(cancel_controller)
127
+ private :stream_watch
128
+ def emit_event(on_event, event) = @event_fanout.emit_event(on_event, event)
129
+ end
130
+ end
131
+ end
@@ -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