samagotchi 0.12.0 → 0.14.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +20 -7
  4. data/bin/chi +9 -9
  5. data/docs/architecture.md +4 -2
  6. data/docs/cli.md +34 -10
  7. data/docs/configuration.md +79 -15
  8. data/docs/desktop.md +10 -6
  9. data/docs/guardrails.md +74 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +31 -16
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge/card_store.rb +94 -6
  17. data/lib/samagotchi/bridge.rb +14 -2
  18. data/lib/samagotchi/bridge_client.rb +8 -3
  19. data/lib/samagotchi/bundle_command.rb +18 -10
  20. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
  22. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  23. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/exit.rb +23 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  31. data/lib/samagotchi/config.rb +81 -24
  32. data/lib/samagotchi/context_window.rb +30 -5
  33. data/lib/samagotchi/desktop_command.rb +5 -3
  34. data/lib/samagotchi/engine.rb +54 -17
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +158 -0
  36. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  37. data/lib/samagotchi/host_registry.rb +31 -7
  38. data/lib/samagotchi/kernel_loop.rb +1 -1
  39. data/lib/samagotchi/llm/chat_loop.rb +5 -3
  40. data/lib/samagotchi/llm/openai_chat.rb +9 -1
  41. data/lib/samagotchi/llm/turn_settings.rb +7 -5
  42. data/lib/samagotchi/memory_bundle/installer.rb +12 -0
  43. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  44. data/lib/samagotchi/memory_paths.rb +8 -0
  45. data/lib/samagotchi/model_profile.rb +30 -16
  46. data/lib/samagotchi/parent_report.rb +213 -0
  47. data/lib/samagotchi/plugin/api.rb +15 -2
  48. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  49. data/lib/samagotchi/question_desk.rb +29 -2
  50. data/lib/samagotchi/recap_setup.rb +15 -5
  51. data/lib/samagotchi/reply_wait.rb +5 -1
  52. data/lib/samagotchi/send_command.rb +50 -75
  53. data/lib/samagotchi/session.rb +15 -0
  54. data/lib/samagotchi/session_manager.rb +40 -7
  55. data/lib/samagotchi/sessions_command.rb +13 -3
  56. data/lib/samagotchi/system_prompt.rb +11 -3
  57. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +53 -14
  59. data/lib/samagotchi/terminal_ui.rb +30 -3
  60. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  61. data/lib/samagotchi/tool_call_parser.rb +5 -6
  62. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  63. data/lib/samagotchi/tools/builtins.rb +14 -2
  64. data/lib/samagotchi/tools/delegate_wait.rb +32 -13
  65. data/lib/samagotchi/tools/execute.rb +8 -8
  66. data/lib/samagotchi/tools/memory.rb +3 -3
  67. data/lib/samagotchi/tools/task_create.rb +3 -2
  68. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  69. data/lib/samagotchi/utf8_default.rb +29 -0
  70. data/lib/samagotchi/version.rb +1 -1
  71. data/lib/samagotchi/web/app.rb +22 -10
  72. data/lib/samagotchi/web/message_parts.rb +2 -1
  73. data/lib/samagotchi/web/public/activity.js +6 -1
  74. data/lib/samagotchi/web/public/app.js +39 -6
  75. data/lib/samagotchi/web/public/index.html +8 -1
  76. data/lib/samagotchi/web/public/notify.js +16 -3
  77. data/lib/samagotchi/web/public/question_cards.js +3 -1
  78. data/lib/samagotchi/web/public/sessions_list.js +37 -0
  79. data/lib/samagotchi/web/public/turn_view.js +6 -9
  80. data/lib/samagotchi/web/server.rb +2 -1
  81. data/lib/samagotchi/web/session_summary.rb +3 -11
  82. metadata +8 -1
@@ -15,12 +15,19 @@
15
15
  # (a card's actions run while the turn goes on), so the state is behind a
16
16
  # Mutex.
17
17
  #
18
+ # What /checkin changes (on/off, mode, the threshold) is the session's: it
19
+ # is saved in the bundle's data dir (sessions/<id>.json) and read back
20
+ # when the session comes back (a worker's restart), before its first turn
21
+ # or command. Another session starts from the settings.
22
+ #
18
23
  # Settings (config.yml, bundles: check-in:):
