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
data/docs/plugins.md ADDED
@@ -0,0 +1,819 @@
1
+ # Plugins
2
+
3
+ A bundle can ship one Ruby file, its **plugin**, that adds slash commands,
4
+ tools, hooks and background setup to every session. Its hooks are ordinary
5
+ bundle hooks ([hooks.md](hooks.md)). Its commands and tools work like chi's
6
+ own.
7
+
8
+ This is the first version of the plugin API. More of it comes later: see
9
+ [Not yet](#not-yet).
10
+
11
+ ## A bundle with a plugin
12
+
13
+ ```
14
+ my-bundle/
15
+ manifest.yml
16
+ plugin.rb
17
+ identity.md # optional, like any bundle's memories, hooks/ and guardrails/
18
+ ```
19
+
20
+ ```yaml
21
+ # manifest.yml
22
+ name: my-bundle
23
+ version: 1.0.0
24
+ plugin:
25
+ file: plugin.rb
26
+ sha256: sha256:6a1a7022… # shasum -a 256 plugin.rb
27
+ requires_chi: ">= 0.1.28" # optional: a gem-style requirement (">= 0.1.28, < 0.2")
28
+ needs: [gh] # optional: outside commands it relies on (see memory.md#bundles-that-need-outside-commands)
29
+ ```
30
+
31
+ The file must be a `.rb` name in the bundle's top directory. It defines a
32
+ class named like the file (`plugin.rb` → `Plugin`, `my_plugin.rb` →
33
+ `MyPlugin`), and that class has a `register(chi)` method:
34
+
35
+ ```ruby
36
+ # plugin.rb
37
+ class Plugin
38
+ def initialize(settings) # optional: config.yml bundles: my-bundle:
39
+ @greeting = settings.fetch("greeting", "hello")
40
+ end
41
+
42
+ def register(chi)
43
+ chi.command "/hello", "greet, and say what the plugin sees" do |args, ctx|
44
+ who = args.empty? ? "there" : args
45
+ ctx.card(id: "hello", title: "#{@greeting}, #{who}",
46
+ body: "This session has **#{ctx.messages.size}** messages.",
47
+ actions: [{ label: "Again", command: "/hello again" }])
48
+ "#{@greeting}, #{who} (#{ctx.messages.size} messages)"
49
+ end
50
+
51
+ chi.tool "echo_args", "Echo the arguments back.",
52
+ params: { text: { type: "string", description: "Any text to echo", required: true } },
53
+ label: "echoing" do |args, _ctx|
54
+ "echo: #{args["text"]}"
55
+ end
56
+
57
+ chi.on(:after_turn) do |_event, ctx|
58
+ File.open(File.join(ctx.data_dir, "turns.log"), "a") { |f| f.puts(ctx.session_id) }
59
+ end
60
+ end
61
+ end
62
+ ```
63
+
64
+ `spec/fixtures/sample_plugin_bundle` is this bundle, plus the `/hello-slow`
65
+ of [anytime](#anytime-true) and the `save_note` tool (see `chi.tool` below). Install it with
66
+ `chi bundle install spec/fixtures/sample_plugin_bundle`.
67
+
68
+ Settings work as they do for hooks ([hooks.md](hooks.md#settings)). An
69
+ `initialize` that takes an argument gets the bundle's section of config.yml
70
+ `bundles:`, as one Hash with string keys.
71
+
72
+ ## The API: `register(chi)`
73
+
74
+ ### `chi.command(name, description, anytime: false) { |args, ctx| … }`
75
+
76
+ This adds a slash command. `name` is `/name` (a–z, 0–9, `_` and `-`).
77
+ `args` is the text after the name, stripped, or `""` when there is none. The
78
+ block returns the text to show (a String), or nil to show nothing. If the
79
+ block raises, the user sees `/name: <error>`.
80
+
81
+ A plugin command works in all three UIs:
82
+
83
+ - **REPL** (`chi --no-shared`): typed at the prompt, and Tab completes it.
84
+ - **Attached TUI** (the default `chi`): the worker's snapshot names the
85
+ session's commands, so the TUI sends `/hello` to the worker and Tab
86
+ completes it. It needs a worker started after the bundle was installed.
87
+ - **Web**: typed in the composer; a `/` opens a list of the session's
88
+ commands (arrows move, ⏎ or Tab picks, Esc closes). A card's action button
89
+ runs it too.
90
+
91
+ A line that is not a known command keeps its old meaning: in the terminal
92
+ UIs `/foo` goes to the model as a prompt; the web refuses it and names the
93
+ commands it knows.
94
+
95
+ #### `anytime: true`
96
+
97
+ A normal command waits for its turn: typed while a turn runs, it is refused
98
+ as busy (the REPL puts it back into the prompt). An `anytime: true` command
99
+ runs **at once, on its own thread, beside the running turn**, and the turn
100
+ goes on:
101
+
102
+ - In a worker (attached, web) it runs as soon as it arrives. It is never
103
+ queued, so it is never busy, mid-turn or at the turn's end. The UIs show
104
+ its line when it arrives (its `command_queued` says `anytime: true`),
105
+ then its cards and notices **as it shows them**, and its output (the
106
+ `command_ran`) when it finishes. So a card can say "working…" first and
107
+ be replaced by the result.
108
+ - In the REPL, typed while a turn runs, it starts on a thread. Its cards
109
+ print as it shows them, above the live region; its output prints there
110
+ too while the turn runs, or at the prompt once the turn has ended.
111
+
112
+ Between turns an anytime command runs like any other, except that its
113
+ cards print as it shows them rather than after its output.
114
+
115
+ An anytime command's cards and notices belong to the command, never to the
116
+ running turn: they are not rows of the turn's step, and their events carry
117
+ `anytime: true`.
118
+
119
+ Its block runs on another thread than the turn, so it must be thread-safe:
120
+
121
+ - Read the conversation through `ctx.messages`, a frozen copy. While a turn
122
+ runs, a worker's holds that turn so far; the REPL's is the conversation
123
+ **before** that turn (`ctx.messages_partial?` says so).
124
+ - Show things only through `ctx` (`ctx.card`, `ctx.notify`), and return
125
+ the text to show.
126
+ - Keep your own state (instance variables) behind a `Mutex` if two
127
+ commands, or a command and a hook, may touch it at once.
128
+
129
+ ```ruby
130
+ chi.command "/hello-slow", "greet after 2 s, even mid-turn", anytime: true do |args, ctx|
131
+ sleep 2
132
+ ctx.card(title: "slow hello, #{args.empty? ? "there" : args}",
133
+ body: "Ran beside the turn; it saw #{ctx.messages.size} messages.")
134
+ nil
135
+ end
136
+ ```
137
+
138
+ #### `/help`
139
+
140
+ `/help` (itself an anytime command) lists every command the session knows:
141
+ chi's own, each bundle's with the bundle's name, and the terminal UIs' own
142
+ (`/stats`, `/exit`, `/detach` …), marked `terminal only` or `attached only`.
143
+ It works in all three UIs.
144
+
145
+ ### `chi.tool(name, description, params:, schema:, label:, preview:, targets:) { |args, ctx| … }`
146
+
147
+ This adds a tool the model can call. It is declared in the system prompt and
148
+ in the chat path's `tools:`, after chi's own tools.
149
+
150
+ - `name`: a–z first, then a–z, 0–9 and `_`, up to 48 characters.
151
+ - `params`: `{ name => property }`, where a property is a JSON Schema
152
+ property (`type:`, `description:`, `enum:`, `items:`, `properties:` …) plus
153
+ `required: true`. The type defaults to `"string"`.
154
+ - `schema`: instead of `params`, the parameters as one JSON Schema object
155
+ (`{ type: "object", properties: {…}, required: [...] }`), an MCP server's
156
+ `inputSchema` for example.
157
+ - `label`: the activity line's verb (`echoing`). The default is `calling tool`.
158
+ The web shows it in place of the tool's name, in its rows, step titles and
159
+ tally (the mcp bundle's `chrome: screenshot`).
160
+ - `preview`: `->(args) { "…" }` for the activity line's parameters. The
161
+ default is `key="value"` for each argument (a list or object as JSON). If
162
+ it raises, the default is shown. Both are saved with the call's result, so
163
+ a web page reloaded later shows the same row: the web server doesn't run
164
+ plugins.
165
+ - `targets`: `->(args) { { paths: [...], command: "…", cwd: "…" } }`, each
166
+ key optional, says what a call acts on, for [guardrails](#guardrails).
167
+
168
+ The block returns the result text. If it starts with `Error:`, it counts as
169
+ a failure. If it raises, the model gets `Error: <message>`. To return images
170
+ too, see [Returning images](#returning-images).
171
+
172
+ #### `args`
173
+
174
+ `args` is a frozen Hash with **string keys**: the arguments the model gave,
175
+ by name (`args["text"]`). The same Hash goes to `preview` and `targets`.
176
+
177
+ Each parser gives them structured: Gemma's native values (strings, numbers,
178
+ booleans, lists, nested objects), Qwen's `<parameter=…>` text, and the chat
179
+ path's JSON. The values are then **typed by the schema**, because Qwen's are
180
+ all text and a model may quote a number anyway:
181
+
182
+ | type | from |
183
+ |---|---|
184
+ | `integer` | `"3"` → `3`; `3.0` → `3` |
185
+ | `number` | `"2.5"` → `2.5` |
186
+ | `boolean` | `"true"`/`"false"`, any case |
187
+ | `array`, `object` | JSON text → a list or a Hash (string keys), its items or fields typed too |
188
+ | `string` | a number or boolean → its text |
189
+
190
+ A value that doesn't fit its type stays as it came (`"three"` for an
191
+ integer), so check it if it matters. Names the schema doesn't have pass
192
+ through. `"type": ["integer", "null"]` counts as `integer`.
193
+
194
+ ```ruby
195
+ chi.tool "save_note", "Save a note to a file.",
196
+ params: { path: { type: "string", description: "The file to write", required: true },
197
+ text: { type: "string", description: "The note", required: true },
198
+ format: { type: "string", enum: %w[plain markdown] },
199
+ meta: { type: "object", description: "Header fields",
200
+ properties: { tags: { type: "array", items: { type: "string" } },
201
+ priority: { type: "integer" } } } },
202
+ preview: ->(args) { "#{args["path"]} (#{args["text"].to_s.length} chars)" },
203
+ targets: ->(args) { { paths: [args["path"]] } } do |args, ctx|
204
+ File.write(File.expand_path(args["path"], ctx.cwd), args["text"])
205
+ "saved #{args["path"]}" # args["meta"]["priority"] is an Integer
206
+ end
207
+ ```
208
+
209
+ #### Schemas on the native paths
210
+
211
+ The native prompts (Gemma, Qwen on llama.cpp) declare each parameter with a
212
+ type and a description only. A plugin tool's schema is **flattened** for
213
+ them, and what doesn't fit goes into the description in words:
214
+
215
+ - an `enum`: `How the note is written. One of: "plain", "markdown".`;
216
+ - an object's fields: `type: object`, and `A JSON object with tags (array),
217
+ priority (integer).`;
218
+ - a list's items: `A list of string values.`;
219
+ - `additionalProperties` and deeper nesting are dropped.
220
+
221
+ The chat path (`api: openai` hosts) gets the full schema, nesting and all.
222
+ Either way the call's `args` are typed by the full schema. Keep deeply
223
+ nested schemas for tools that mostly run on chat hosts.
224
+
225
+ #### Guardrails
226
+
227
+ Guardrail rules keyed by a tool's name apply to plugin tools, as they do to
228
+ chi's own. Path and command rules need to know what a call acts on, and that
229
+ is what `targets:` says:
230
+
231
+ - `paths:`: files the call reads or writes, absolute or relative to `cwd:`
232
+ (else the session's directory). `path:` globs, `outside_repo` and the
233
+ protected paths (chi's config, …) match them.
234
+ - `command:`: a shell command the call runs; `command:` rules match it.
235
+ - `cwd:`: where it runs, for the repo root and relative paths.
236
+
237
+ A tool without `targets:` is matched by its name only. A `targets:` that
238
+ raises counts as nothing (it is logged). See [guardrails.md](guardrails.md).
239
+
240
+ #### Returning images
241
+
242
+ A tool can hand the model images too. The block returns a
243
+ `Samagotchi::Plugin::ToolResult`, which is the text plus `images:`:
244
+
245
+ ```ruby
246
+ chi.tool "screenshot", "Take a screenshot of the page." do |_args, ctx|
247
+ path = take_screenshot(ctx) # a PNG file
248
+ Samagotchi::Plugin::ToolResult.new("Took a screenshot.", images: [{ path: path }])
249
+ end
250
+ ```
251
+
252
+ - An image is `{ path: "/abs/file.png" }` or `{ bytes: png, name: "shot.png" }`
253
+ (raw bytes, not base64). png, jpeg, gif and webp are sent; bmp, tiff and
254
+ heic are converted if ImageMagick or sips is there. A large image is scaled
255
+ down (`image.max_side`, `image.max_bytes`), like one the model `read`s.
256
+ - Each image is stored with the session (`images/`) and goes to the model
257
+ after the tool's text, the same way a `read` of an image file does. The
258
+ web tool row and the terminal show it.
259
+ - At most **4** images per result are attached; each one past that gets a
260
+ line (`shot5.png is not attached: at most 4 images per tool result`).
261
+ - An entry that isn't `{path:}` or `{bytes:}`, or isn't an image, becomes an
262
+ `Error: …` line for that image. The text and the other images still go.
263
+ - A model that can't see images (`vision: false`, or known text-only) gets a
264
+ line instead of each image: `shot.png is an image; this model can't see
265
+ images`. Say what the image shows in the text, if it matters then.
266
+ - `ToolResult` is a String, so hooks, the log and the activity line see the
267
+ text as before.
268
+
269
+ #### When the tools change
270
+
271
+ The system prompt is built once, after the plugins load, so the server can
272
+ keep its cached prompt prefix. A plugin whose tools are known only later
273
+ declares them with [`chi.replace_tools`](#chireplace_tools--set--),
274
+ which rebuilds the prompt for the next turn when the set changed; that
275
+ costs the cache once. `chi.tools_changed!` alone says the tools changed
276
+ without replacing any.
277
+
278
+ ### `chi.replace_tools { |set| … }`
279
+
280
+ The plugin's whole tool set, after `register` (from an init task, a
281
+ command, a tool call). The block declares tools on `set` with
282
+ `set.tool(...)`, which takes `chi.tool`'s arguments:
283
+
284
+ ```ruby
285
+ @chi.replace_tools do |set|
286
+ listed.each do |t|
287
+ set.tool("idx_#{t[:name]}", t[:description], params: t[:params]) { |args, ctx| query(t, args) }
288
+ end
289
+ end
290
+ ```
291
+
292
+ - The set is **staged**: the session applies it at the start of the next
293
+ turn, before that turn's system prompt, on the turn's own thread. So it is
294
+ safe from any thread, and a turn never sees half a set.
295
+ - The plugin's tools not in the set go, new ones are added, and one whose
296
+ schema or label changed is registered again. Unchanged ones stay as they
297
+ are. If anything changed, the prompt is built again.
298
+ - A later set replaces an earlier staged one.
299
+ - A name that another bundle (or chi) has is left out, with a notice. A bad
300
+ tool raises `ArgumentError` at once, as `chi.tool` does, and nothing is
301
+ staged.
302
+ - Inside `register`, use `chi.tool`: `replace_tools` raises there.
303
+
304
+ ### `chi.on(event, priority: 100) { |event, ctx| … }`
305
+
306
+ This is a bundle hook, the same as a `hooks/*.rb` file. See
307
+ [hooks.md](hooks.md#hook-events) for the events and for what `event[:notify]`,
308
+ `event[:ask_user]` and `event[:stop_turn]` do. Its label is
309
+ `plugin.rb (bundle my-bundle)`. The block may take only the event. If it
310
+ raises, the error is logged and the hook is skipped.
311
+
312
+ ### `chi.service(name, eager: false) { |svc| … }`
313
+
314
+ A long-lived thing the plugin keeps for the session: a server process, a
315
+ connection. The block starts it, and what it returns is the service's value.
316
+ Inside the block, `svc.on_stop { … }` says how to stop it.
317
+
318
+ ```ruby
319
+ def register(chi)
320
+ server = chi.service(:index, eager: true) do |svc|
321
+ io = IO.popen(["my-indexer", "--stdio"], "r+")
322
+ svc.on_stop { io.close }
323
+ io
324
+ end
325
+ chi.tool("index_query", "…", params: { q: { type: "string", required: true } }) do |args, _ctx|
326
+ server.value.puts(args["q"])
327
+ server.value.gets
328
+ end
329
+ end
330
+ ```
331
+
332
+ - `chi.service` returns the service. `service.value` starts it on first use
333
+ and returns what the block returned; later calls return the same value.
334
+ With `eager: true` it starts at once, inside `register`, so a raise there
335
+ fails the plugin's load unless the plugin rescues it.
336
+ - A block that raises leaves the service unstarted: its `on_stop` callbacks
337
+ so far run, and the next `value` tries again.
338
+ - `service.running?`, `service.state` (`:idle`, `:running`, `:stopped`) and
339
+ `service.stop`.
340
+ - The services stop when chi leaves: the REPL exits, or the session's
341
+ worker exits (an idle exit, `/exit`, a crash, TERM). See
342
+ [Shutdown](#shutdown). A stopped service never starts again; `value`
343
+ raises `Samagotchi::Plugin::Service::Stopped`.
344
+ - A plugin whose load fails after it started services has them stopped.
345
+ - `kill -9` runs nothing: a child process is orphaned then. Most stdio
346
+ servers leave when their stdin closes, which it does as chi's process
347
+ ends.
348
+
349
+ ### `chi.init(label, provides_tools: false, quiet: false, timeout: nil, failed: nil) { |ctx| … }`
350
+
351
+ Slow setup that must not hold chi's start: downloading a model, indexing a
352
+ repo, logging in, starting a server for the first time. `register` itself
353
+ should return at once (everything in it runs before the session's UI is
354
+ up), so it hands the slow part to `chi.init`. The block runs **on its own
355
+ thread** once the session can show it: in a worker right after its Bridge
356
+ is up (so a new web chat opens at once), in the REPL at its first prompt.
357
+
358
+ ```ruby
359
+ class Plugin
360
+ def initialize(settings = {})
361
+ @model = settings["model"] || "small-embedder"
362
+ end
363
+
364
+ def register(chi)
365
+ @chi = chi
366
+ chi.init("Downloading #{@model}", provides_tools: true, timeout: 120) do |ctx|
367
+ path = download(@model, into: ctx.data_dir) { ctx.cancelled? } # stop when chi shuts down
368
+ @chi.replace_tools do |set|
369
+ set.tool("embed_search", "Search the repo by meaning.",
370
+ params: { query: { type: "string", required: true } }) { |args, _ctx| search(path, args["query"]) }
371
+ end
372
+ "#{@model} ready"
373
+ end
374
+ end
375
+ end
376
+ ```
377
+
378
+ - **What the UIs show.** Every UI shows a running task (web: a spinner line
379
+ over the composer, `mcp · Starting MCP server chrome (first run, saving
380
+ its tools)…`; the attached TUI: its activity row; the REPL: a line) and a
381
+ line when it is done: `✓` and what the block returned, a short summary
382
+ (`chrome ready, 3 tools`), or `<label>: done` for anything else. A UI that
383
+ joins while it runs sees it too.
384
+ - **A raise** is a warn card. Its title is `failed:`, short (`chrome didn't
385
+ start`; the card shows the bundle beside it), and its body the message;
386
+ without `failed:` the title is `setup failed` and the body
387
+ `<label>: <message>`.
388
+ - **`provides_tools: true`**: the task brings tools (with
389
+ `chi.replace_tools`). A turn sent while it runs starts at once (the user's
390
+ message shows), then waits for it **before its first model request**, so
391
+ the model sees the tools; the UIs keep showing the task meanwhile. The
392
+ wait lasts at most `timeout` seconds from the task's start (default 60).
393
+ A Ctrl-C cancels the turn and ends its wait, but not the task, whose
394
+ tools come with the next turn. A task that fails or ends late leaves the
395
+ turn without its tools. Tasks without `provides_tools` never hold a turn.
396
+ - **`quiet: true`**: nothing is shown unless it fails (a background
397
+ refresh).
398
+ - **`ctx.cancelled?`** in the block says chi is shutting down: the block
399
+ should stop then. It is the task's own, not the running turn's.
400
+ - `ctx.notify` and `ctx.card` from the block show between turns, even while
401
+ a turn runs.
402
+ - Each task runs once per session start. A `-p … --non-interactive` run
403
+ starts them with its turn and shows nothing but the answer.
404
+
405
+ ### `chi.ctx`
406
+
407
+ The plugin's context (the `ctx` its handlers get), for `register` itself:
408
+ its settings, log and data_dir, for example. A `ctx.notify` or `ctx.card`
409
+ while chi starts (inside `register`) is shown once the session's UI can show
410
+ it (a worker's Bridge is up, the REPL's first prompt), after the plugins'
411
+ load warnings; a UI that joins later still gets it.
412
+
413
+ ### Names
414
+
415
+ A command or tool name that the session already has is a **load error**.
416
+ That includes a chi built-in and another bundle's name. Bundles load in
417
+ name order, so the first bundle keeps the name.
418
+
419
+ ## The context: `ctx`
420
+
421
+ Every handler gets the plugin's context. There is one per plugin for the
422
+ session's life, and each read gives the session as it is now.
423
+
424
+ | | |
425
+ |---|---|
426
+ | `ctx.session_id` | the session's id (nil before there is one) |
427
+ | `ctx.cwd` | the session's working directory |
428
+ | `ctx.repo_root` | the git checkout holding `cwd`, or nil |
429
+ | `ctx.settings` | the bundle's settings, frozen |
430
+ | `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use |
431
+ | `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
432
+ | `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn |
433
+ | `ctx.messages_partial?` | whether `ctx.messages` leaves out a running turn (the REPL mid-turn), so a plugin can say what its answer is about |
434
+ | `ctx.notify(text, level: :info)` | one line to the user, like a hook's `event[:notify]`, labelled by the bundle (`my-bundle> …`). Every UI shows it, during a turn (a tool, a hook) or between turns (a command) |
435
+ | `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
436
+ | `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a question, like a hook's `event[:ask_user]` |
437
+ | `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
438
+ | `ctx.ask_model(messages:, prompt:, …)` | a side answer from the session's model: see [Side answers](#side-answers-ctxask_model) |
439
+ | `ctx.sessions` | fork, send to and read other sessions: see [Other sessions](#other-sessions-ctxsessions) |
440
+
441
+ The Engine itself is never handed to a plugin.
442
+
443
+ ## Cards
444
+
445
+ A card is a small framed message with buttons: a title, a body and
446
+ actions. Core draws it in all three UIs; a plugin has no JS or CSS of its
447
+ own.
448
+
449
+ ```ruby
450
+ id = ctx.card(title: "Build finished", body: "**3** warnings in `lib/`",
451
+ actions: [{ label: "Show them", command: "/warnings" }],
452
+ level: :warn)
453
+ ctx.card(id: id, title: "Build finished", body: "no warnings left") # replaces it
454
+ ```
455
+
456
+ - `title:` is required. `body:` is markdown in the web. The terminal shows
457
+ it wrapped, with the markdown cheaply stripped: `**bold**` and `__x__`
458
+ lose their marks, backticks and code fences go, headings lose their `#`s,
459
+ and lists stay as they are.
460
+ - `actions:` are up to 6 `{label:, command:}`. A command is a line the
461
+ session runs as if the user typed it: `/hello again`, `/model x`, a
462
+ plugin's own command. The web shows a button; the terminal shows
463
+ `→ /hello again`, to type.
464
+ - `level:` is `:info` or `:warn` (the warning colour).
465
+ - `id:` names an earlier card to replace. Without one a new id is made. The
466
+ web updates the card in place; the terminal prints it again, marked
467
+ `(updated)`. A card that waits for something (a model's answer) shows
468
+ first, then is replaced.
469
+ - A bad card (no title, a bad level or action) raises `ArgumentError`.
470
+
471
+ Where it shows:
472
+
473
+ | | during a turn (a tool, a hook) | between turns (a command) |
474
+ |---|---|---|
475
+ | REPL | where it happens, above the live region | at the prompt, after the command's output |
476
+ | attached TUI | where it happens | as it arrives |
477
+ | web | a row of the running step | between the turns |
478
+
479
+ In the web, a turn's block collapses when the turn ends. A `:warn` card, and
480
+ any card of a turn that ended without completing (cancelled, failed, the
481
+ worker gone), then moves out of the block, after the turn's end line, so it
482
+ stays in sight; a reload puts it in the same place. An `:info` card of a
483
+ completed turn stays in its step.
484
+
485
+ An [anytime command](#anytime-true)'s cards show as it shows them, after its
486
+ line, in every UI, whether a turn runs or not.
487
+
488
+ A worker keeps its last 20 cards and hook notices, for a UI that joins
489
+ later. The web shows them where they arrived after a reload (a turn's
490
+ notice as a row of its step, above the call it came before); the attached
491
+ TUI shows the cards and between-turns notices since the last turn when it
492
+ joins. They live as long as the worker: an idle exit or a restart forgets
493
+ them, and they are not saved with the session.
494
+
495
+ The event is `{type: :card, id:, source:, title:, body:, level:, actions:,
496
+ in_turn:}` (`source` is the bundle), logged as `card` with its source, id and
497
+ title.
498
+
499
+ ## Side answers: `ctx.ask_model`
500
+
501
+ ```ruby
502
+ answer = ctx.ask_model(messages: ctx.messages, prompt: "what did we decide about the cache?",
503
+ system: "Answer briefly.", timeout: 120, max_tokens: 400)
504
+ ```
505
+
506
+ One request to the session's current model on its host, resolved as a turn
507
+ resolves them (a `/model` switch counts). It has **no tools**, thinking is
508
+ off, and it writes nothing: the conversation, the saved session and the
509
+ next turn never see it, and no hook fires. It returns the answer text.
510
+
511
+ - `messages:` go as a transcript, filtered like the idle recap's: no system
512
+ prompt, tool calls, tool output or thinking, and an image is a line
513
+ naming it (`[image shot.png]`). A long one keeps its tail (32,000
514
+ characters). The transcript and `prompt:` go in one user message.
515
+ - `system:` has a short default ("answer about the conversation below,
516
+ briefly, and don't continue its task").
517
+ - `max_tokens:` defaults to 1024. An answer cut off by it ends with `…`.
518
+ - `cancel:` takes a `Samagotchi::CancellationController`; cancelling it
519
+ aborts the request.
520
+ - It blocks until the answer comes, so call it from an anytime command or a
521
+ thread of your own. A local server that runs one request at a time
522
+ (llama.cpp with one slot) answers it after a running turn's current
523
+ request.
524
+ - It raises `Samagotchi::Plugin::ModelError` when the request fails or
525
+ times out (the message says why), and `Samagotchi::Plugin::ModelCancelled`
526
+ when cancelled.
527
+
528
+ It goes through the host's OpenAI API (`/v1/chat/completions`), which
529
+ llama.cpp serves on a native host too.
530
+
531
+ ## Other sessions: `ctx.sessions`
532
+
533
+ ```ruby
534
+ id = ctx.sessions.fork(messages: ctx.messages + [{ role: "user", content: q }, { role: "model", content: a }],
535
+ title: "btw: #{q}")
536
+ ctx.sessions.send(id, "go on from here")
537
+ ctx.sessions.read(id) # => {id:, title:, status:, parent_id:, running:, messages:}
538
+ ```
539
+
540
+ - `fork(messages:, title: nil, prompt: nil)` starts a child session in its
541
+ own worker, from these messages, in this session's folder and model. It
542
+ shows in every list as a child of this one (`↳ parent`), and the user can
543
+ attach to it. It returns the child's id.
544
+ - Without `prompt:` the child waits idle. With one, it runs it as its first
545
+ turn, and counts against `session.max_children` (like `delegate`).
546
+ - `title:` is what the lists show until its first turn (else the prompt, or
547
+ the first user message).
548
+ - An image a message names is copied into the child. One whose file is
549
+ gone is dropped, with `[image x.png was not copied]` in its message.
550
+ - `send(id, text)` sends a user message to a session (an id or a unique
551
+ prefix); it runs as a turn, and a stopped session is woken. It waits up to
552
+ 5 s for the session's worker, so call it from an anytime command or a
553
+ thread of your own, never from a tool or hook of a running turn. A
554
+ session open in a chi REPL can't take it.
555
+ - `read(id)` gives a session now: from its worker when one runs (with a
556
+ running turn so far, `running: true`), else as saved. `messages` has no
557
+ system prompt.
558
+ - Each raises `Samagotchi::Plugin::Sessions::Error` with the reason.
559
+
560
+ ## The btw bundle
561
+
562
+ `chi bundle install btw` installs the bundle shipped with chi. It is written
563
+ only against this API (`lib/samagotchi/bundles/btw/plugin.rb`).
564
+
565
+ - `/btw <question>` asks the session's model a side question about the
566
+ conversation, even while a turn runs. A card `btw: <question>` shows
567
+ "thinking…" at once, and the same card then shows the answer. Nothing else
568
+ sees the answer.
569
+ - The card's **Keep as session** runs `/btw keep <id>`. It forks the
570
+ conversation, the question and the answer into an idle child session, and
571
+ shows a card `kept as <id>`.
572
+ - The last 10 answers can be kept, while the session's worker (or REPL)
573
+ runs. After that, or after a restart, `keep` says expired.
574
+ - In the REPL, a question asked during a turn is about the conversation
575
+ before that turn, and the card says so. In a worker it includes the turn
576
+ so far.
577
+ - Settings: `bundles: btw: {max_tokens: 1024, timeout: 120}`.
578
+ - It ships no memory, so it adds no line to the prompt's memory index. Its
579
+ 0.1.0 shipped `btw.md` as one, and `chi bundle upgrade btw` leaves that file
580
+ (and its index line) behind. Drop it with `chi bundle uninstall btw`, then
581
+ `chi bundle install btw`. After an upgrade already ran, delete
582
+ `~/.config/samagotchi/memories/btw.md` and its `**btw**` line in `index.md`
583
+ there by hand.
584
+
585
+ ## The mcp bundle
586
+
587
+ `chi bundle install mcp` installs the bundle shipped with chi. It is written
588
+ only against this API (`lib/samagotchi/bundles/mcp/plugin.rb`), and adds
589
+ tools from [MCP](https://modelcontextprotocol.io) servers. It has no memory
590
+ file, so it costs the prompt nothing but its tools. Stdio servers only, for
591
+ now.
592
+
593
+ ```yaml
594
+ # config.yml
595
+ bundles:
596
+ mcp:
597
+ timeout: 60 # seconds per tool call (default 60)
598
+ startup_timeout: 10 # seconds for initialize and tools/list (default 10)
599
+ servers:
600
+ everything:
601
+ command: [npx, -y, "@modelcontextprotocol/server-everything"]
602
+ files:
603
+ command: [npx, -y, "@modelcontextprotocol/server-filesystem", ~/scratch]
604
+ env: {NODE_OPTIONS: "--no-warnings"} # added to chi's environment
605
+ cwd: ~/scratch # default: where chi runs
606
+ tools: [read_*, list_directory] # optional: only these (globs)
607
+ timeout: 120 # optional: this server's per-call timeout
608
+ chrome:
609
+ command: [npx, -y, "chrome-devtools-mcp@latest", --slim, --headless]
610
+ attach_image_paths: true # the default; false leaves a path as text
611
+ start: lazy # the default; eager: start it with every session
612
+ ```
613
+
614
+ - **Start: from a cache, on the first call.** A server's `tools/list` is
615
+ saved in the bundle's data dir (`$XDG_STATE_HOME/samagotchi/plugins/mcp/
616
+ tools-<server>.json`), keyed by a digest of its `command`, `env` (names
617
+ and values: only the digest is stored) and `cwd`. A session with a saved
618
+ list registers the tools at once and **doesn't start the server**: the
619
+ first call of one of its tools does (the call's row shows the wait). So a
620
+ session that never uses MCP spawns nothing, and a new chat opens without
621
+ waiting for `npx`. If the live list differs from the saved one, the saved
622
+ one is replaced, and so are the tools, from the next turn on.
623
+ - **The first run** (no saved list, or the config changed) starts the
624
+ server in an [init task](#chiinitlabel-provides_tools-false-quiet-false-timeout-nil--ctx--):
625
+ every UI shows `Starting MCP server x (first run, saving its tools)`, and
626
+ a turn sent meanwhile waits for its tools. A server that doesn't start,
627
+ answer or list its tools within `startup_timeout` (each step) is a warn
628
+ card, `…: failed`, and its tools are left out. The rest of chi works as
629
+ usual.
630
+ - **Freshness.** A saved list older than a day is still used, and a quiet
631
+ background task lists the tools again with a server of its own (then
632
+ stops it), saves them, and replaces the tools if they changed. One worker
633
+ does it at a time.
634
+ - **A cached server that doesn't start** (the command is gone, it crashes)
635
+ fails that call with `Error: MCP server x didn't start: …` and one notice;
636
+ later calls answer the same at once, and its tools are left out from the
637
+ next turn. The saved list stays: the next session tries again.
638
+ - **`start: eager`** on a server starts it with every session (in an init
639
+ task, after the Bridge is up), for a server whose start does something
640
+ you want at once.
641
+ - **`npx -y …@latest` checks the npm registry on every start** (~3.4 s for
642
+ `chrome-devtools-mcp`, against ~0.9 s for the installed binary). The cache
643
+ hides it from new chats, but not from the first call. For a faster first
644
+ call, install the server once and run it directly:
645
+
646
+ ```yaml
647
+ chrome:
648
+ command: [chrome-devtools-mcp, --slim, --headless] # after npm i -g chrome-devtools-mcp
649
+ ```
650
+
651
+ - **Tools.** Each tool is the model's as `mcp_<server>_<tool>`, lower case,
652
+ with anything but a-z, 0-9 and `_` made `_`, cut at 48 characters. A name
653
+ that clashes is left out, with a notice. The tool's `inputSchema` is its
654
+ schema (flattened on the native paths, see
655
+ [Schemas on the native paths](#schemas-on-the-native-paths)); its label is
656
+ `<server>: <tool>` and its preview the arguments, short.
657
+ - **Calls.** A call is `tools/call`. The text blocks of the answer are joined;
658
+ audio or a resource without text is a short placeholder
659
+ (`[audio: audio/wav]`). `isError` makes it `Error: …`. A call that takes
660
+ longer than the timeout is an `Error:`, and a cancelled turn stops the
661
+ wait; both send `notifications/cancelled` to the server.
662
+ - **Images.** An `image` block goes to the model as a picture
663
+ ([Returning images](#returning-images): at most 4 per call, a line
664
+ instead when the model can't see images). Its place in the text is a line,
665
+ `[image 1: image/png, attached]`, so the model knows the order. A text
666
+ block that is **only the absolute path of an image file** is attached too
667
+ (`[image 1: screenshot.png, attached]` after the path), but only when the
668
+ file is under the system temp dir or the server's `cwd`: a server's text
669
+ can't pull in any image on disk. `chrome-devtools-mcp --slim` answers
670
+ `screenshot` that way; without `--slim`, `take_screenshot` returns an image
671
+ block. `attach_image_paths: false` on a server leaves such paths as text.
672
+ - **A server that exits** fails its calls with `Error: MCP server x is not
673
+ running (…)`, and there is one notice. It is not restarted until chi
674
+ restarts (a new session, or the worker's next start).
675
+ - **Stop.** The servers stop with chi ([Shutdown](#shutdown)): stdin is
676
+ closed, then TERM and KILL go to the server's process group.
677
+ - **`/mcp`** (anytime) shows a card with the servers, their state (cached
678
+ (not started), running with its pid, failed, stopped) and their tools.
679
+ - The server's stderr goes to the debug log (`plugins` records, bundle=mcp).
680
+ - **Guardrails.** A rule's `tool:` can be a glob, so one rule covers every
681
+ MCP tool:
682
+
683
+ ```yaml
684
+ guardrails:
685
+ rules:
686
+ - id: mcp-ask
687
+ tool: "mcp_*"
688
+ verdict: ask
689
+ reason: an MCP server's tool
690
+ ```
691
+
692
+ An MCP tool has no `targets:`, so the question shows its arguments,
693
+ under the tool's label as its row shows it (`everything: get_sum: a=20
694
+ b=22`; any plugin tool with a label is asked about by it), and "Allow this call for the
695
+ session" (or in this repo) allows that tool with those arguments only.
696
+
697
+ ## The loop-guard bundle
698
+
699
+ `chi bundle install loop-guard` installs the bundle shipped with chi. It is
700
+ written only against this API (`lib/samagotchi/bundles/loop-guard/plugin.rb`),
701
+ with `chi.on` hooks, and has no memory file.
702
+
703
+ A local model can run the same tool call again and again in one turn: each
704
+ step's thinking starts over, so it never notices it already tried. (A real
705
+ one ran `find . -name 'config.yml'` ten times, getting nothing each time.)
706
+ loop-guard breaks that:
707
+
708
+ - A call is keyed by its tool and its arguments (whitespace collapsed), and
709
+ its result by a hash of the output. When a call has already returned the
710
+ same result `deny_after` times this turn (default 2), the next identical
711
+ call is **denied** with advice, so the 3rd one is caught:
712
+
713
+ ```
714
+ [execute] Error: denied by guardrail (bundle loop-guard): repeated call. The user was not asked. You already ran this exact call 2 times this turn and it returned the same result each time (exit: 0 (no output)). Don't repeat it. Try a different approach, or tell the user what you're stuck on.
715
+ ```
716
+
717
+ The user sees one line per call per turn: `loop-guard> loop: execute find
718
+ . -name 'config.yml' 2>/dev/null repeated, denied`.
719
+ - At the `stop_after`-th deny in a turn (default 4) the turn is **stopped**
720
+ (core's own "stopped" notice), and a card lists the repeated calls, so the
721
+ user can say what to try instead.
722
+ - A denied call has no result: a deny (loop-guard's, known-names', a rule's)
723
+ never counts as the call's result, so the deny sticks.
724
+ - The counts are per turn, and a count is the turn's total, not a run of
725
+ consecutive repeats: the loop usually has other calls in between. A new
726
+ turn (a prompt, a continue, a reminder) starts from zero, since a new user
727
+ message can make an old call right again. A steering message merged into
728
+ a running turn doesn't reset them.
729
+ - The polling tools, where repeating is the point, are ignored.
730
+
731
+ ```yaml
732
+ # config.yml
733
+ bundles:
734
+ loop-guard:
735
+ deny_after: 2 # same call, same result this many times: deny the next one
736
+ stop_after: 4 # stop the turn at this many denies
737
+ ignore_tools: [task_wait, task_get, delegate_result, list_sessions, list_reminders]
738
+ mode: deny # deny | notify: notify only warns, once per call per turn
739
+ ```
740
+
741
+ Its hooks run at the default priority (100), after known-names (50), so in
742
+ known-names' `correct` mode loop-guard keys the corrected call.
743
+
744
+ Not caught (yet):
745
+
746
+ - near-duplicates, such as `find . -name 'config*'` after `'config.yml'`;
747
+ - loops across turns;
748
+ - alternating calls (A, B, A, B) that each return something new;
749
+ - thinking that goes in circles inside one long generation.
750
+
751
+ ## Shutdown
752
+
753
+ When the REPL exits, or a session's worker exits (an idle exit, `/exit`, a
754
+ crash, TERM), chi shuts the session's Engine down:
755
+
756
+ 1. The idle jobs (reminders, the recap) stop.
757
+ 2. The plugins' init tasks are cancelled (`ctx.cancelled?` turns true).
758
+ They and the anytime commands still running get up to 3 seconds, all
759
+ together, to finish, so their output reaches the UIs; an init task
760
+ announces nothing after this.
761
+ 3. The plugins' services stop, the newest first.
762
+
763
+ ## Loading, and when it fails
764
+
765
+ Plugins load when a session starts (`Engine.new`, in the REPL or a
766
+ worker), after the bundle hooks. The steps are:
767
+
768
+ 1. The file must match the sha256 recorded at install.
769
+ 2. chi must meet `requires_chi`.
770
+ 3. The file is `module_eval`'d into a new module in the bundle's namespace
771
+ (`Samagotchi::Bundles::<bundle>`).
772
+ 4. The class is built, and `register(chi)` runs.
773
+
774
+ What `register` adds takes effect only when it returns. A plugin that raises
775
+ halfway adds nothing.
776
+
777
+ A plugin that fails to load is shown on stderr at start, and in every UI as
778
+ soon as the session's UI is up (`plugins> plugin plugin.rb (bundle x) failed
779
+ to load (…)`); a UI that joins later gets it too. The rest
780
+ of chi, including the other plugins, works as usual. Unlike a required
781
+ guardrail, a plugin failure does not deny tool calls.
782
+
783
+ A change needs a restart. A running worker keeps the plugins it started
784
+ with. After an install or upgrade, a worker only loads the new code when it
785
+ starts again (`chi sessions stop`, or the idle exit).
786
+
787
+ ## Trust
788
+
789
+ A plugin is Ruby code that runs inside chi with your permissions, just like
790
+ a bundle's hooks. **Installing a bundle means trusting it**, as you would a
791
+ gem.
792
+
793
+ - The sha256 is an integrity check, not proof of who wrote the file. A file
794
+ edited after install is not loaded until you reinstall the bundle.
795
+ - Install only copies the file. The code first runs at the next session
796
+ start.
797
+ - Guardrail rules keyed by a tool's name apply to plugin tools, as they do to
798
+ chi's own tools; path and command rules see what `targets:` says.
799
+
800
+ ## The bundle commands
801
+
802
+ - `chi bundle install <dir>`: copies the plugin to
803
+ `<memories>/.bundles/<name>/plugin/`. It records the plugin's sha256 and
804
+ `requires_chi`, and warns about a wrong declared sha256 or an unmet
805
+ `requires_chi`.
806
+ - `chi bundle status [<name>]`: shows `plugin=plugin.rb` in the list. For
807
+ one bundle it shows `Plugin: plugin.rb [ok|modified|missing]` and a
808
+ `requires_chi` failure.
809
+ - `chi bundle diff <name> [plugin.rb]`: shows the installed base and the
810
+ file on disk.
811
+ - `chi bundle build --name <name>`: puts the installed plugin (and
812
+ `requires_chi`) into the built bundle.
813
+ - `chi bundle uninstall <name>`: removes the plugin with the bundle.
814
+
815
+ ## Not yet
816
+
817
+ These are planned:
818
+
819
+ - `chi.prompt` for sections of the system prompt.