samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../log"
5
+ require_relative "../guardrails/context"
6
+ require_relative "../idle_client"
7
+ require_relative "side_question"
8
+ require_relative "sessions"
9
+
10
+ module Samagotchi
11
+ module Plugin
12
+ # The Engine's side of a Context: callables, so the Engine itself is
13
+ # never handed out. +messages+ returns the conversation, +notify+ takes
14
+ # (text, level, label), +ask_user+ takes (question:, options:, header:,
15
+ # allow_freeform:, hook:) like the hook runtime's, +card+ takes
16
+ # Engine#show_card's keywords and returns the id, +ask_model+ takes
17
+ # (chat messages, timeout:, max_tokens:, cancel_controller:) and returns
18
+ # the answer text.
19
+ # +messages_partial+ says whether +messages+ leaves out a running turn;
20
+ # +model_name+ and +state_dir+ are what ctx.sessions forks with.
21
+ Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
22
+ :ask_model, :model_name, :state_dir, keyword_init: true)
23
+
24
+ # ctx.ask_model failed: the model couldn't be reached, timed out, or
25
+ # sent nothing usable. The message says why, for the user.
26
+ class ModelError < StandardError; end
27
+
28
+ # ctx.ask_model was cancelled (its cancel: controller).
29
+ class ModelCancelled < ModelError; end
30
+
31
+ # What a plugin's handlers get as +ctx+ (docs/plugins.md): the session
32
+ # they run for, the bundle's settings and storage, a log, and the
33
+ # user-facing helpers hooks have. One per plugin, for the Engine's life:
34
+ # each read is of the session now.
35
+ class Context
36
+ # Debug-log records tagged plugins, with bundle=<bundle> (tags are a
37
+ # closed list, LogLine::TAGS).
38
+ class Logger
39
+ def initialize(bundle)
40
+ @bundle = bundle
41
+ end
42
+
43
+ %i[debug info warn error].each do |level|
44
+ define_method(level) do |event, **fields|
45
+ Samagotchi::Log.public_send(level, :plugins, event.to_s, bundle: @bundle, **fields)
46
+ end
47
+ end
48
+ end
49
+
50
+ # @return [String] the bundle the plugin came with
51
+ attr_reader :bundle
52
+
53
+ # @return [Hash] config.yml `bundles: <bundle>:` (string keys), frozen
54
+ attr_reader :settings
55
+
56
+ # @return [Logger]
57
+ attr_reader :log
58
+
59
+ # @param label [String] "<file> (bundle <name>)", what notices and
60
+ # questions are labelled by
61
+ # @param host [Host]
62
+ def initialize(bundle:, label:, settings:, host:, env: ENV)
63
+ @bundle = bundle.to_s
64
+ @label = label
65
+ @settings = deep_freeze(settings.is_a?(Hash) ? settings : {})
66
+ @host = host
67
+ @env = env
68
+ @log = Logger.new(@bundle)
69
+ @git = Guardrails::GitInfo.new
70
+ @data_dir = nil
71
+ end
72
+
73
+ # @return [String, nil] the session's id (nil before the first one)
74
+ def session_id = @host.session_id.call
75
+
76
+ # @return [String] the session's working directory
77
+ def cwd = @host.cwd.call || Dir.pwd
78
+
79
+ # @return [String, nil] the git checkout holding #cwd
80
+ def repo_root = @git.root(cwd)
81
+
82
+ # $XDG_STATE_HOME/samagotchi/plugins/<bundle>/, created on first use.
83
+ # @return [String]
84
+ def data_dir
85
+ @data_dir ||= begin
86
+ xdg = @env.fetch("XDG_STATE_HOME", "").to_s.strip
87
+ base = xdg.empty? ? File.join(Dir.home, ".local", "state") : xdg
88
+ File.join(base, "samagotchi", "plugins", @bundle).tap { |dir| FileUtils.mkdir_p(dir) }
89
+ end
90
+ end
91
+
92
+ # The conversation so far, a frozen copy, without the system prompt.
93
+ # While a turn runs, a session worker's (attached TUI, web) adds the
94
+ # turn so far: its prompt, the model's text and the lines merged into
95
+ # it; the REPL's is the conversation before that turn
96
+ # (#messages_partial?).
97
+ # @return [Array<Hash>]
98
+ def messages
99
+ Array(@host.messages.call).map { |message| message.dup.freeze }.freeze
100
+ end
101
+
102
+ # Whether #messages leaves out a running turn (the REPL mid-turn), so
103
+ # a plugin can say what its answer is about.
104
+ def messages_partial? = !!@host.messages_partial&.call
105
+
106
+ # One line to the user, labelled by the plugin, like a hook's
107
+ # event[:notify]. Every UI shows it, during a turn or between turns.
108
+ # @param level [Symbol] :info or :warn
109
+ def notify(text, level: :info)
110
+ @host.notify.call(text.to_s, level, @label)
111
+ nil
112
+ end
113
+
114
+ # A card in every UI: a title, a body (markdown in the web, plain
115
+ # text in the terminal) and actions, each a command line the session
116
+ # runs when the user picks it (docs/plugins.md, Cards). Showing a card
117
+ # with an earlier card's id replaces that card.
118
+ # @param actions [Array<Hash>] {label:, command:} ("/hello again")
119
+ # @param level [Symbol] :info or :warn
120
+ # @param id [String, nil] an earlier card's id to replace it
121
+ # @return [String] the card's id
122
+ # @raise [ArgumentError] no title, a bad level or action
123
+ def card(title:, body: "", actions: [], level: :info, id: nil)
124
+ @host.card.call(source: @bundle, title: title, body: body, actions: actions, level: level, id: id)
125
+ end
126
+
127
+ # A side answer (plan D7): one request to the session's current model
128
+ # on its host, with no tools and thinking off. It writes nothing,
129
+ # fires no hooks, and doesn't touch the conversation. It blocks until
130
+ # the answer comes, so call it from an anytime command or your own
131
+ # thread; with a local server that runs one request at a time it
132
+ # waits for a running turn.
133
+ # @param messages [Array<Hash>] the conversation to ask about
134
+ # (usually #messages); sent as a transcript without the system
135
+ # prompt, tool calls, tool output and thinking; an image is a line
136
+ # naming it
137
+ # @param prompt [String] the question
138
+ # @param system [String, nil] instructions (a short default)
139
+ # @param timeout [Numeric] seconds
140
+ # @param max_tokens [Integer, nil] the answer's limit (default
141
+ # ASK_MAX_TOKENS); an answer cut off ends with "…"
142
+ # @param cancel [CancellationController, nil] cancelling it aborts
143
+ # the request
144
+ # @return [String] the answer ("" when the model said nothing)
145
+ # @raise [ModelError] the request failed or timed out
146
+ # @raise [ModelCancelled] +cancel+ was cancelled
147
+ def ask_model(messages:, prompt:, system: nil, timeout: 120, max_tokens: nil, cancel: nil)
148
+ raise ArgumentError, "ask_model needs a prompt" if prompt.to_s.strip.empty?
149
+
150
+ request = SideQuestion.request(messages: messages, prompt: prompt, system: system)
151
+ limit = max_tokens.nil? ? ASK_MAX_TOKENS : Integer(max_tokens)
152
+ @host.ask_model.call(request, timeout: Float(timeout), max_tokens: limit, cancel_controller: cancel).to_s
153
+ rescue IdleClient::SummarizeError => e
154
+ raise ModelError, e.message
155
+ rescue LLM::RequestCancelled
156
+ raise ModelCancelled, "the request was cancelled"
157
+ end
158
+
159
+ # ctx.ask_model's answer limit when none is given.
160
+ ASK_MAX_TOKENS = 1024
161
+
162
+ # Other sessions: fork one from a conversation, send one a message,
163
+ # read one (Sessions).
164
+ # @return [Sessions]
165
+ def sessions
166
+ @sessions ||= Sessions.new(@host)
167
+ end
168
+
169
+ # A single-select question through the question flow, like a hook's
170
+ # event[:ask_user].
171
+ # @return [Hash, nil] {selected:, freeform:, selected_indices:}, or nil
172
+ # (no one to ask, cancelled, bad options)
173
+ def ask_user(question:, options:, header: nil, allow_freeform: false)
174
+ @host.ask_user.call(question: question, options: options, header: header, allow_freeform: allow_freeform,
175
+ hook: @label)
176
+ end
177
+
178
+ # Whether the running turn was cancelled (a long tool should stop).
179
+ def cancelled? = !!@host.cancelled.call
180
+
181
+ private
182
+
183
+ def deep_freeze(value)
184
+ case value
185
+ when Hash then value.to_h { |k, v| [deep_freeze(k), deep_freeze(v)] }.freeze
186
+ when Array then value.map { |v| deep_freeze(v) }.freeze
187
+ when String then value.dup.freeze
188
+ else value
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api"
4
+ require_relative "context"
5
+ require_relative "../hooks"
6
+ require_relative "../log"
7
+ require_relative "../version"
8
+ require_relative "../memory_bundle/manifest"
9
+ require_relative "../memory_bundle/provenance"
10
+
11
+ module Samagotchi
12
+ module Plugin
13
+ # What a plugin registers into: an Engine's own registries.
14
+ # +context_for+ is (bundle_name, settings, label) → the Plugin::Context
15
+ # its handlers get; +tools_changed+ drops what was built from the tools
16
+ # (the Engine's system prompts); +services+ (Plugin::Services) is what
17
+ # Engine#shutdown stops; +stage_tools+ is (bundle, specs, context),
18
+ # chi.replace_tools' set, which the Engine applies on its turn thread;
19
+ # +init+ adds a chi.init task (Engine#add_init_task).
20
+ Registries = Struct.new(:commands, :tools, :hooks, :context_for, :tools_changed, :services, :stage_tools, :init,
21
+ keyword_init: true)
22
+
23
+ # Loads installed bundles' plugins (manifest plugin: {file:, sha256:})
24
+ # into an Engine's registries (docs/plugins.md).
25
+ #
26
+ # For each bundle, by name: the file must match the sha256 recorded at
27
+ # install and chi must meet the bundle's requires_chi. The file is
28
+ # module_eval'd into the bundle's namespace (as its hooks are), the
29
+ # class named like the file (plugin.rb → Plugin) is built with the
30
+ # bundle's settings, and its #register gets a Plugin::Api. What it
31
+ # registered takes effect only when #register returns: a plugin that
32
+ # fails adds nothing.
33
+ #
34
+ # A failure is logged and added to +failures+ (not required: tool calls
35
+ # still run), which the Engine announces once. Not fail-closed.
36
+ module Loader
37
+ TAG = :plugins
38
+
39
+ module_function
40
+
41
+ # @param registries [Registries]
42
+ # @param failures [Guardrails::LoadFailures, nil]
43
+ # @param settings [Hash{String => Hash}] config.yml `bundles:`
44
+ # @return [Array<String>] the bundles whose plugin loaded
45
+ def load_installed(registries, failures: nil, settings: {})
46
+ loaded = []
47
+ MemoryBundle::Provenance.each_installed_with_plugin do |bundle_name, data|
48
+ ok = load_bundle(bundle_name, data, registries, failures: failures, settings: settings[bundle_name.to_s] || {})
49
+ loaded << bundle_name if ok
50
+ end
51
+ loaded
52
+ rescue StandardError => e
53
+ Log.error(TAG, "plugins_load_failed", echo: "[samagotchi:plugins] failed to load bundle plugins: #{e.class}: #{e.message}",
54
+ error: e.class.name)
55
+ loaded || []
56
+ end
57
+
58
+ # @return [Boolean] whether the plugin loaded
59
+ def load_bundle(bundle_name, data, registries, failures: nil, settings: {})
60
+ file = MemoryBundle::Provenance.new(name: bundle_name).plugin_path(data) unless data[:error]
61
+ basename = file ? File.basename(file) : "plugin"
62
+ reason = data[:error] || unloadable_reason(file, data)
63
+ return failed(bundle_name, basename, reason, failures) if reason
64
+
65
+ api = nil
66
+ plugin = instantiate(bundle_name, file, settings)
67
+ plugin_label = label(bundle_name, basename)
68
+ api = Api.new(bundle: bundle_name, label: plugin_label, registries: registries,
69
+ context: registries.context_for&.call(bundle_name, settings, plugin_label))
70
+ plugin.register(api)
71
+ api.commit!
72
+ Log.info(TAG, "plugin_loaded", bundle: bundle_name, file: basename, **api.counts)
73
+ true
74
+ rescue Exception => e # rubocop:disable Lint/RescueException -- a plugin's SyntaxError or exit must not stop chi
75
+ api&.abort!
76
+ raise if e.is_a?(Interrupt)
77
+
78
+ failed(bundle_name, basename, "#{e.class}: #{e.message}", failures)
79
+ end
80
+
81
+ # What a plugin's hooks, notices and failures are named by.
82
+ def label(bundle_name, basename) = "#{basename} (bundle #{bundle_name})"
83
+
84
+ # Why the installed file can't be loaded, or nil.
85
+ def unloadable_reason(file, data)
86
+ failure = MemoryBundle::Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION)
87
+ return failure if failure
88
+ return "the file is missing" unless file && File.file?(file)
89
+
90
+ Hooks::BundleLoader.sha_mismatch(file, data[:plugin][:sha256], required: true)
91
+ end
92
+
93
+ # module_eval the file into a fresh module in the bundle's namespace
94
+ # (Samagotchi::Bundles::<bundle>::PluginLoad<n>: each load gets new
95
+ # classes, not the last load's reopened) and build its class
96
+ # (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as hooks are built:
97
+ # an initialize that takes an argument gets the settings.
98
+ def instantiate(bundle_name, file, settings)
99
+ namespace = fresh_namespace(bundle_name)
100
+ namespace.module_eval(File.read(file), file, 1)
101
+ class_name = File.basename(file, ".rb").split("_").map(&:capitalize).join
102
+ klass = namespace.const_get(class_name, false)
103
+ plugin = Hooks.build_plugin(klass, settings)
104
+ raise ArgumentError, "#{class_name} does not respond to #register" unless plugin.respond_to?(:register)
105
+
106
+ plugin
107
+ end
108
+
109
+ LOADS = Mutex.new
110
+
111
+ def fresh_namespace(bundle_name)
112
+ LOADS.synchronize do
113
+ @loads = (@loads || 0) + 1
114
+ Hooks::BundleLoader.namespace_for(bundle_name).const_set(:"PluginLoad#{@loads}", Module.new)
115
+ end
116
+ end
117
+
118
+ def failed(bundle_name, basename, reason, failures)
119
+ Log.warn(TAG, "plugin_not_loaded", echo: "[samagotchi:plugins] bundle '#{bundle_name}' plugin '#{basename}' not loaded: #{reason}",
120
+ bundle: bundle_name, file: basename)
121
+ failures&.add("plugin #{basename} (bundle #{bundle_name})", reason, required: false)
122
+ false
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require_relative "../log"
5
+
6
+ module Samagotchi
7
+ module Plugin
8
+ # A plugin's long-lived thing (a server process, a connection), from
9
+ # chi.service (docs/plugins.md, Services). Its block starts it: on
10
+ # first #value, or at register time with eager: true. What the block
11
+ # returns is #value. Stop callbacks (#on_stop, given inside the block)
12
+ # run when the Engine shuts down (Engine#shutdown), newest first.
13
+ class Service
14
+ # The service was stopped (the Engine shut down): it doesn't start
15
+ # again.
16
+ class Stopped < StandardError; end
17
+
18
+ # @return [String] "<bundle>:<name>"
19
+ attr_reader :name
20
+
21
+ def initialize(name, &start)
22
+ @name = name
23
+ @start = start
24
+ @mutex = Monitor.new
25
+ @state = :idle
26
+ @value = nil
27
+ @on_stop = []
28
+ end
29
+
30
+ # Start it if it hasn't started, and return what its block returned.
31
+ # A block that raises leaves it idle (its on_stop callbacks so far
32
+ # run), so the next use tries again.
33
+ # @raise [Stopped] after #stop
34
+ def value
35
+ @mutex.synchronize do
36
+ raise Stopped, "service #{@name} is stopped" if @state == :stopped
37
+ return @value if @state == :running
38
+
39
+ begin
40
+ @value = @start.call(self)
41
+ @state = :running
42
+ Log.info(:plugins, "service_started", service: @name)
43
+ @value
44
+ rescue Exception # rubocop:disable Lint/RescueException -- clean up, then re-raise as is
45
+ run_on_stop
46
+ raise
47
+ end
48
+ end
49
+ end
50
+ alias start value
51
+
52
+ # A callback for #stop, given in the start block (close the pipes,
53
+ # kill the process).
54
+ def on_stop(&block)
55
+ raise ArgumentError, "on_stop needs a block" unless block
56
+
57
+ @mutex.synchronize { @on_stop << block }
58
+ nil
59
+ end
60
+
61
+ # @return [Boolean]
62
+ def running? = @state == :running
63
+
64
+ # @return [Symbol] :idle, :running or :stopped
65
+ def state = @state
66
+
67
+ # Run the stop callbacks (newest first) once; a raise is logged, the
68
+ # rest still run. It never starts again after this.
69
+ def stop
70
+ @mutex.synchronize do
71
+ return if @state == :stopped
72
+
73
+ was = @state
74
+ @state = :stopped
75
+ @value = nil
76
+ run_on_stop
77
+ Log.info(:plugins, "service_stopped", service: @name) if was == :running
78
+ end
79
+ nil
80
+ end
81
+
82
+ private
83
+
84
+ def run_on_stop
85
+ callbacks = @on_stop.reverse
86
+ @on_stop = []
87
+ callbacks.each do |callback|
88
+ callback.call
89
+ rescue StandardError => e
90
+ Log.warn(:plugins, "service_stop_failed", service: @name, error: e.class.name, msg: e.message)
91
+ end
92
+ end
93
+ end
94
+
95
+ # An Engine's services, in the order they were registered. #stop_all
96
+ # stops them newest first.
97
+ class Services
98
+ def initialize
99
+ @list = []
100
+ @mutex = Mutex.new
101
+ end
102
+
103
+ def add(service)
104
+ @mutex.synchronize { @list << service }
105
+ service
106
+ end
107
+
108
+ # @return [Array<Service>]
109
+ def to_a = @mutex.synchronize { @list.dup }
110
+
111
+ def stop_all
112
+ to_a.reverse_each(&:stop)
113
+ nil
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../bridge_client"
5
+ require_relative "../bridge/turn_accumulator"
6
+ require_relative "../tools/delegate"
7
+
8
+ module Samagotchi
9
+ # Loaded on first use: session_manager requires terminal_ui, which
10
+ # requires the Engine and so the plugins (a require cycle otherwise).
11
+ autoload :SessionManager, File.expand_path("../session_manager", __dir__)
12
+
13
+ module Plugin
14
+ # ctx.sessions (docs/plugins.md, Sessions): other sessions, from a
15
+ # plugin. A fork is an ordinary chi session in its own worker, shown in
16
+ # every list as a child of this one (↳ parent).
17
+ class Sessions
18
+ # A fork, send or read that couldn't be done; the message says why.
19
+ class Error < StandardError; end
20
+
21
+ # @param host [Host] session_id, cwd, model_name and state_dir
22
+ def initialize(host)
23
+ @host = host
24
+ end
25
+
26
+ # Start a child session from +messages+ (usually ctx.messages plus
27
+ # more). With no prompt the child waits idle for the user; with one it
28
+ # runs it as its first turn, and counts against session.max_children.
29
+ # Each image a message names is copied into the child, or dropped with
30
+ # a note in its message when its file is gone.
31
+ # @param messages [Array<Hash>] the child's conversation to start with
32
+ # @param title [String, nil] what the lists show for it (the prompt, or
33
+ # the first user message, by default)
34
+ # @param prompt [String, nil] the child's first turn
35
+ # @return [String] the child's id
36
+ # @raise [Error] no session yet, or too many children running
37
+ def fork(messages:, title: nil, prompt: nil)
38
+ parent_id = @host.session_id.call or raise Error, "this session has no id yet"
39
+ state_dir = self.state_dir
40
+ prompt = prompt.to_s.strip.empty? ? nil : prompt.to_s
41
+ check_children(parent_id, state_dir) if prompt
42
+
43
+ child = SessionManager.spawn_session(
44
+ prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
45
+ parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, title: title,
46
+ images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir
47
+ )
48
+ child.id
49
+ end
50
+
51
+ # Send +text+ to session +id+ as a user message (it runs as a turn;
52
+ # a stopped session is woken). Waits up to 5 s for its worker, so call
53
+ # it from an anytime command or your own thread, never a tool or hook
54
+ # of a running turn.
55
+ # @return [String] the id of the session it went to
56
+ # @raise [Error] no such session, a REPL owns it, or it wasn't taken
57
+ def send(id, text)
58
+ raise Error, "nothing to send" if text.to_s.strip.empty?
59
+
60
+ state_dir = self.state_dir
61
+ sid = resolve(id, state_dir)
62
+ delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: "plugin", state_dir: state_dir)
63
+ case delivered[:status]
64
+ when :accepted then sid
65
+ when :refused then raise Error, "session #{sid[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
66
+ when :timeout then raise Error, "session #{sid[0, 8]}'s worker did not answer in time; the message was not sent"
67
+ else raise Error, "the message to session #{sid[0, 8]} could not be written"
68
+ end
69
+ rescue SessionManager::OwnedByTUI
70
+ raise Error, "session #{sid[0, 8]} is open in a chi REPL, which takes no messages from others"
71
+ end
72
+
73
+ # Session +id+ now: from its worker when one runs (with a running turn
74
+ # so far), else as saved.
75
+ # @return [Hash] {id:, title:, status:, parent_id:, running:, messages:}
76
+ # (messages without the system prompt, symbol keys)
77
+ # @raise [Error] no such session
78
+ def read(id)
79
+ state_dir = self.state_dir
80
+ sid = resolve(id, state_dir)
81
+ session = Session.load(sid, state_dir: state_dir)
82
+ messages = session.messages
83
+ running = false
84
+ if (live = live_snapshot(sid, state_dir))
85
+ messages = Array(live["messages"]).map { |message| symbolize_message(message) }
86
+ if (turn = live["current_turn"])
87
+ running = true
88
+ messages += Bridge::TurnAccumulator.messages_of(deep_symbolize(turn))
89
+ end
90
+ end
91
+ { id: sid, title: session.first_preview.to_s, status: session.status.to_s, parent_id: session.parent_id,
92
+ running: running, messages: without_system_head(messages) }
93
+ end
94
+
95
+ private
96
+
97
+ def state_dir = @host.state_dir&.call || Session.default_state_dir
98
+
99
+ def resolve(id, state_dir)
100
+ sid = Session.resolve_id(id.to_s.strip, state_dir: state_dir)
101
+ raise Error, "no session #{id}" unless sid && Session.exist?(sid, state_dir: state_dir)
102
+
103
+ sid
104
+ rescue Session::AmbiguousId, ArgumentError => e
105
+ raise Error, e.message
106
+ end
107
+
108
+ def check_children(parent_id, state_dir)
109
+ running = Tools::Delegate.running_children(parent_id, state_dir: state_dir)
110
+ max = Tools::Delegate.max_children
111
+ return if running.size < max
112
+
113
+ raise Error, "#{running.size} child sessions of this session are running (the most is #{max}, " \
114
+ "#{Tools::Delegate::MAX_CHILDREN_KEY}): #{running.map { |s| s[:short_id] }.join(", ")}"
115
+ end
116
+
117
+ def live_snapshot(sid, state_dir)
118
+ BridgeClient.discover(sid, session_dir: Session.session_dir(sid, state_dir: state_dir))&.get_json("snapshot")
119
+ rescue StandardError
120
+ nil
121
+ end
122
+
123
+ def without_system_head(messages)
124
+ first = messages.first
125
+ system = first && first[:role].to_s == "system" && first[:kind].to_s.empty?
126
+ system ? messages.drop(1) : messages
127
+ end
128
+
129
+ # A message a plugin got frozen (ctx.messages), as a session holds it.
130
+ def unfrozen(message)
131
+ message.to_h { |key, value| [key.to_sym, value.frozen? && value.is_a?(String) ? value.dup : value] }
132
+ end
133
+
134
+ # Symbol keys, as Session.load gives them (its image refs too).
135
+ def symbolize_message(message)
136
+ message = message.transform_keys(&:to_sym)
137
+ message[:images] = message[:images].map { |ref| ref.transform_keys(&:to_sym) } if message[:images].is_a?(Array)
138
+ message
139
+ end
140
+
141
+ def deep_symbolize(value)
142
+ case value
143
+ when Hash then value.to_h { |k, v| [k.to_sym, deep_symbolize(v)] }
144
+ when Array then value.map { |v| deep_symbolize(v) }
145
+ else value
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../idle_recap"
5
+
6
+ module Samagotchi
7
+ module Plugin
8
+ # The request behind ctx.ask_model (plan D7): the conversation as a
9
+ # transcript, filtered like the idle recap's (IdleRecap::TranscriptFilter:
10
+ # no system prompt, no tool calls or outputs, no thinking, an image as a
11
+ # line naming it), then the question. One user message holds both, so a
12
+ # conversation that ends on a user turn (a running turn's prompt) doesn't
13
+ # run into the question.
14
+ module SideQuestion
15
+ DEFAULT_SYSTEM = "You answer a question about the conversation below, on the side. Answer briefly and " \
16
+ "don't continue the conversation's task."
17
+ # The most transcript one request sends: the tail is kept.
18
+ MAX_TRANSCRIPT_CHARS = 32_000
19
+
20
+ module_function
21
+
22
+ # @param messages [Array<Hash>] the conversation (symbol or string keys)
23
+ # @param prompt [String] the question
24
+ # @param system [String, nil] instructions; DEFAULT_SYSTEM by default
25
+ # @return [Array<Hash>] chat messages: {role: "system"}, {role: "user"}
26
+ def request(messages:, prompt:, system: nil)
27
+ system = system.to_s.strip.empty? ? DEFAULT_SYSTEM : system.to_s
28
+ transcript = transcript(messages)
29
+ question = prompt.to_s.strip
30
+ user = if transcript.empty?
31
+ question
32
+ else
33
+ "<conversation>\n#{transcript}\n</conversation>\n\n#{question}"
34
+ end
35
+ [{ role: "system", content: system }, { role: "user", content: user }]
36
+ end
37
+
38
+ # "User: …" and "Assistant: …" paragraphs; the tail when it is long.
39
+ # @return [String]
40
+ def transcript(messages)
41
+ text = stringified(messages).filter_map do |message|
42
+ line = IdleRecap::TranscriptFilter.build([message])
43
+ next if line.strip.empty?
44
+
45
+ "#{message["role"] == "user" ? "User" : "Assistant"}: #{line}"
46
+ end.join("\n\n")
47
+ return text if text.length <= MAX_TRANSCRIPT_CHARS
48
+
49
+ "[earlier conversation left out]\n\n#{text[-MAX_TRANSCRIPT_CHARS..]}"
50
+ end
51
+
52
+ # String keys, as TranscriptFilter reads a saved session.
53
+ def stringified(messages)
54
+ JSON.parse(JSON.generate(Array(messages)))
55
+ rescue StandardError
56
+ []
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Plugin
5
+ # What a plugin tool's block returns to hand the model images too
6
+ # (docs/plugins.md, Returning images):
7
+ #
8
+ # Samagotchi::Plugin::ToolResult.new("took a screenshot", images: [{ path: "/tmp/shot.png" }])
9
+ # Samagotchi::Plugin::ToolResult.new("two frames", images: [{ bytes: png, name: "frame1.png" }, …])
10
+ #
11
+ # It is the text itself (a String), so everything that reads a tool's
12
+ # text keeps working; #images is what ToolRunner attaches. An entry
13
+ # is {path:} or {bytes:, name:} (raw bytes, not base64); one that isn't
14
+ # becomes an "Error:" line for that image, the rest still go.
15
+ class ToolResult < String
16
+ attr_reader :images
17
+
18
+ def initialize(text = "", images: [])
19
+ @images = (images.is_a?(Hash) ? [images] : Array(images)).freeze
20
+ super(text.to_s)
21
+ end
22
+ end
23
+ end
24
+ end