19
24
  # after: 50 tool calls in one turn before the first check-in
20
25
  # every: 50 then again every this many more
21
26
  # mode: ask ask | nudge | notify
22
27
  # message: "..." what a nudge says; {calls} is the count
23
28
  # ignore_tools: [task_wait, task_get, delegate_result]
29
+ require "fileutils"
30
+ require "json"
24
31
  require "securerandom"
25
32
 
26
33
  class Plugin
@@ -39,6 +46,8 @@ class Plugin
39
46
  @message = message.empty? ? DEFAULT_MESSAGE : message
40
47
  @ignore = settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
41
48
  @enabled = true
49
+ @defaults = { "enabled" => @enabled, "mode" => @mode, "after" => @after, "every" => @every }
50
+ @session = :none # the session whose /checkin state is loaded
42
51
  @mutex = Mutex.new
43
52
  reset
44
53
  end
@@ -69,7 +78,10 @@ class Plugin
69
78
 
70
79
  def before_turn(ctx)
71
80
  close_card(ctx, "The turn ended after #{calls} tool calls.")
72
- @mutex.synchronize { reset }
81
+ @mutex.synchronize do
82
+ load_session(ctx)
83
+ reset
84
+ end
73
85
  end
74
86
 
75
87
  def after_tool_call(event, ctx)
@@ -77,6 +89,7 @@ class Plugin
77
89
  return if @ignore.include?(tool)
78
90
 
79
91
  due = @mutex.synchronize do
92
+ load_session(ctx)
80
93
  @count += 1
81
94
  @tools = (@tools + [tool]).last(LAST_TOOLS)
82
95
  next nil unless @enabled && @count >= @next_at
@@ -159,18 +172,25 @@ class Plugin
159
172
  # --- /checkin --------------------------------------------------------------
160
173
 
161
174
  def command(args, ctx)
175
+ @mutex.synchronize { load_session(ctx) }
162
176
  case args
163
177
  when "" then status
164
178
  when "on", "off"
165
- @mutex.synchronize { @enabled = args == "on" }
179
+ @mutex.synchronize do
180
+ @enabled = args == "on"
181
+ save_session(ctx)
182
+ end
166
183
  "check-in is #{args} for this session"
167
184
  when /\A\d+\z/
168
- threshold(Integer(args))
185
+ threshold(Integer(args), ctx)
169
186
  when /\Amode\s+(\S+)\z/
170
187
  mode = Regexp.last_match(1)
171
188
  return "check-in: unknown mode #{mode} (ask, nudge or notify)" unless MODES.include?(mode)
172
189
 
173
- @mutex.synchronize { @mode = mode }
190
+ @mutex.synchronize do
191
+ @mode = mode
192
+ save_session(ctx)
193
+ end
174
194
  "check-in mode is #{mode} for this session"
175
195
  when "nudge" then nudge(ctx)
176
196
  when "later" then later(ctx)
@@ -189,12 +209,13 @@ class Plugin
189
209
 
190
210
  # For this session: check in at +n+ calls and every +n+ after; a turn
191
211
  # already past it is checked on +n+ calls from now.
192
- def threshold(n)
212
+ def threshold(n, ctx)
193
213
  return "check-in: the threshold must be at least 1" unless n.positive?
194
214
 
195
215
  @mutex.synchronize do
196
216
  @after = @every = n
197
217
  @next_at = n > @count ? n : @count + n
218
+ save_session(ctx)
198
219
  end
199
220
  "check-in after #{n} tool calls, then every #{n}, for this session"
200
221
  end
@@ -223,6 +244,48 @@ class Plugin
223
244
  nil
224
245
  end
225
246
 
