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,631 @@
1
+ # The mcp bundle (docs/plugins.md, The mcp bundle): tools from MCP servers.
2
+ # Each server in config.yml runs as a child process (stdio only in v1) for
3
+ # the session's life; its tools are the model's as mcp_<server>_<tool>.
4
+ #
5
+ # bundles:
6
+ # mcp:
7
+ # timeout: 60 # seconds per tool call (default 60)
8
+ # startup_timeout: 10 # seconds for initialize and tools/list (default 10)
9
+ # servers:
10
+ # everything:
11
+ # command: [npx, -y, "@modelcontextprotocol/server-everything"]
12
+ # env: {DEBUG: "0"} # added to chi's environment
13
+ # cwd: ~/scratch # default: where chi runs
14
+ # tools: [echo, add] # optional: only these (globs work)
15
+ # timeout: 120 # optional: this server's per-call timeout
16
+ # attach_image_paths: true # default: a result that is only the path of an
17
+ # # image in the temp dir or cwd attaches it
18
+ #
19
+ # Image blocks in a result go to the model as images (text: "[image 1:
20
+ # image/png, attached]"); so does a text block that is only the absolute
21
+ # path of an image file under the system temp dir or the server's cwd
22
+ # (chrome-devtools-mcp --slim answers a screenshot that way).
23
+ # A server that doesn't start, answer or list its tools is skipped with a
24
+ # notice; the rest of chi works. /mcp lists the servers and their tools.
25
+ require "digest"
26
+ require "json"
27
+ require "open3"
28
+ require "time"
29
+ require "shellwords"
30
+ require "tmpdir"
31
+
32
+ class Plugin
33
+ PROTOCOL_VERSION = "2025-06-18"
34
+ CALL_TIMEOUT = 60
35
+ STARTUP_TIMEOUT = 10
36
+ DESCRIPTION_CHARS = 1024
37
+ PREVIEW_CHARS = 60
38
+ NAME_CHARS = 48
39
+ # A cached tool list older than this is refreshed in the background.
40
+ CACHE_TTL = 24 * 60 * 60
41
+ IMAGE_EXT = { "image/png" => "png", "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp" }.freeze
42
+
43
+ # A JSON-RPC client for one MCP server over stdio: newline-delimited JSON
44
+ # on the process's stdin and stdout; stderr goes to the log.
45
+ class Client
46
+ # The request failed: an error answer, a timeout, or the server is gone.
47
+ class Error < StandardError; end
48
+ class Timeout < Error; end
49
+ class Cancelled < Error; end
50
+ # The server's process ended (or never started).
51
+ class Dead < Error; end
52
+
53
+ POLL_SECONDS = 0.1
54
+
55
+ attr_reader :pid
56
+
57
+ # @param command [Array<String>]
58
+ # @param env [Hash] added to the environment
59
+ # @param cwd [String]
60
+ # @param log [#call] (event, fields) for stderr lines and protocol noise
61
+ # @param on_exit [#call, nil] called once, with the reason, when the
62
+ # process ends while the client is open
63
+ def initialize(command, env: {}, cwd: Dir.pwd, log: ->(*) {}, on_exit: nil)
64
+ @log = log
65
+ @on_exit = on_exit
66
+ @mutex = Mutex.new
67
+ @write_mutex = Mutex.new
68
+ @pending = {}
69
+ @next_id = 0
70
+ @dead = nil
71
+ @closing = false
72
+ @stdin, @stdout, @stderr, @wait = Open3.popen3(env.to_h { |k, v| [k.to_s, v.to_s] }, *command,
73
+ chdir: cwd, pgroup: true)
74
+ @pid = @wait.pid
75
+ @reader = Thread.new { read_loop }
76
+ @err_reader = Thread.new { stderr_loop }
77
+ rescue SystemCallError => e
78
+ raise Dead, "can't start #{command.first}: #{e.message.sub(/ - .*\z/m, "")}"
79
+ end
80
+
81
+ # @return [String, nil] why the server is gone, or nil while it runs
82
+ def dead = @mutex.synchronize { @dead }
83
+
84
+ # Send a request and wait for its answer.
85
+ # @param cancelled [#call, nil] polled while waiting; true sends
86
+ # notifications/cancelled and raises Cancelled
87
+ # @return [Hash] the result
88
+ def request(method, params = nil, timeout:, cancelled: nil)
89
+ queue = Queue.new
90
+ id = @mutex.synchronize do
91
+ raise Dead, @dead if @dead
92
+
93
+ @next_id += 1
94
+ @pending[@next_id] = queue
95
+ @next_id
96
+ end
97
+ write({ jsonrpc: "2.0", id: id, method: method, params: params }.compact)
98
+ deadline = monotonic + timeout
99
+ loop do
100
+ left = deadline - monotonic
101
+ if left <= 0
102
+ cancel(id, "timed out")
103
+ raise Timeout, "#{method} timed out after #{format("%g", timeout)}s"
104
+ end
105
+ if cancelled&.call
106
+ cancel(id, "cancelled by the user")
107
+ raise Cancelled, "#{method} was cancelled"
108
+ end
109
+ message = queue.pop(timeout: [left, POLL_SECONDS].min)
110
+ next unless message
111
+ raise Dead, message[:dead] if message[:dead]
112
+ if (error = message["error"])
113
+ raise Error, "#{error["message"] || "error"} (#{error["code"]})"
114
+ end
115
+
116
+ return message["result"] || {}
117
+ end
118
+ ensure
119
+ @mutex.synchronize { @pending.delete(id) } if id
120
+ end
121
+
122
+ def notify(method, params = nil)
123
+ write({ jsonrpc: "2.0", method: method, params: params }.compact)
124
+ end
125
+
126
+ # End the process: stdin closed first (most servers leave then), then
127
+ # TERM and KILL to its process group.
128
+ def close
129
+ @mutex.synchronize { @closing = true }
130
+ [@stdin].each { |io| io.close unless io.closed? }
131
+ unless @wait.join(1)
132
+ signal("TERM")
133
+ signal("KILL") unless @wait.join(1)
134
+ @wait.join(1)
135
+ end
136
+ [@stdout, @stderr].each { |io| io.close unless io.closed? }
137
+ [@reader, @err_reader].each { |t| t.join(1) }
138
+ nil
139
+ rescue IOError
140
+ nil
141
+ end
142
+
143
+ private
144
+
145
+ def write(message)
146
+ line = JSON.generate(message)
147
+ @write_mutex.synchronize do
148
+ @stdin.write(line, "\n")
149
+ @stdin.flush
150
+ end
151
+ rescue IOError, SystemCallError => e
152
+ raise Dead, dead || "the server's stdin is closed (#{e.class})"
153
+ end
154
+
155
+ def cancel(id, reason)
156
+ notify("notifications/cancelled", { requestId: id, reason: reason })
157
+ rescue Dead
158
+ nil
159
+ end
160
+
161
+ def read_loop
162
+ @stdout.each_line do |line|
163
+ next if line.strip.empty?
164
+
165
+ message = begin
166
+ JSON.parse(line)
167
+ rescue JSON::ParserError
168
+ @log.call("bad_line", line: line[0, 200])
169
+ next
170
+ end
171
+ dispatch(message) if message.is_a?(Hash)
172
+ end
173
+ rescue IOError
174
+ nil
175
+ ensure
176
+ ended
177
+ end
178
+
179
+ def dispatch(message)
180
+ if message.key?("method")
181
+ # A request from the server (ping, roots/list, …): ping is answered,
182
+ # the rest aren't supported. A notification is logged.
183
+ return @log.call("server_notification", method: message["method"]) unless message.key?("id")
184
+
185
+ answer = if message["method"] == "ping"
186
+ { jsonrpc: "2.0", id: message["id"], result: {} }
187
+ else
188
+ { jsonrpc: "2.0", id: message["id"], error: { code: -32_601, message: "not supported by chi" } }
189
+ end
190
+ begin
191
+ write(answer)
192
+ rescue Dead
193
+ nil
194
+ end
195
+ else
196
+ queue = @mutex.synchronize { @pending[message["id"]] }
197
+ queue&.push(message)
198
+ end
199
+ end
200
+
201
+ def stderr_loop
202
+ @stderr.each_line { |line| @log.call("stderr", line: line.chomp[0, 500]) }
203
+ rescue IOError
204
+ nil
205
+ end
206
+
207
+ def ended
208
+ status = @wait.value
209
+ reason = "the server exited (#{status.exitstatus ? "status #{status.exitstatus}" : "signal #{status.termsig}"})"
210
+ waiting, closing = @mutex.synchronize do
211
+ @dead ||= reason
212
+ [@pending.values, @closing]
213
+ end
214
+ waiting.each { |queue| queue.push({ dead: reason }) }
215
+ @on_exit&.call(reason) unless closing
216
+ end
217
+
218
+ def signal(name)
219
+ Process.kill(name, -@pid)
220
+ rescue Errno::ESRCH, Errno::EPERM
221
+ nil
222
+ end
223
+
224
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
225
+ end
226
+
227
+ # One configured server: its service, state and tools. +listed+ is its
228
+ # tools/list as the server answers it (what the cache keeps), +tools+
229
+ # the ones chi offers (the tools: filter applied, each with chi_name).
230
+ Server = Struct.new(:name, :config, :service, :state, :error, :tools, :listed, :timeout, :cwd, :command, :env,
231
+ :digest, :cached_at, keyword_init: true)
232
+
233
+ def initialize(settings = {})
234
+ @settings = settings
235
+ @timeout = positive(settings["timeout"]) || CALL_TIMEOUT
236
+ @startup_timeout = positive(settings["startup_timeout"]) || STARTUP_TIMEOUT
237
+ @servers = []
238
+ @publish = Mutex.new
239
+ # The tools left out so far (a name clash): said once, not at each
240
+ # publish.
241
+ @left_out = []
242
+ end
243
+
244
+ # Each server's tools come from its cache (tools-<server>.json in the
245
+ # bundle's data dir, keyed by a digest of its config) when there is one:
246
+ # the server then starts on the first call of one of its tools (start:
247
+ # lazy, the default), and a cache older than a day is refreshed quietly
248
+ # in the background. Without a cache (the first run, a changed config)
249
+ # the server starts in an init task (chi.init) that every UI shows; a
250
+ # turn sent meanwhile waits for its tools. start: eager starts it with
251
+ # every session.
252
+ def register(chi)
253
+ @chi = chi
254
+ ctx = chi.ctx
255
+ configs = @settings["servers"]
256
+ configs = {} unless configs.is_a?(Hash)
257
+ @servers = configs.map do |name, config|
258
+ server = Server.new(name: name.to_s, config: config.is_a?(Hash) ? config : {}, state: :starting, tools: [])
259
+ server.timeout = positive(server.config["timeout"]) || @timeout
260
+ resolve(server, ctx)
261
+ server.service = chi.service(name) { |svc| start(server, svc, ctx) }
262
+ server
263
+ end
264
+ # Two startup_timeouts: initialize, then tools/list.
265
+ wait = @startup_timeout * 2
266
+ @servers.each do |server|
267
+ # A failure's card title, short: the card shows "mcp" beside it and
268
+ # the error in its body.
269
+ failed = "#{server.name} didn't start"
270
+ if load_cache(server, ctx)
271
+ declare(chi, server, ctx)
272
+ if server.config["start"].to_s == "eager"
273
+ chi.init("Starting MCP server #{server.name}", timeout: wait, failed: failed) { boot(server, ctx) }
274
+ elsif server.cached_at.nil? || Time.now - server.cached_at > CACHE_TTL
275
+ chi.init("Refreshing MCP server #{server.name}'s tools", quiet: true, timeout: wait,
276
+ failed: "#{server.name}'s tools weren't refreshed") do
277
+ refresh(server, ctx)
278
+ end
279
+ end
280
+ else
281
+ why = File.exist?(cache_path(server, ctx)) ? "config changed" : "first run"
282
+ chi.init("Starting MCP server #{server.name} (#{why}, saving its tools)", provides_tools: true, timeout: wait,
283
+ failed: failed) do
284
+ boot(server, ctx)
285
+ end
286
+ end
287
+ end
288
+ chi.command "/mcp", "list the MCP servers, their state and their tools", anytime: true do |_args, command_ctx|
289
+ command_ctx.card(title: "MCP servers", body: listing, id: "mcp-servers")
290
+ nil
291
+ end
292
+ end
293
+
294
+ private
295
+
296
+ # The command, env and cwd the server runs with, and their digest (the
297
+ # cache's key: command, args, env names and values, and the cwd; the
298
+ # env itself is never stored).
299
+ def resolve(server, ctx)
300
+ command = server.config["command"]
301
+ command = Shellwords.split(command) if command.is_a?(String)
302
+ server.command = Array(command).map(&:to_s)
303
+ env = server.config["env"].is_a?(Hash) ? server.config["env"] : {}
304
+ server.env = env.to_h { |key, value| [key.to_s, value.to_s] }
305
+ server.cwd = server.config["cwd"] ? File.expand_path(server.config["cwd"].to_s) : ctx.cwd
306
+ server.digest = Digest::SHA256.hexdigest(JSON.generate([server.command, server.env.sort, server.cwd]))
307
+ end
308
+
309
+ # The service's start: spawn, initialize, list the tools (cancelled with
310
+ # the turn that waits for it), then cache the list.
311
+ def start(server, svc, ctx)
312
+ raise Client::Error, "no command (bundles: mcp: servers: #{server.name}: command: [...])" if server.command.empty?
313
+
314
+ cancelled = -> { ctx.cancelled? }
315
+ client = Client.new(server.command, env: server.env, cwd: server.cwd,
316
+ log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) },
317
+ on_exit: ->(reason) { exited(server, reason, ctx) })
318
+ svc.on_stop { client.close }
319
+ client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
320
+ clientInfo: { name: "chi", version: Samagotchi::VERSION } },
321
+ timeout: @startup_timeout, cancelled: cancelled)
322
+ client.notify("notifications/initialized")
323
+ listed = list_tools(client, cancelled)
324
+ changed = listed != server.listed
325
+ server.listed = listed
326
+ save_cache(server, ctx) if changed
327
+ client
328
+ end
329
+
330
+ # Start a server in an init task; its tools replace the cached ones (or
331
+ # come for the first time) when they differ.
332
+ # @return [String] the task's summary
333
+ # @raise [Client::Error] it didn't start (the task's warn card says why)
334
+ def boot(server, ctx)
335
+ before = server.listed
336
+ client = server.service.value
337
+ server.state = :running
338
+ ctx.log.info("mcp_server_started", server: server.name, pid: client.pid, tools: server.listed.size)
339
+ publish(ctx) if server.listed != before
340
+ count = server.listed.size
341
+ "#{server.name} ready, #{count} tool#{"s" unless count == 1}"
342
+ rescue StandardError => e
343
+ had_tools = server.state == :cached
344
+ server.state = :failed
345
+ server.error = e.message
346
+ ctx.log.warn("mcp_server_failed", server: server.name, error: e.class.name, msg: e.message)
347
+ publish(ctx) if had_tools
348
+ raise Client::Error, "MCP server #{server.name} didn't start: #{e.message}; its tools are left out"
349
+ end
350
+
351
+ # The quiet daily refresh of a cached server's list: a server of its own
352
+ # (not the session's: that one still starts on the first call), listed
353
+ # and stopped. The cache is rewritten (its clock too) and a changed list
354
+ # replaces the tools. One worker at a time (a lock file); the others skip.
355
+ def refresh(server, ctx)
356
+ File.open("#{cache_path(server, ctx)}.lock", File::CREAT | File::RDWR) do |lock|
357
+ return nil unless lock.flock(File::LOCK_EX | File::LOCK_NB)
358
+
359
+ cancelled = -> { ctx.cancelled? }
360
+ client = Client.new(server.command, env: server.env, cwd: server.cwd,
361
+ log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) })
362
+ begin
363
+ client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
364
+ clientInfo: { name: "chi", version: Samagotchi::VERSION } },
365
+ timeout: @startup_timeout, cancelled: cancelled)
366
+ client.notify("notifications/initialized")
367
+ listed = list_tools(client, cancelled)
368
+ ensure
369
+ client.close
370
+ end
371
+ # Started meanwhile: its own list is the fresh one.
372
+ return nil unless server.state == :cached
373
+
374
+ changed = listed != server.listed
375
+ server.listed = listed
376
+ save_cache(server, ctx)
377
+ ctx.log.info("mcp_tools_refreshed", server: server.name, tools: listed.size, changed: changed)
378
+ publish(ctx) if changed
379
+ end
380
+ nil
381
+ end
382
+
383
+ # tools/list, every page.
384
+ def list_tools(client, cancelled)
385
+ tools = []
386
+ cursor = nil
387
+ 20.times do
388
+ result = client.request("tools/list", cursor ? { cursor: cursor } : nil, timeout: @startup_timeout,
389
+ cancelled: cancelled)
390
+ tools.concat(Array(result["tools"]).select { |tool| tool.is_a?(Hash) && tool["name"] })
391
+ cursor = result["nextCursor"]
392
+ break unless cursor
393
+ end
394
+ tools
395
+ end
396
+
397
+ # ── The tools/list cache ──────────────────────────────────────────────
398
+
399
+ # Where the server's cache is: tools-<server>.json, the name sanitized.
400
+ def cache_path(server, ctx)
401
+ File.join(ctx.data_dir, "tools-#{server.name.gsub(/[^A-Za-z0-9_.-]+/, "_")}.json")
402
+ end
403
+
404
+ # Take the server's tools from its cache: true when it holds this
405
+ # config's list (the server is then :cached, not started).
406
+ def load_cache(server, ctx)
407
+ data = JSON.parse(File.read(cache_path(server, ctx)))
408
+ return false unless data.is_a?(Hash) && data["digest"] == server.digest && data["tools"].is_a?(Array)
409
+
410
+ server.listed = data["tools"].select { |tool| tool.is_a?(Hash) && tool["name"] }
411
+ server.cached_at = begin
412
+ Time.iso8601(data["saved_at"].to_s)
413
+ rescue ArgumentError
414
+ nil
415
+ end
416
+ server.state = :cached
417
+ true
418
+ rescue SystemCallError, JSON::ParserError
419
+ false
420
+ end
421
+
422
+ # Written aside and renamed: workers share the dir.
423
+ def save_cache(server, ctx)
424
+ path = cache_path(server, ctx)
425
+ tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
426
+ File.write(tmp, JSON.generate({ digest: server.digest, saved_at: Time.now.utc.iso8601, tools: server.listed }))
427
+ File.rename(tmp, path)
428
+ rescue SystemCallError => e
429
+ ctx.log.warn("mcp_cache_not_written", server: server.name, msg: e.message)
430
+ end
431
+
432
+ # ── Tools ─────────────────────────────────────────────────────────────
433
+
434
+ # Declare the server's tools on +target+ (chi at load, or the set of
435
+ # chi.replace_tools later), the tools: filter applied.
436
+ def declare(target, server, ctx)
437
+ wanted = server.config["tools"] && Array(server.config["tools"]).map(&:to_s)
438
+ tools = server.listed.map(&:dup)
439
+ tools = tools.select { |tool| wanted.any? { |w| File.fnmatch(w, tool["name"], File::FNM_EXTGLOB) } } if wanted
440
+ server.tools = tools
441
+ tools.each do |tool|
442
+ name = tool_name(server.name, tool["name"])
443
+ target.tool(name, description(tool), schema: tool["inputSchema"] || { "type" => "object", "properties" => {} },
444
+ label: "#{server.name}: #{tool["name"]}", preview: ->(args) { preview(args) }) do |args, call_ctx|
445
+ call(server, tool["name"], args, call_ctx)
446
+ end
447
+ tool["chi_name"] = name
448
+ rescue ArgumentError => e
449
+ text = "MCP tool #{server.name}/#{tool["name"]} left out: #{e.message}"
450
+ ctx.notify(text, level: :warn) unless @left_out.include?(text)
451
+ @left_out << text
452
+ end
453
+ end
454
+
455
+ # The servers' tools changed after load (a live list that differs from
456
+ # the cache, a server that didn't start): the whole set again, for the
457
+ # next turn.
458
+ def publish(ctx)
459
+ @publish.synchronize do
460
+ @chi.replace_tools do |set|
461
+ @servers.each { |server| declare(set, server, ctx) if %i[running cached].include?(server.state) }
462
+ end
463
+ end
464
+ end
465
+
466
+ # mcp_<server>_<tool> in the tool name rule: a-z, 0-9 and _, at most 48.
467
+ def tool_name(server, tool)
468
+ "mcp_#{server}_#{tool}".downcase.gsub(/[^a-z0-9_]+/, "_").squeeze("_")[0, NAME_CHARS].sub(/_+\z/, "")
469
+ end
470
+
471
+ def description(tool)
472
+ text = tool["description"].to_s.strip
473
+ text = tool["title"].to_s if text.empty?
474
+ text.length > DESCRIPTION_CHARS ? "#{text[0, DESCRIPTION_CHARS - 1]}…" : text
475
+ end
476
+
477
+ def preview(args)
478
+ line = args.map { |key, value| "#{key}=#{value.is_a?(String) ? value : JSON.generate(value)}" }.join(" ")
479
+ line = line.gsub(/\s+/, " ")
480
+ line.length > PREVIEW_CHARS ? "#{line[0, PREVIEW_CHARS - 1]}…" : line
481
+ end
482
+
483
+ # A tools/call, as the model's tool result. A cached server starts
484
+ # here, on its first call.
485
+ def call(server, tool, args, ctx)
486
+ return "Error: MCP server #{server.name} didn't start: #{server.error}" if server.state == :failed
487
+
488
+ client = server.state == :cached ? lazy_start(server, ctx) : server.service.value
489
+ return client if client.is_a?(String)
490
+
491
+ result = client.request("tools/call", { name: tool, arguments: args }, timeout: server.timeout,
492
+ cancelled: -> { ctx.cancelled? })
493
+ text, images = content(result, server, tool)
494
+ return "Error: #{text.empty? ? "the tool failed" : text}" if result["isError"]
495
+
496
+ images.empty? ? text : Samagotchi::Plugin::ToolResult.new(text, images: images)
497
+ rescue Client::Dead => e
498
+ "Error: MCP server #{server.name} is not running (#{e.message})"
499
+ rescue Client::Error, Samagotchi::Plugin::Service::Stopped => e
500
+ "Error: #{e.message}"
501
+ end
502
+
503
+ # Start a cached server for a call. The live list replaces the cached
504
+ # one for the next turn when it differs. A start that fails marks the
505
+ # server failed (one notice; its calls answer at once, and its tools go
506
+ # next turn); a cancelled one leaves it cached for the next call.
507
+ # @return [Client, String] the client, or the call's error text
508
+ def lazy_start(server, ctx)
509
+ cached = server.listed
510
+ client = server.service.value
511
+ started = server.state == :cached
512
+ server.state = :running
513
+ if started
514
+ ctx.log.info("mcp_server_started", server: server.name, pid: client.pid, tools: server.listed.size, lazy: true)
515
+ publish(ctx) if server.listed != cached
516
+ end
517
+ client
518
+ rescue Client::Cancelled => e
519
+ "Error: #{e.message}"
520
+ rescue Samagotchi::Plugin::Service::Stopped => e
521
+ "Error: #{e.message}"
522
+ rescue StandardError => e
523
+ raise unless server.state == :cached
524
+
525
+ server.state = :failed
526
+ server.error = e.message
527
+ ctx.log.warn("mcp_server_failed", server: server.name, error: e.class.name, msg: e.message, lazy: true)
528
+ ctx.notify("MCP server #{server.name} didn't start: #{e.message}; its tools are left out from the next turn",
529
+ level: :warn)
530
+ publish(ctx)
531
+ "Error: MCP server #{server.name} didn't start: #{e.message}"
532
+ end
533
+
534
+ # The answer's text, and the images to attach: its image blocks, and a
535
+ # text block that is only an image's path (#image_path).
536
+ # @return [Array(String, Array<Hash>)]
537
+ def content(result, server, tool)
538
+ blocks = Array(result["content"])
539
+ return [JSON.generate(result["structuredContent"]), []] if blocks.empty? && result["structuredContent"]
540
+
541
+ images = []
542
+ text = blocks.map do |block|
543
+ case block["type"]
544
+ when "text"
545
+ line = block["text"].to_s
546
+ path = image_path(line, server)
547
+ next line unless path
548
+
549
+ images << { path: path, name: File.basename(path) }
550
+ "#{line}\n[image #{images.size}: #{File.basename(path)}, attached]"
551
+ when "image"
552
+ mime = block["mimeType"] || "unknown type"
553
+ bytes = block["data"].to_s.unpack1("m")
554
+ next "[image: #{mime}, empty]" if bytes.empty?
555
+
556
+ images << { bytes: bytes, name: "#{tool}-#{images.size + 1}.#{IMAGE_EXT.fetch(mime, "img")}" }
557
+ "[image #{images.size}: #{mime}, attached]"
558
+ when "audio" then "[audio: #{block["mimeType"] || "unknown type"}]"
559
+ when "resource"
560
+ resource = block["resource"] || {}
561
+ resource["text"] || "[resource: #{resource["uri"]}]"
562
+ when "resource_link" then "[resource link: #{block["uri"]}]"
563
+ else "[#{block["type"] || "unknown"} content]"
564
+ end
565
+ end.join("\n")
566
+ [text, images]
567
+ end
568
+
569
+ # The path when +text+ is only the absolute path of an image file under
570
+ # the system temp dir or the server's cwd (a server's text can't pull
571
+ # in any image on disk), and the server's attach_image_paths isn't off.
572
+ def image_path(text, server)
573
+ return nil if server.config["attach_image_paths"] == false
574
+
575
+ path = text.strip
576
+ return nil unless path.start_with?("/") && !path.include?("\n") && File.file?(path)
577
+
578
+ real = File.realpath(path)
579
+ roots = [Dir.tmpdir, server.cwd].compact.map { |dir| File.realpath(dir) rescue nil }.compact
580
+ return nil unless roots.any? { |root| real.start_with?(root.end_with?("/") ? root : "#{root}/") }
581
+
582
+ image_magic?(real) ? real : nil
583
+ rescue SystemCallError
584
+ nil
585
+ end
586
+
587
+ # png, jpeg, gif or webp by the file's first bytes.
588
+ def image_magic?(path)
589
+ head = File.binread(path, 12).to_s.b
590
+ head.start_with?("\x89PNG\r\n\x1a\n".b, "\xFF\xD8\xFF".b, "GIF87a", "GIF89a") ||
591
+ (head.start_with?("RIFF") && head[8, 4] == "WEBP")
592
+ end
593
+
594
+ # The process ended while chi runs: one notice; the calls say so.
595
+ def exited(server, reason, ctx)
596
+ return unless server.state == :running
597
+
598
+ server.state = :exited
599
+ server.error = reason
600
+ ctx.log.warn("mcp_server_exited", server: server.name, msg: reason)
601
+ ctx.notify("MCP server #{server.name} stopped: #{reason}; its tools fail until chi restarts", level: :warn)
602
+ end
603
+
604
+ def listing
605
+ return "No servers. Add them in config.yml under `bundles: mcp: servers:` (docs/plugins.md, The mcp bundle)." if @servers.empty?
606
+
607
+ @servers.map do |server|
608
+ head = "**#{server.name}**: #{state_text(server)}"
609
+ tools = server.tools.map { |tool| tool["chi_name"] }.compact
610
+ tools.empty? ? head : "#{head}\n#{tools.map { |t| "- `#{t}`" }.join("\n")}"
611
+ end.join("\n\n")
612
+ end
613
+
614
+ def state_text(server)
615
+ case server.state
616
+ when :cached then "cached (not started), #{server.tools.size} tool#{"s" unless server.tools.size == 1}"
617
+ when :running
618
+ count = server.tools.count { |tool| tool["chi_name"] }
619
+ "running (pid #{server.service.value.pid}), #{count} tool#{"s" unless count == 1}"
620
+ when :starting then "starting"
621
+ else "#{server.state == :failed ? "failed" : "stopped"}: #{server.error}"
622
+ end
623
+ rescue Samagotchi::Plugin::Service::Stopped
624
+ "stopped"
625
+ end
626
+
627
+ def positive(value)
628
+ number = Float(value.to_s, exception: false)
629
+ number&.positive? ? number : nil
630
+ end
631
+ end