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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The session's participants: the running turn's author, a pair turn's
6
+ # stop check, who came or went, the system prompt's participants line and
7
+ # a participant's author fields. Writes @participant_stop,
8
+ # @participants_line; reads @participants, @participants_watch,
9
+ # @guardrail_wiring.
10
+ module ParticipantsPart
11
+ # The running turn's author when a participant wrote it, else nil
12
+ # (ctx.turn_author, the hooks' author).
13
+ # @return [String, nil]
14
+ def turn_author = turn_running? ? @guardrail_wiring.turn_author : nil
15
+
16
+ # Whether a participant's pair turn must stop, read before each of its
17
+ # generations (#guard_participant_spend): author ("p:<id>") → why (their
18
+ # link ended, or they passed their cap), nil while it may go on. The
19
+ # Worker sets it (Worker#participant_turn_stop); without one (a one-shot)
20
+ # nothing is checked mid-turn.
21
+ attr_writer :participant_stop
22
+
23
+ # Before each generation (KernelLoop#generation_guard): a pair turn whose
24
+ # participant's link ended, or whose spend (Engine#metrics: their
25
+ # finished turns, this one's finished generations, their side requests)
26
+ # passed their cap, stops on the plugins' stop path (cost-cap's,
27
+ # work-cap's), with its notice. A generation's own cost is known only
28
+ # once it ends, so a turn can pass the cap by its last generation. The
29
+ # turn-start check is the Worker's (Worker#participant_refusal); a
30
+ # review turn only reads, and keeps to it.
31
+ def guard_participant_spend
32
+ author = turn_author
33
+ return if author.nil? || @participant_stop.nil?
34
+ return unless @guardrail_wiring.turn_mode == ParticipantMode::PAIR
35
+
36
+ reason = @participant_stop.call(author) or return
37
+ hook_stop_turn(reason, PARTICIPANTS_HOOK)
38
+ end
39
+ private :guard_participant_spend
40
+
41
+ # A participant joined (invited) or left (revoked, expired) since the
42
+ # last look: :participants_changed to the observers (ParticipantsWatch;
43
+ # the worker looks on its idle tick). The first look announces nobody.
44
+ # @return [ParticipantsWatch::Change, nil]
45
+ def announce_participants
46
+ change = @participants_watch.changes or return nil
47
+
48
+ announce(change.to_event)
49
+ change
50
+ end
51
+
52
+ # Reads the session's participants at a turn's start; the first to join,
53
+ # the first invited to act (a pair participant: the line's words) and
54
+ # the last to leave rebuild the system prompt (its participants line),
55
+ # once each: a turn in between sends the same prefix.
56
+ def refresh_participants!(session)
57
+ line = @participants.refresh!(session).prompt_mode(session)
58
+ return if line == @participants_line
59
+
60
+ @participants_line = line
61
+ @prompt_builder.reset!
62
+ end
63
+ private :refresh_participants!
64
+
65
+ # The author fields a message +author+ wrote carries (Steer.author_fields:
66
+ # author:, and author_name: from participants.json), {} for the owner.
67
+ # The worker's drain names a participant's merged lines with it, and
68
+ # its running save a participant's prompt.
69
+ # @param author [String, nil] a turn's or a line's author (TurnOrigin#author)
70
+ # @param session [Session, nil] the session it is for (the turn's)
71
+ def author_fields(author, session: @session)
72
+ return {} unless Steer.participant_author?(author)
73
+
74
+ Steer.author_fields(author, @participants.name_for(author, session: session))
75
+ end
76
+
77
+ # A participant's opening message keeps the rules it was delivered under
78
+ # (ParticipantMode), stamped before any request carries it, so its header
79
+ # never changes when the session's mode does.
80
+ def participant_mode_fields(author)
81
+ mode = Steer.participant_author?(author) && @guardrail_wiring.turn_mode
82
+ mode ? { participant_mode: mode.to_s } : {}
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Engine
5
+ # The Engine as the plugins' host: loading them (Plugin::Registries), their
6
+ # init tasks and staged tool sets (PluginTasks), Plugin::Host's view of
7
+ # the session, ctx.messages and ctx.http's cancel. Writes nothing of its
8
+ # own; reads @plugin_tasks, @extension_load, @hook_routes, @tools,
9
+ # @tool_index, @toolkit_binding, @prompt_builder, @session.
10
+ module PluginsPart
11
+ # Add a plugin's init task (Plugin::Api#init at commit); it starts with
12
+ # #start_init_tasks!.
13
+ def add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed: nil, &)
14
+ @plugin_tasks.add(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
15
+ quiet: quiet, timeout: timeout, failed: failed, &)
16
+ end
17
+
18
+ # Start the init tasks not started yet, each on its own thread
19
+ # (PluginTasks#start!).
20
+ def start_init_tasks!
21
+ @plugin_tasks.start!
22
+ end
23
+
24
+ # The running init tasks a UI shows (not the quiet ones), for a UI that
25
+ # joins while they run (Bridge#snapshot, with the event log held).
26
+ # @return [Array<Hash>] {bundle:, id:, label:}
27
+ def init_tasks
28
+ @plugin_tasks.running
29
+ end
30
+
31
+ # Wait for the running init tasks that provide tools (PluginTasks#await).
32
+ # @return [Boolean] whether it waited
33
+ def await_init_tasks(controller = nil, on_event = nil)
34
+ @plugin_tasks.await(controller, on_event)
35
+ end
36
+
37
+ # The init task this thread runs, or nil.
38
+ def current_init_task = @plugin_tasks.current
39
+ private :current_init_task
40
+
41
+ private
42
+
43
+ def load_plugins
44
+ host = plugin_host
45
+ registries = Plugin::Registries.new(
46
+ commands: @command_registry, tools: @tools, hooks: @hooks,
47
+ context_for: lambda { |bundle, settings, label|
48
+ Plugin::Context.new(bundle: bundle, label: label, settings: settings, host: host)
49
+ },
50
+ tools_changed: -> { tools_changed! },
51
+ services: @services,
52
+ stage_tools: ->(bundle, specs, context) { stage_tools(bundle, specs, context) },
53
+ init: lambda { |bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
54
+ add_init_task(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
55
+ quiet: quiet, timeout: timeout, failed: failed, &block)
56
+ },
57
+ toolkits: @toolkit_binding.plugin_kits,
58
+ tool_index: @tool_index,
59
+ peer_sources: @peer_sources,
60
+ prompt_sections: @prompt_sections,
61
+ prompt_changed: -> { prompt_changed! },
62
+ judgments: @judgments,
63
+ hook_failed: ->(hook, failed) { @hook_routes.plugin_hook_failed(hook, failed) }
64
+ )
65
+ # What plugins show while they load (an MCP server that didn't
66
+ # start) waits for the first turn, beside the load warnings: no UI
67
+ # is there yet, and the Engine isn't built.
68
+ @extension_load.load_plugins(registries, failures: @plugin_failures) do
69
+ warn_undeclared_judgments
70
+ warn_bundle_settings
71
+ end
72
+ end
73
+
74
+ # config.yml bundles: names and keys that do nothing (BundleSettingsCheck),
75
+ # held like the judgments' line, for the same reason. Logged by every
76
+ # Engine; shown by the first to find them (BundleSettingsCheck.unshown).
77
+ def warn_bundle_settings
78
+ lines = BundleSettingsCheck.warnings(@extension_load.bundle_settings)
79
+ lines.each { |line| Log.warn(:config, "bundle_setting_unknown", msg: line) }
80
+ BundleSettingsCheck.unshown(lines).each { |line| hook_notify(line, :warn, "config") }
81
+ end
82
+ private :warn_bundle_settings
83
+
84
+ # Held with the plugins' own notices, so every UI gets it: a worker's
85
+ # stderr goes nowhere.
86
+ def warn_undeclared_judgments
87
+ @judgments.undeclared.each do |name|
88
+ Log.warn(:config, "judgment_undeclared", judgment: name)
89
+ hook_notify("system_one.judgments.#{name}: no loaded bundle declares it, so it does nothing", :warn, "config")
90
+ end
91
+ end
92
+ private :warn_undeclared_judgments
93
+
94
+ # Keep a plugin's new tool set (chi.replace_tools, from any thread)
95
+ # for the turn thread (PluginTasks#stage_tools).
96
+ def stage_tools(bundle, specs, context)
97
+ @plugin_tasks.stage_tools(bundle, specs, context)
98
+ end
99
+ private :stage_tools
100
+
101
+ # Apply the staged tool sets, on the turn thread, before the turn's
102
+ # system prompt is built (PluginTasks#apply_staged_tools!). A changed
103
+ # set may change the chi group the search tool's description counts
104
+ # (a plugin tool the toolkit leaves out).
105
+ # @return [Boolean] whether the tools changed
106
+ def apply_staged_tools!
107
+ changed = @plugin_tasks.apply_staged_tools!
108
+ @tool_index.refresh! if changed
109
+ changed
110
+ end
111
+ public :apply_staged_tools!
112
+
113
+ # The tools changed (a plugin's chi.tools_changed!): the system prompts,
114
+ # which declare them, are built again on the next turn.
115
+ def tools_changed!
116
+ prompt_changed!
117
+ end
118
+
119
+ # A plugin's prompt section would give other text now
120
+ # (chi.prompt_changed!): the system prompts are built again at their
121
+ # next build (the next turn's start; a running turn keeps the prompt it
122
+ # started with). One cache restart: the plugin's command asked first
123
+ # (Plugin::Context#cache_confirm).
124
+ def prompt_changed!
125
+ @prompt_builder&.reset!
126
+ end
127
+ public :prompt_changed!
128
+
129
+ # What a Plugin::Context reads and calls: the session now, and the
130
+ # hook runtime's notify and ask_user.
131
+ def plugin_host
132
+ Plugin::Host.new(
133
+ session_id: -> { @session&.id },
134
+ cwd: -> { @session&.working_directory },
135
+ messages: -> { plugin_messages },
136
+ messages_partial: -> { turn_running? && @running_turn_messages.nil? },
137
+ turn_running: -> { turn_running? },
138
+ notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
139
+ ask_user: ->(question) { hook_ask_user(question) },
140
+ # An init task's is its own (a Ctrl-C ends a turn, not the task).
141
+ cancelled: lambda {
142
+ (task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
143
+ },
144
+ card: ->(**card) { show_card(**card) },
145
+ steer: ->(text, label) { steer(text, source: hook_source(label)) },
146
+ stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
147
+ stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
148
+ toolkit: -> { @toolkit_binding.name },
149
+ declared_tools: -> { toolkit_explained.declared.map(&:name) },
150
+ use_toolkit: ->(name) { use_toolkit(name) },
151
+ load_tools: ->(names, bundle) { load_tools(names, bundle) },
152
+ cancel_controller: ->(handler: false) { plugin_cancel_controller(handler: handler) },
153
+ ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
154
+ ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
155
+ model: model, source: source)
156
+ },
157
+ model_name: -> { effective_model_ref },
158
+ model_key: -> { @model_binding.model_key },
159
+ # The names a models: map looks the model up under; [] when it doesn't resolve.
160
+ model_names: lambda do
161
+ effective_model.names
162
+ rescue StandardError
163
+ []
164
+ end,
165
+ state_dir: -> { session_state_dir },
166
+ scratch: -> { @scratch },
167
+ setup: -> { SessionSetup.of(@session) },
168
+ # The files whose text the built system prompt holds; [] until a
169
+ # build and after a rebuild that drops them (SystemPrompt#prompt_files
170
+ # returns [] itself: the || keeps a nil builder safe).
171
+ prompt_files: -> { @prompt_builder&.prompt_files || [] },
172
+ frontend: -> { interface },
173
+ cost: -> { @metrics.cost },
174
+ usage: -> { @metrics.usage },
175
+ turn_author: -> { turn_author },
176
+ # A plugin's prompt section is in the stable part: a full re-read.
177
+ cache_confirm: ->(line, changed) { CacheConfirm.ask(line: line, cost: :full, changed: changed, engine: self) },
178
+ candidate_proposed: ->(_candidate) { judge_late_candidates },
179
+ judgments: -> { @judgments }
180
+ )
181
+ end
182
+
183
+ # The controller a plugin's ctx.http call without cancel: stops with
184
+ # (docs/plugins.md): an init task's own, else the running turn's for the
185
+ # turn's own work until the turn has ended (TurnState#own_controller); nil
186
+ # for an anytime command, a service or a thread of the plugin's.
187
+ def plugin_cancel_controller(handler:)
188
+ (task = current_init_task) ? task.cancel : @turn_state.own_controller(handler: handler)
189
+ end
190
+ private :plugin_cancel_controller
191
+
192
+ # A plugin's ctx.messages: the conversation without the system prompt
193
+ # (a session saved by an older chi's REPL may start with it), and,
194
+ # while a turn runs, the turn so far from the Bridge (plan O1). Read
195
+ # with the event log held, so a turn is in exactly one of the two.
196
+ def plugin_messages
197
+ synchronize_events do
198
+ messages = AnswerDisplay.strip_all(messages_checkpoint)
199
+ first = messages.first
200
+ messages = messages.drop(1) if first && first[:role].to_s == "system" && first[:kind].to_s.empty?
201
+ running = turn_running? && @running_turn_messages ? Array(@running_turn_messages.call) : []
202
+ messages + running
203
+ end
204
+ end
205
+ private :plugin_messages
206
+ end
207
+ end
208
+ end
@@ -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