247
+ # --- the session's /checkin state (under @mutex) ---------------------------
248
+
249
+ # The current session's state, when the session isn't the one loaded:
250
+ # its saved file over the settings.
251
+ def load_session(ctx)
252
+ id = ctx.session_id
253
+ return if id == @session
254
+
255
+ @session = id
256
+ state = @defaults.merge(read_state(ctx, id))
257
+ @enabled = state["enabled"] == true
258
+ @mode = MODES.include?(state["mode"]) ? state["mode"] : @defaults["mode"]
259
+ @after = positive(state["after"]) || @defaults["after"]
260
+ @every = positive(state["every"]) || @defaults["every"]
261
+ @next_at = @after if @count.zero?
262
+ end
263
+
264
+ def read_state(ctx, id)
265
+ return {} unless id
266
+
267
+ data = JSON.parse(File.read(state_path(ctx, id)))
268
+ data.is_a?(Hash) ? data : {}
269
+ rescue SystemCallError, JSON::ParserError
270
+ {}
271
+ end
272
+
273
+ # Written aside and renamed: a reader never sees half a file.
274
+ def save_session(ctx)
275
+ id = ctx.session_id
276
+ return unless id
277
+
278
+ path = state_path(ctx, id)
279
+ FileUtils.mkdir_p(File.dirname(path))
280
+ temp = "#{path}.#{Process.pid}.tmp"
281
+ File.write(temp, JSON.generate({ "enabled" => @enabled, "mode" => @mode, "after" => @after, "every" => @every }))
282
+ File.rename(temp, path)
283
+ rescue SystemCallError => e
284
+ ctx.log.warn("check_in_state_not_saved", msg: e.message) if ctx.respond_to?(:log)
285
+ end
286
+
287
+ def state_path(ctx, id) = File.join(ctx.data_dir, "sessions", "#{id.to_s.gsub(/[^A-Za-z0-9_.-]+/, "_")}.json")
288
+
226
289
  # --- helpers ---------------------------------------------------------------
227
290
 
228
291
  def calls = @mutex.synchronize { @count }
@@ -80,3 +80,22 @@ rules:
80
80
  command: '(\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles|\.git/hooks)'
81
81
  verdict: ask
82
82
  reason: the command names chi's config, hooks, guardrails or git hooks
83
+
84
+ # ── answering chi's own questions around chi answer ───────────────────
85
+ # A parent agent answers a child's approval with chi answer, which holds it
86
+ # to guardrails.parent_approvals. These shapes go around that: chi --attach
87
+ # or chi -p with stdin from a pipe, a here-string or a file (a chi after 0.12 treats
88
+ # those as a parent too, but an older child doesn't), and a POST to the
89
+ # Bridge's or the web's answer route.
90
+ - id: chi-answer-piped
91
+ tool: shell
92
+ command: '(\|[^;&|\n]*\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b|\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b[^;&|\n]*<)'
93
+ verdict: ask
94
+ scopes: [once, session]
95
+ reason: answers chi's questions from a pipe or a file, around chi answer
96
+ - id: chi-answer-http
97
+ tool: shell
98
+ command: '\b(curl|wget|http|xh)\b[^;&|\n]*/answer\b'
99
+ verdict: ask
100
+ scopes: [once, session]
101
+ reason: posts an answer to a chi session's question directly, around chi answer
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.2.0
3
+ version: 0.3.0
4
4
  scope: system
5
- description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes outside the repo, git hooks), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
5
+ description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes outside the repo, git hooks, answering chi around chi answer), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
7
7
  # models: in small-models.yml (guardrails.small_models)
8
8
  requires_chi: ">= 0.8.1"
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.3.2
3
+ version: 0.4.0
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:61b195a5621ddf57ba69a505aff06d620d6459e836d736205343a251b0fecb71
10
+ sha256: sha256:009dbb3b754958a6d9edb1e3b828f486aa0ddd09ff5b548f0c576abbfea18807
11
11
  requires_chi: ">= 0.8.0"
@@ -13,15 +13,18 @@
13
13
  # cwd: ~/scratch # default: where chi runs
14
14
  # tools: [echo, add] # optional: only these (globs work)
15
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
16
+ # attach_image_paths: true # default: an image path in the text, in the
17
+ # # temp dir or cwd, attaches it
18
18
  #
19
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).
20
+ # image/png, attached]"); so does an image file named in a text block,
21
+ # under the system temp dir or the server's cwd: the block is only its
22
+ # absolute path (chrome-devtools-mcp --slim answers a screenshot that way),
23
+ # or it says one ending in an image extension ("Saved it to /tmp/a.png.").
23
24
  # 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
