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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -1
- data/README.md +20 -7
- data/bin/chi +9 -9
- data/docs/architecture.md +4 -2
- data/docs/cli.md +34 -10
- data/docs/configuration.md +79 -15
- data/docs/desktop.md +10 -6
- data/docs/guardrails.md +74 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +31 -16
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge/card_store.rb +94 -6
- data/lib/samagotchi/bridge.rb +14 -2
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +81 -24
- data/lib/samagotchi/context_window.rb +30 -5
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +54 -17
- data/lib/samagotchi/guardrails/parent_approvals.rb +158 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +31 -7
- data/lib/samagotchi/kernel_loop.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -3
- data/lib/samagotchi/llm/openai_chat.rb +9 -1
- data/lib/samagotchi/llm/turn_settings.rb +7 -5
- data/lib/samagotchi/memory_bundle/installer.rb +12 -0
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_profile.rb +30 -16
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/send_command.rb +50 -75
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_manager.rb +40 -7
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +11 -3
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +53 -14
- data/lib/samagotchi/terminal_ui.rb +30 -3
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +14 -2
- data/lib/samagotchi/tools/delegate_wait.rb +32 -13
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/memory.rb +3 -3
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/utf8_default.rb +29 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -10
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +6 -1
- data/lib/samagotchi/web/public/app.js +39 -6
- data/lib/samagotchi/web/public/index.html +8 -1
- data/lib/samagotchi/web/public/notify.js +16 -3
- data/lib/samagotchi/web/public/question_cards.js +3 -1
- data/lib/samagotchi/web/public/sessions_list.js +37 -0
- data/lib/samagotchi/web/public/turn_view.js +6 -9
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +3 -11
- 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
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
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:
|
|
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:
|
|
17
|
-
# #
|
|
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
|
|
21
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
310
|
-
#
|
|
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 =
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
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
|
-
|
|
545
|
-
next line
|
|
650
|
+
paths = image_paths(line, server).reject { |path| images.any? { |image| image[:path] == path } }
|
|
651
|
+
next line if paths.empty?
|
|
546
652
|
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
#
|
|
568
|
-
#
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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;
|
|
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
|
-
|
|
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.
|
|
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 `
|
|
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:`,
|
|
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
|
|
140
|
-
- **Hooks**: each entry must have `path` (relative to `hooks_dir`), `on_error` is `skip` (default) or `log
|
|
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`
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|