+ # notice; the rest of chi works. One that exits mid-session starts again on
26
+ # its next call, at most MAX_RESTARTS times a session. /mcp lists the
27
+ # servers and their tools.
25
28
  require "digest"
26
29
  require "json"
27
30
  require "open3"
@@ -38,6 +41,8 @@ class Plugin
38
41
  NAME_CHARS = 48
39
42
  # A cached tool list older than this is refreshed in the background.
40
43
  CACHE_TTL = 24 * 60 * 60
44
+ # Restarts of a server that exited, per session.
45
+ MAX_RESTARTS = 3
41
46
  IMAGE_EXT = { "image/png" => "png", "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp" }.freeze
42
47
 
43
48
  # A JSON-RPC client for one MCP server over stdio: newline-delimited JSON
@@ -60,9 +65,13 @@ class Plugin
60
65
  # @param log [#call] (event, fields) for stderr lines and protocol noise
61
66
  # @param on_exit [#call, nil] called once, with the reason, when the
62
67
  # process ends while the client is open
63
- def initialize(command, env: {}, cwd: Dir.pwd, log: ->(*) {}, on_exit: nil)
68
+ # @param on_notification [#call, nil] (method, params) for each
69
+ # notification from the server, on the reader thread: it must not
70
+ # wait for an answer there
71
+ def initialize(command, env: {}, cwd: Dir.pwd, log: ->(*) {}, on_exit: nil, on_notification: nil)
64
72
  @log = log
65
73
  @on_exit = on_exit
74
+ @on_notification = on_notification
66
75
  @mutex = Mutex.new
67
76
  @write_mutex = Mutex.new
68
77
  @pending = {}
@@ -179,8 +188,11 @@ class Plugin
179
188
  def dispatch(message)
180
189
  if message.key?("method")
181
190
  # 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")
191
+ # the rest aren't supported. A notification is logged and handed on.
192
+ unless message.key?("id")
193
+ @log.call("server_notification", method: message["method"])
194
+ return @on_notification&.call(message["method"], message["params"])
195
+ end
184
196
 
185
197
  answer = if message["method"] == "ping"
186
198
  { jsonrpc: "2.0", id: message["id"], result: {} }
@@ -227,8 +239,10 @@ class Plugin
227
239
  # One configured server: its service, state and tools. +listed+ is its
228
240
  # tools/list as the server answers it (what the cache keeps), +tools+
229
241
  # the ones chi offers (the tools: filter applied, each with chi_name).
242
+ # +client+ is the running process's (a restart replaces it; the
243
+ # service's stop closes the current one), +restarts+ how many it had.
230
244
  Server = Struct.new(:name, :config, :service, :state, :error, :tools, :listed, :timeout, :cwd, :command, :env,
231
- :digest, :cached_at, keyword_init: true)
245
+ :digest, :cached_at, :relist, :relisting, :client, :restarts, keyword_init: true)
232
246
 
233
247
  def initialize(settings = {})
234
248
  @settings = settings
@@ -236,6 +250,8 @@ class Plugin
236
250
  @startup_timeout = positive(settings["startup_timeout"]) || STARTUP_TIMEOUT
237
251
  @servers = []
238
252
  @publish = Mutex.new
253
+ @relist = Mutex.new
254
+ @restart = Mutex.new
239
255
  # The tools left out so far (a name clash): said once, not at each
240
256
  # publish.
241
257
  @left_out = []
@@ -255,7 +271,8 @@ class Plugin
255
271
  configs = @settings["servers"]
256
272
  configs = {} unless configs.is_a?(Hash)
257
273
  @servers = configs.map do |name, config|
258
- server = Server.new(name: name.to_s, config: config.is_a?(Hash) ? config : {}, state: :starting, tools: [])
274
+ server = Server.new(name: name.to_s, config: config.is_a?(Hash) ? config : {}, state: :starting, tools: [],
275
+ restarts: 0)
259
276
  server.timeout = positive(server.config["timeout"]) || @timeout
260
277
  resolve(server, ctx)
261
278
  server.service = chi.service(name) { |svc| start(server, svc, ctx) }
@@ -306,16 +323,29 @@ class Plugin
306
323
  server.digest = Digest::SHA256.hexdigest(JSON.generate([server.command, server.env.sort, server.cwd]))
307
324
  end
308
325
 
309
- # The service's start: spawn, initialize, list the tools (cancelled with
310
- # the turn that waits for it), then cache the list.
326
+ # The service's start: spawn (#spawn). Its stop closes the server's
327
+ # current process, a restarted one too.
311
328
  def start(server, svc, ctx)
312
329
  raise Client::Error, "no command (bundles: mcp: servers: #{server.name}: command: [...])" if server.command.empty?
313
330
 
331
+ svc.on_stop { server.client&.close }
332
+ spawn(server, ctx)
333
+ end
334
+
335
+ # Spawn the server's process (it is server.client from then on),
336
+ # initialize, list the tools (cancelled with the turn that waits for
337
+ # it), then cache the list.
338
+ # @return [Client]
339
+ def spawn(server, ctx)
314
340
  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 }
341
+ client = server.client = Client.new(
342
+ server.command, env: server.env, cwd: server.cwd,
343
+ log: ->(event, **fields) { ctx.log.debug("mcp_#{event}", server: server.name, **fields) },
344
+ on_exit: ->(reason) { exited(server, reason, ctx) },
345
+ on_notification: lambda { |method, _params|
346
+ tools_changed(server, ctx) if method == "notifications/tools/list_changed"
347
+ }
348
+ )
319
349
  client.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {},
320
350
  clientInfo: { name: "chi", version: Samagotchi::VERSION } },
321
351
  timeout: @startup_timeout, cancelled: cancelled)
@@ -380,6 +410,46 @@ class Plugin
380
410
  nil
381
411
  end
382
412
 
413
+ # notifications/tools/list_changed from a running server: list its tools
414
+ # again (on a thread of its own: the answer comes on the reader thread
415
+ # that told us), save them and replace the tools for the next turn.
416
+ # Notices that come together list once more after the one running.
417
+ def tools_changed(server, ctx)
418
+ @relist.synchronize do
419
+ server.relist = true
420
+ return if server.relisting
421
+
422
+ server.relisting = true
423
+ end
424
+ Thread.new do
425
+ relist(server, ctx) while relist_pending?(server)
426
+ end
427
+ end
428
+
429
+ # Takes the server's pending relist: true once per notice; false (and
430
+ # the relisting thread ends) when none is left.
431
+ def relist_pending?(server)
432
+ @relist.synchronize do
433
+ pending = server.relist
434
+ server.relist = false
435
+ server.relisting = false unless pending
436
+ pending
437
+ end
438
+ end
439
+
440
+ def relist(server, ctx)
441
+ server.service.value # raises once stopped
442
+ listed = list_tools(server.client, nil)
443
+ return if listed == server.listed
444
+
445
+ server.listed = listed
446
+ save_cache(server, ctx)
447
+ ctx.log.info("mcp_tools_changed", server: server.name, tools: listed.size)
448
+ publish(ctx)
449
+ rescue StandardError => e
450
+ ctx.log.warn("mcp_tools_not_relisted", server: server.name, error: e.class.name, msg: e.message)
451
+ end
452
+
383
453
  # tools/list, every page.
384
454
  def list_tools(client, cancelled)
385
455
  tools = []
@@ -456,7 +526,7 @@ class Plugin
456
526
  def publish(ctx)
457
527
  @publish.synchronize do
458
528
  @chi.replace_tools do |set|
459
- @servers.each { |server| declare(set, server, ctx) if %i[running cached].include?(server.state) }
529
+ @servers.each { |server| declare(set, server, ctx) if %i[running cached exited].include?(server.state) }
460
530
  end
461
531
  end
462
532
  end
@@ -483,7 +553,13 @@ class Plugin
483
553
  def call(server, tool, args, ctx)
484
554
  return "Error: MCP server #{server.name} didn't start: #{server.error}" if server.state == :failed
485
555
 
486
- client = server.state == :cached ? lazy_start(server, ctx) : server.service.value
556
+ client = case server.state
557
+ when :cached then lazy_start(server, ctx)
558
+ when :exited then restart(server, ctx)
559
+ else
560
+ server.service.value # raises once stopped
561
+ server.client
562
+ end
487
563
  return client if client.is_a?(String)
488
564
 
489
565
  result = client.request("tools/call", { name: tool, arguments: args }, timeout: server.timeout,
@@ -529,6 +605,36 @@ class Plugin
529
605
  "Error: MCP server #{server.name} didn't start: #{e.message}"
530
606
  end
531
607
 
608
+ # Start a server that exited again, for a call: at most MAX_RESTARTS
609
+ # times a session. A start that fails or is cancelled leaves it exited
610
+ # (the next call tries again, while restarts are left).
611
+ # @return [Client, String] the client, or the call's error text
612
+ def restart(server, ctx)
613
+ @restart.synchronize do
614
+ server.service.value # raises once stopped
615
+ return server.client if server.state == :running # another call restarted it
616
+
617
+ if server.restarts >= MAX_RESTARTS
618
+ return "Error: MCP server #{server.name} is not running (#{server.error}; restarted #{MAX_RESTARTS} times this session)"
619
+ end
620
+
621
+ server.restarts += 1
622
+ cached = server.listed
623
+ server.client&.close
624
+ begin
625
+ client = spawn(server, ctx)
626
+ rescue StandardError => e
627
+ server.client&.close
628
+ ctx.log.warn("mcp_server_not_restarted", server: server.name, error: e.class.name, msg: e.message)
629
+ return "Error: MCP server #{server.name} didn't restart: #{e.message}"
630
+ end
631
+ server.state = :running
632
+ ctx.log.info("mcp_server_restarted", server: server.name, pid: client.pid, restarts: server.restarts)
633
+ publish(ctx) if server.listed != cached
634
+ client
635
+ end
636
+ end
637
+
532
638
  # The answer's text, and the images to attach: its image blocks, and a
533
639
  # text block that is only an image's path (#image_path).
534
640
  # @return [Array(String, Array<Hash>)]
@@ -541,11 +647,14 @@ class Plugin
541
647
  case block["type"]
542
648
  when "text"
543
649
  line = block["text"].to_s
544
- path = image_path(line, server)
545
- next line unless path
650
+ paths = image_paths(line, server).reject { |path| images.any? { |image| image[:path] == path } }
651
+ next line if paths.empty?
546
652
 
547
- images << { path: path, name: File.basename(path) }
548
- "#{line}\n[image #{images.size}: #{File.basename(path)}, attached]"
653
+ notes = paths.map do |path|
654
+ images << { path: path, name: File.basename(path) }
655
+ "[image #{images.size}: #{File.basename(path)}, attached]"
656
+ end
657
+ [line, *notes].join("\n")
549
658
  when "image"
550
659
  mime = block["mimeType"] || "unknown type"
551
660
  bytes = block["data"].to_s.unpack1("m")
@@ -564,14 +673,31 @@ class Plugin
564
673
  [text, images]
565
674
  end
566
675
 
567
- # The path when +text+ is only the absolute path of an image file under
568
- # the system temp dir or the server's cwd (a server's text can't pull
569
- # in any image on disk), and the server's attach_image_paths isn't off.
570
- def image_path(text, server)
571
- return nil if server.config["attach_image_paths"] == false
676
+ # An absolute path ending in an image extension, inside a sentence
677
+ # ("Saved screenshot to /tmp/shot.png."): no spaces, quotes or brackets.
678
+ IMAGE_PATH_IN_TEXT = %r{(?<![\w/.~-])/[^\s"'`<>()\[\]{},;]+?\.(?:png|jpe?g|gif|webp)(?![\w/-])}i
679
+
680
+ # The images +text+ names, when the server's attach_image_paths isn't
681
+ # off: the whole text when it is one absolute path (any name), else each
682
+ # absolute path in it that ends in an image extension. Each must be an
683
+ # image file (by its bytes) under the system temp dir or the server's
684
+ # cwd: a server's text can't pull in any image on disk.
685
+ # @return [Array<String>] real paths, each once
686
+ def image_paths(text, server)
687
+ return [] if server.config["attach_image_paths"] == false
688
+
689
+ whole = text.strip
690
+ candidates = if whole.start_with?("/") && !whole.include?("\n") && File.file?(whole)
691
+ [whole]
692
+ else
693
+ text.scan(IMAGE_PATH_IN_TEXT)
694
+ end
695
+ candidates.filter_map { |path| image_path(path, server) }.uniq
696
+ end
572
697
 
573
- path = text.strip
574
- return nil unless path.start_with?("/") && !path.include?("\n") && File.file?(path)
698
+ # +path+'s real path when it is an image file under a root, else nil.
699
+ def image_path(path, server)
700
+ return nil unless File.file?(path)
575
701
 
576
702
  real = File.realpath(path)
577
703
  roots = [Dir.tmpdir, server.cwd].compact.map { |dir| File.realpath(dir) rescue nil }.compact
@@ -589,14 +715,20 @@ class Plugin
589
715
  (head.start_with?("RIFF") && head[8, 4] == "WEBP")
590
716
  end
591
717
 
592
- # The process ended while chi runs: one notice; the calls say so.
718
+ # The process ended while chi runs: one notice; its next call starts it
719
+ # again (#restart) while restarts are left.
593
720
  def exited(server, reason, ctx)
594
721
  return unless server.state == :running
595
722
 
596
723
  server.state = :exited
597
724
  server.error = reason
598
- ctx.log.warn("mcp_server_exited", server: server.name, msg: reason)
599
- ctx.notify("MCP server #{server.name} stopped: #{reason}; its tools fail until chi restarts", level: :warn)
725
+ ctx.log.warn("mcp_server_exited", server: server.name, msg: reason, restarts: server.restarts)
726
+ after = if server.restarts < MAX_RESTARTS
727
+ "it restarts on its next call"
728
+ else
729
+ "it was restarted #{MAX_RESTARTS} times, its tools fail until chi restarts"
730
+ end
731
+ ctx.notify("MCP server #{server.name} stopped: #{reason}; #{after}", level: :warn)
600
732
  end
601
733
 
602
734
  def listing
@@ -614,7 +746,7 @@ class Plugin
614
746
  when :cached then "cached (not started), #{server.tools.size} tool#{"s" unless server.tools.size == 1}"
615
747
  when :running
616
748
  count = server.tools.count { |tool| tool["chi_name"] }
617
- "running (pid #{server.service.value.pid}), #{count} tool#{"s" unless count == 1}"
749
+ "running (pid #{server.client.pid}), #{count} tool#{"s" unless count == 1}"
618
750
  when :starting then "starting"
619
751
  else "#{server.state == :failed ? "failed" : "stopped"}: #{server.error}"
620
752
  end
@@ -60,7 +60,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
60
60
  - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
61
61
  - `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
62
62
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
63
- - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; env `SAMAGOTCHI_GUARDRAILS_ENABLED`), `rules:` (list), `disable:` (list of rule ids, `id` or `bundle:id`, switching off a bundle's or config rule without editing it). A rule takes only `id`, `tool`, `command`, `path`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules load when a session starts: restart the worker/REPL after an edit. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
63
+ - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
64
64
 
65
65
  ```yaml
66
66
  guardrails:
@@ -122,7 +122,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
122
122
  3. Mutate the intended **nested** key in the raw hash. Preserve all other keys byte-for-byte where possible. Example for default model: `raw_data["default"] ||= {}; raw_data["default"]["model"] = "new-model"`.
123
123
  4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agent_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
124
124
  5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
125
- 6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, guardrail rules, bundle settings) waits for its restart. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
125
+ 6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, bundle settings) waits for its restart; guardrail rules and `model_aliases:` are read again live. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
126
126
 
127
127
  ## Validations
128
128
 
@@ -136,8 +136,8 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
136
136
  - must not point to itself (case-insensitive)
137
137
  - keys are normalized to downcase on write — `Qwen` and `qwen` collide
138
138
  - **Alias target**: non-empty string (model id).
139
- - **Hosts**: each entry needs `host`, `port` 1-65535, `transport` and `api` optional (a raw `api` must match `transport`), name must match `/\A[a-z0-9][a-z0-9._-]*\z/i`.
140
- - **Hooks**: each entry must have `path` (relative to `hooks_dir`), `on_error` is `skip` (default) or `log`. Class name must match file basename snake→Pascal.
139
+ - **Hosts**: each entry needs `host` (or `url:`, an http(s) URL with an optional path, replacing `host`/`port`; never both), `port` 1-65535 (default 8080), `enabled: false` skips the entry, `transport` and `api` optional (a raw `api` must match `transport`), name must match `/\A[a-z0-9][a-z0-9._-]*\z/i`.
140
+ - **Hooks**: each entry must have `path` (relative to `hooks_dir`), `on_error` is `skip` (default) or `log`; `required: true` on a `before_tool_call` hook denies the call when the hook raises. Class name must match file basename snake→Pascal.
141
141
  - **Scalars via registry**: `Config.coerce` validates `String/Numeric/true/false` per `type: :string/:integer/:float/:bool/:enum`; invalid values warn and fall back to entry `default`.
142
142
  - **Keys**: `validate_yaml_sections` flags unknown keys (with a suggestion), a registry section that isn't a mapping, and env/CLI-only keys (`model.profile`).
143
143
 
@@ -151,5 +151,5 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
151
151
 
152
152
  - Precedence is `CLI > ENV > file > default` (`Config.resolve`; `Config.get_with_origin` names the layer). `ENV` holds only what the user (or a spawning chi's CLI flags) set, and CLI (`--recap-base-url`) wins over both via `Config.reload!(cli_overrides:)`.
153
153
  - `--recap_base_url` (underscore) is rejected as unknown — use `--recap-base-url` (kebab). Same for all registry flags.
154
- - `model_aliases` require restart or `/model` reload to take effect; document the change.
154
+ - `model_aliases` are read again at each model resolution: an edit applies to the next `--model`, `/model`, new session or delegate, no restart. A running session keeps the model it already resolved.
155
155
  - Keep edits minimal: touch only the key you intend to change; preserve `hosts:`/`hooks:`/`guardrails:`/`bundles:` maps. Adding a `bundles: <name>:` entry does not install the bundle (`chi bundle install <name>`).
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.12.0
3
+ version: 0.14.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
9
- config_modification_protocol.md: sha256:dddd083d6f419674e6e0cc39118ca9785583a557e4950af1a9b028e52e8a3e0c
9
+ config_modification_protocol.md: sha256:d0e9d4a84652bf54ec2fd664f61c536637af9decdd22fcd982f52195bf9f5fb4
10
10
  memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "exit"
4
+
3
5
  module Samagotchi
4
6
  module CLI
5
7
  # The usage side of a subcommand: its error lines, usage errors (exit 2)
@@ -7,7 +9,6 @@ module Samagotchi
7
9
  # #command_name ("chi send"); its usage text is USAGE unless it
8
10
  # overrides #usage_text.
9
11
  module Command
10
- USAGE_EXIT = 2
11
12
  HELP_WORDS = %w[-h --help help].freeze
12
13
 
13
14
  private
@@ -30,7 +31,7 @@ module Samagotchi
30
31
  usage = usage_on_error
31
32
  error_line("#{command_name}: #{message}#{usage ? "" : " (see #{command_name} --help)"}")
32
33
  @stderr.puts(usage) if usage
33
- USAGE_EXIT
34
+ Exit::USAGE
34
35
  end
35
36
 
36
37
  # +argv+ through +flags+ (a Flags), help and usage errors done.
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module CLI
5
+ # chi's exit statuses, one table for every subcommand (docs/cli.md,
6
+ # docs/sub-agent.md): a script or a parent agent branches on them.
7
+ module Exit
8
+ OK = 0
9
+ # Anything that went wrong at run time: a refusal, a failed turn, a
10
+ # worker gone, a file that isn't there.
11
+ FAILED = 1
12
+ # The command line itself: an unknown flag, a missing argument, an
13
+ # option the question doesn't offer.
14
+ USAGE = 2
15
+ # A question waits for an answer (chi send --wait, chi answer, chi -p).
16
+ QUESTION = 3
17
+ # Still running after --timeout.
18
+ RUNNING = 4
19
+ # Ctrl-C: what a shell gives a command it interrupted.
20
+ INTERRUPTED = 130
21
+ end
22
+ end
23
+ end