samagotchi 0.36.0 → 0.37.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 +25 -1
- data/README.md +2 -2
- data/bin/chi +1 -1
- data/docs/broadcast.md +91 -12
- data/docs/configuration.md +8 -1
- data/docs/guardrails.md +15 -0
- data/docs/plugins.md +23 -2
- data/lib/samagotchi/broadcast/triage.rb +267 -0
- data/lib/samagotchi/broadcast/triage_log.rb +165 -0
- data/lib/samagotchi/broadcast/triage_model.rb +56 -0
- data/lib/samagotchi/broadcast_command.rb +273 -69
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +35 -23
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cli/command.rb +27 -3
- data/lib/samagotchi/config.rb +9 -0
- data/lib/samagotchi/context_command.rb +0 -17
- data/lib/samagotchi/engine.rb +4 -6
- data/lib/samagotchi/guardrail_wiring.rb +7 -2
- data/lib/samagotchi/guardrails/approval.rb +5 -2
- data/lib/samagotchi/guardrails/approvals.rb +9 -5
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/targets.rb +76 -11
- data/lib/samagotchi/idle_client.rb +48 -28
- data/lib/samagotchi/idle_recap.rb +6 -5
- data/lib/samagotchi/idle_target.rb +95 -0
- data/lib/samagotchi/llm/openai_chat.rb +29 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/note_command.rb +0 -21
- data/lib/samagotchi/recap_setup.rb +38 -52
- data/lib/samagotchi/send_command.rb +0 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/question_card.js +4 -3
- metadata +5 -1
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.5.
|
|
3
|
+
version: 0.5.1
|
|
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:e41b880d70d2574f2be7d64e3ddee209ec77ce203f77d347ccdc6f9df1b358e1
|
|
11
11
|
requires_chi: ">= 0.33.0"
|
|
@@ -247,10 +247,17 @@ class Plugin
|
|
|
247
247
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
248
248
|
end
|
|
249
249
|
|
|
250
|
+
# One tool chi offers from a server (the tools: filter applied): +name+
|
|
251
|
+
# is the server's, +chi_name+ the model's (mcp_<server>_<tool>), +schema+
|
|
252
|
+
# its inputSchema (an empty object when it has none). +error+ is why
|
|
253
|
+
# declaring it failed (a name clash, a bad schema), nil when the model
|
|
254
|
+
# has it.
|
|
255
|
+
McpTool = Data.define(:server, :name, :chi_name, :description, :schema, :error)
|
|
256
|
+
|
|
250
257
|
# One configured server: its service, state and tools. +listed+ is its
|
|
251
258
|
# tools/list as the server answers it (what the cache keeps), +tools+
|
|
252
|
-
# the ones chi offers (
|
|
253
|
-
# +tokens+ their definitions' estimated size (#definition_tokens).
|
|
259
|
+
# the ones chi offers (McpTool each; #declare swaps in a whole new
|
|
260
|
+
# list), +tokens+ their definitions' estimated size (#definition_tokens).
|
|
254
261
|
# +client+ is the running process's (a restart replaces it; the
|
|
255
262
|
# service's stop closes the current one), +restarts+ how many it had.
|
|
256
263
|
Server = Struct.new(:name, :config, :service, :state, :error, :tools, :listed, :timeout, :cwd, :command, :env,
|
|
@@ -516,31 +523,36 @@ class Plugin
|
|
|
516
523
|
# tokens go to the log when they changed.
|
|
517
524
|
def declare(target, server, ctx)
|
|
518
525
|
wanted = server.config["tools"] && Array(server.config["tools"]).map(&:to_s)
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
server.tools = tools
|
|
526
|
+
listed = server.listed
|
|
527
|
+
listed = listed.select { |tool| wanted.any? { |w| File.fnmatch(w, tool["name"], File::FNM_EXTGLOB) } } if wanted
|
|
522
528
|
tokens = 0
|
|
523
|
-
tools.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
+
tools = listed.map do |listed_tool|
|
|
530
|
+
tool = McpTool.new(server: server.name, name: listed_tool["name"],
|
|
531
|
+
chi_name: tool_name(server.name, listed_tool["name"]), description: description(listed_tool),
|
|
532
|
+
schema: listed_tool["inputSchema"] || { "type" => "object", "properties" => {} }, error: nil)
|
|
533
|
+
target.tool(tool.chi_name, tool.description, schema: tool.schema,
|
|
534
|
+
label: "#{server.name}: #{tool.name}", preview: ->(args) { preview(args) }) do |args, call_ctx|
|
|
535
|
+
call(server, tool, args, call_ctx)
|
|
529
536
|
end
|
|
530
|
-
tool
|
|
531
|
-
|
|
537
|
+
tokens += definition_tokens(tool.chi_name, tool.description, tool.schema)
|
|
538
|
+
tool
|
|
532
539
|
rescue ArgumentError => e
|
|
533
|
-
text = "MCP tool #{server.name}/#{tool
|
|
540
|
+
text = "MCP tool #{server.name}/#{tool.name} left out: #{e.message}"
|
|
534
541
|
ctx.notify(text, level: :warn) unless @left_out.include?(text)
|
|
535
542
|
@left_out << text
|
|
543
|
+
tool.with(error: e.message)
|
|
536
544
|
end
|
|
545
|
+
# Whole, in one swap: a reader on another thread never sees half a list.
|
|
546
|
+
server.tools = tools.freeze
|
|
537
547
|
return if tokens == server.tokens
|
|
538
548
|
|
|
539
549
|
server.tokens = tokens
|
|
540
|
-
ctx.log.info("mcp_tools_estimated", server: server.name, tools:
|
|
541
|
-
tokens: tokens)
|
|
550
|
+
ctx.log.info("mcp_tools_estimated", server: server.name, tools: offered(server).size, tokens: tokens)
|
|
542
551
|
end
|
|
543
552
|
|
|
553
|
+
# The server's tools the model has (declared without an error).
|
|
554
|
+
def offered(server) = server.tools.reject(&:error)
|
|
555
|
+
|
|
544
556
|
# A tool's definition as the chat path sends it (LLM::ChatLoop#tool_definitions:
|
|
545
557
|
# the chat schema, wrapped as a function), in estimated tokens
|
|
546
558
|
# (TokenUsage::CHARS_PER_TOKEN). The native prompts (Gemma, Qwen) render a
|
|
@@ -579,8 +591,8 @@ class Plugin
|
|
|
579
591
|
line.length > PREVIEW_CHARS ? "#{line[0, PREVIEW_CHARS - 1]}…" : line
|
|
580
592
|
end
|
|
581
593
|
|
|
582
|
-
# A tools/call, as the model's tool result. A cached
|
|
583
|
-
# here, on its first call.
|
|
594
|
+
# A tools/call of +tool+ (McpTool), as the model's tool result. A cached
|
|
595
|
+
# server starts here, on its first call.
|
|
584
596
|
def call(server, tool, args, ctx)
|
|
585
597
|
return "Error: MCP server #{server.name} didn't start: #{server.error}" if server.state == :failed
|
|
586
598
|
|
|
@@ -593,9 +605,9 @@ class Plugin
|
|
|
593
605
|
end
|
|
594
606
|
return client if client.is_a?(String)
|
|
595
607
|
|
|
596
|
-
result = client.request("tools/call", { name: tool, arguments: args }, timeout: server.timeout,
|
|
597
|
-
|
|
598
|
-
text, images = content(result, server, tool)
|
|
608
|
+
result = client.request("tools/call", { name: tool.name, arguments: args }, timeout: server.timeout,
|
|
609
|
+
cancelled: -> { ctx.cancelled? })
|
|
610
|
+
text, images = content(result, server, tool.name)
|
|
599
611
|
return "Error: #{text.empty? ? "the tool failed" : text}" if result["isError"]
|
|
600
612
|
|
|
601
613
|
images.empty? ? text : Samagotchi::Plugin::ToolResult.new(text, images: images)
|
|
@@ -778,7 +790,7 @@ class Plugin
|
|
|
778
790
|
servers = @servers.map do |server|
|
|
779
791
|
head = "**#{server.name}**: #{state_text(server)}"
|
|
780
792
|
# Sorted: a server lists its tools in its own (often grouped) order.
|
|
781
|
-
tools = server.
|
|
793
|
+
tools = offered(server).map(&:chi_name).sort
|
|
782
794
|
tools.empty? ? head : "#{head}\n#{tools.map { |t| "- `#{t}`" }.join("\n")}"
|
|
783
795
|
end
|
|
784
796
|
total = @servers.sum { |server| OFFERED.include?(server.state) ? server.tokens : 0 }
|
|
@@ -791,7 +803,7 @@ class Plugin
|
|
|
791
803
|
case server.state
|
|
792
804
|
when :cached then "cached (not started), #{server.tools.size} tool#{"s" unless server.tools.size == 1}, #{tokens_text(server)}"
|
|
793
805
|
when :running
|
|
794
|
-
count = server.
|
|
806
|
+
count = offered(server).size
|
|
795
807
|
"running (pid #{server.client.pid}), #{count} tool#{"s" unless count == 1}, #{tokens_text(server)}"
|
|
796
808
|
when :starting then "starting"
|
|
797
809
|
else "#{server.state == :failed ? "failed" : "stopped"}: #{server.error}"
|
|
@@ -5,9 +5,9 @@ require_relative "exit"
|
|
|
5
5
|
module Samagotchi
|
|
6
6
|
module CLI
|
|
7
7
|
# The usage side of a subcommand: its error lines, usage errors (exit 2)
|
|
8
|
-
# and help. The including class sets @stdout and
|
|
9
|
-
#
|
|
10
|
-
# overrides #usage_text.
|
|
8
|
+
# and help, and its piped stdin. The including class sets @stdout and
|
|
9
|
+
# @stderr (@stdin for #read_stdin) and defines #command_name ("chi
|
|
10
|
+
# send"); its usage text is USAGE unless it overrides #usage_text.
|
|
11
11
|
module Command
|
|
12
12
|
HELP_WORDS = %w[-h --help help].freeze
|
|
13
13
|
|
|
@@ -47,6 +47,30 @@ module Samagotchi
|
|
|
47
47
|
|
|
48
48
|
parsed
|
|
49
49
|
end
|
|
50
|
+
|
|
51
|
+
# The including class's @stdin, read only when it is a pipe or a
|
|
52
|
+
# file. A terminal means nobody piped anything in, and a socket a
|
|
53
|
+
# launcher or an agent's shell passes down may never close: waiting
|
|
54
|
+
# on either would hang a script. (A pipe the caller never closes
|
|
55
|
+
# still hangs, as it would for cat.)
|
|
56
|
+
# @return [String, nil] nil for a terminal or a socket
|
|
57
|
+
def read_stdin
|
|
58
|
+
return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
|
|
59
|
+
|
|
60
|
+
if @stdin.respond_to?(:stat)
|
|
61
|
+
stat = @stdin.stat
|
|
62
|
+
return nil unless stat.pipe? || stat.file?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@stdin.read
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# +text+ as UTF-8 whatever the locale says: with no LANG/LC_* (an app
|
|
69
|
+
# started from Finder, launchd) stdin reads as US-ASCII and ARGV as
|
|
70
|
+
# binary. Invalid bytes become U+FFFD rather than an error.
|
|
71
|
+
def utf8(text)
|
|
72
|
+
text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
73
|
+
end
|
|
50
74
|
end
|
|
51
75
|
end
|
|
52
76
|
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -89,6 +89,15 @@ module Samagotchi
|
|
|
89
89
|
# delivers it there without triage.
|
|
90
90
|
Entry.new(key: "broadcast.active_hours", yaml_path: %w[broadcast active_hours], type: :float, default: 8.0, expose: %i[env config]),
|
|
91
91
|
Entry.new(key: "broadcast.ticket_pattern", yaml_path: %w[broadcast ticket_pattern], type: :string, default: '\b[A-Z][A-Z0-9]+-\d+\b', expose: %i[env config]),
|
|
92
|
+
# The triage model that judges a recipient no tag matched (Broadcast::TriageModel): unset, recap.*'s, then
|
|
93
|
+
# default.model. At most triage_parallel requests at a time, all within triage_deadline seconds (the rest are
|
|
94
|
+
# delivered unchecked); a logprob P(yes) under threshold skips the session.
|
|
95
|
+
Entry.new(key: "broadcast.triage_model", yaml_path: %w[broadcast triage_model], type: :string, default: nil, expose: %i[env config]),
|
|
96
|
+
Entry.new(key: "broadcast.triage_host_ref", yaml_path: %w[broadcast triage_host_ref], type: :string, default: nil, expose: %i[env config]),
|
|
97
|
+
Entry.new(key: "broadcast.triage_base_url", yaml_path: %w[broadcast triage_base_url], type: :string, default: nil, expose: %i[env config]),
|
|
98
|
+
Entry.new(key: "broadcast.triage_parallel", yaml_path: %w[broadcast triage_parallel], type: :integer, default: 4, expose: %i[env config]),
|
|
99
|
+
Entry.new(key: "broadcast.triage_deadline", yaml_path: %w[broadcast triage_deadline], type: :float, default: 20.0, expose: %i[env config]),
|
|
100
|
+
Entry.new(key: "broadcast.threshold", yaml_path: %w[broadcast threshold], type: :float, default: 0.5, expose: %i[env config]),
|
|
92
101
|
|
|
93
102
|
Entry.new(key: "session.retention_days", yaml_path: %w[session retention_days], type: :integer, default: 14, expose: %i[env config cli]),
|
|
94
103
|
Entry.new(key: "session.max_count", yaml_path: %w[session max_count], type: :integer, default: 500, expose: %i[env config cli]),
|
|
@@ -452,22 +452,5 @@ module Samagotchi
|
|
|
452
452
|
error_line("#{command_name}: #{message}")
|
|
453
453
|
CLI::Exit::FAILED
|
|
454
454
|
end
|
|
455
|
-
|
|
456
|
-
# Only a pipe or a file is read, as chi note does: a terminal or a
|
|
457
|
-
# socket would hang a script.
|
|
458
|
-
def read_stdin
|
|
459
|
-
return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
|
|
460
|
-
|
|
461
|
-
if @stdin.respond_to?(:stat)
|
|
462
|
-
stat = @stdin.stat
|
|
463
|
-
return nil unless stat.pipe? || stat.file?
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
@stdin.read
|
|
467
|
-
end
|
|
468
|
-
|
|
469
|
-
def utf8(text)
|
|
470
|
-
text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
471
|
-
end
|
|
472
455
|
end
|
|
473
456
|
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -2352,11 +2352,10 @@ module Samagotchi
|
|
|
2352
2352
|
# @return [String] the answer
|
|
2353
2353
|
def ask_side_model(request, timeout:, max_tokens:, cancel_controller:)
|
|
2354
2354
|
target = session_model_recap_target
|
|
2355
|
-
client = IdleClient.
|
|
2356
|
-
timeout: timeout)
|
|
2355
|
+
client = IdleClient.for(target, timeout: timeout)
|
|
2357
2356
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
2358
2357
|
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller)
|
|
2359
|
-
Log.info(:plugins, "ask_model", model: target
|
|
2358
|
+
Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
|
|
2360
2359
|
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
2361
2360
|
answer.text
|
|
2362
2361
|
end
|
|
@@ -2372,10 +2371,9 @@ module Samagotchi
|
|
|
2372
2371
|
# The session's current model as a recap target: its host's OpenAI API
|
|
2373
2372
|
# (native llama.cpp hosts serve /v1/chat/completions too), key variable
|
|
2374
2373
|
# and bare model name, as a turn resolves them.
|
|
2374
|
+
# @return [IdleTarget]
|
|
2375
2375
|
def session_model_recap_target
|
|
2376
|
-
|
|
2377
|
-
{ base_url: target.openai_base_url, api_key_env: target.entry.api_key_env,
|
|
2378
|
-
model: target.bare_model, label: @effective_model_name.to_s }
|
|
2376
|
+
IdleTarget.of_model(@host_registry, @effective_model_name)
|
|
2379
2377
|
end
|
|
2380
2378
|
|
|
2381
2379
|
# Build the idle reminders job. Always created (reminders are opt-in
|
|
@@ -226,8 +226,13 @@ module Samagotchi
|
|
|
226
226
|
return verdict.settle!(:deny, decided_by: "no one", note: "No one to approve it (non-interactive run).")
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
-
# A plugin tool is asked about by its label
|
|
230
|
-
label
|
|
229
|
+
# A plugin tool is asked about by its targets:' label:, else its
|
|
230
|
+
# label, as its row shows it; a call that acts as another tool (an MCP
|
|
231
|
+
# tool behind mcp_call) by that tool's label, else (none registered)
|
|
232
|
+
# by its name (Approval.payload).
|
|
233
|
+
acts_as = verdict.targets&.acts_as
|
|
234
|
+
label = verdict.targets&.label ||
|
|
235
|
+
ToolActivity.plugin_label(acts_as || verdict.call[:name].to_s, registry: @tools_lookup.call)
|
|
231
236
|
# verdict.call is the call that will run (a hook may have replaced it).
|
|
232
237
|
payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call),
|
|
233
238
|
only: (SCRATCH_SCOPES if @scratch))
|
|
@@ -18,7 +18,8 @@ module Samagotchi
|
|
|
18
18
|
|
|
19
19
|
# @param verdict [Verdict] an ask, with its targets and context
|
|
20
20
|
# @param label [String, nil] a plugin tool's label ("chrome:
|
|
21
|
-
# screenshot"): the question names the tool by it, as its row does
|
|
21
|
+
# screenshot"): the question names the tool by it, as its row does
|
|
22
|
+
# (without one, by the tool the call acts as, else its name);
|
|
22
23
|
# approval[:tool] stays the raw name
|
|
23
24
|
# @param preview [Hash, nil] EditPreview.for(verdict.call) for edit and
|
|
24
25
|
# write: the card shows its diff, the question text one change line
|
|
@@ -37,6 +38,8 @@ module Samagotchi
|
|
|
37
38
|
kind: KIND,
|
|
38
39
|
approval: {
|
|
39
40
|
tool: targets&.tool || verdict.call[:name].to_s,
|
|
41
|
+
# The tool the call acts as (an MCP tool behind mcp_call).
|
|
42
|
+
acts_as: targets&.acts_as,
|
|
40
43
|
label: label,
|
|
41
44
|
command: targets&.command,
|
|
42
45
|
paths: targets && !targets.paths.empty? ? targets.paths : nil,
|
|
@@ -126,7 +129,7 @@ module Samagotchi
|
|
|
126
129
|
# change: +3 −1 (edit/write, from the preview)
|
|
127
130
|
def question_text(verdict, label: nil, preview: nil)
|
|
128
131
|
targets = verdict.targets
|
|
129
|
-
tool = label || targets&.tool || verdict.call[:name].to_s
|
|
132
|
+
tool = label || targets&.acts_as || targets&.tool || verdict.call[:name].to_s
|
|
130
133
|
what = targets&.command || (targets && targets.paths.join(", "))
|
|
131
134
|
what = Approval.args_text(targets&.args, limit: ARGS_CHARS) if what.to_s.empty?
|
|
132
135
|
lines = ["#{tool}: #{what}"]
|
|
@@ -90,7 +90,7 @@ module Samagotchi
|
|
|
90
90
|
return nil unless STORED_SCOPES.include?(scope)
|
|
91
91
|
|
|
92
92
|
entry = { "scope" => scope, "tool" => verdict.targets&.tool || verdict.call[:name].to_s,
|
|
93
|
-
"rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
|
|
93
|
+
"acts_as" => verdict.targets&.acts_as, "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
|
|
94
94
|
case scope
|
|
95
95
|
when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
|
|
96
96
|
when "repo"
|
|
@@ -110,14 +110,18 @@ module Samagotchi
|
|
|
110
110
|
|
|
111
111
|
# tool + the normalized command, or the sorted paths; for a plugin
|
|
112
112
|
# tool with neither (an MCP tool), its arguments, so "this call" is
|
|
113
|
-
# this call.
|
|
113
|
+
# this call. A call that acts as another tool keys as
|
|
114
|
+
# `mcp_call>mcp_github_x`: the real name stays in, so it never reuses
|
|
115
|
+
# that tool's own approvals (nor they its).
|
|
114
116
|
def self.key_for(verdict)
|
|
115
117
|
t = verdict.targets
|
|
116
118
|
return "#{verdict.call[:name]}:" unless t
|
|
117
|
-
return "#{t.tool}:#{t.command.to_s.strip.gsub(/\s+/, " ")}" if t.command
|
|
118
|
-
return "#{t.tool}:#{Approval.args_text(t.args)}" if t.paths.empty?
|
|
119
119
|
|
|
120
|
-
"#{t.tool}
|
|
120
|
+
tool = t.acts_as ? "#{t.tool}>#{t.acts_as}" : t.tool
|
|
121
|
+
return "#{tool}:#{t.command.to_s.strip.gsub(/\s+/, " ")}" if t.command
|
|
122
|
+
return "#{tool}:#{Approval.args_text(t.args)}" if t.paths.empty?
|
|
123
|
+
|
|
124
|
+
"#{tool}:#{t.paths.sort.join("\n")}"
|
|
121
125
|
end
|
|
122
126
|
|
|
123
127
|
def self.place_for(verdict)
|
|
@@ -9,7 +9,9 @@ module Samagotchi
|
|
|
9
9
|
# Declarative rules from config.yml's `guardrails:` section (and, later,
|
|
10
10
|
# installed bundles). All the fields a rule gives must match:
|
|
11
11
|
# tool: a tool name, a list, or "shell" (execute + task_create);
|
|
12
|
-
# a name may be a glob ("mcp_*", "mcp_{git,gh}_*")
|
|
12
|
+
# a name may be a glob ("mcp_*", "mcp_{git,gh}_*"); it
|
|
13
|
+
# matches the call's name or the tool a plugin tool's call
|
|
14
|
+
# acts as (Targets#acts_as: an MCP tool behind mcp_call)
|
|
13
15
|
# command: a Ruby regex on a shell tool's command
|
|
14
16
|
# path: "outside_repo", or a glob on the resolved paths (absolute
|
|
15
17
|
# or "**/…" globs match the absolute path; others the path
|
|
@@ -51,7 +53,7 @@ module Samagotchi
|
|
|
51
53
|
def matches?(targets)
|
|
52
54
|
return false unless targets
|
|
53
55
|
return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
|
|
54
|
-
return false if tools && !tool_matches?(targets.tool)
|
|
56
|
+
return false if tools && !tool_matches?(targets.tool) && !(targets.acts_as && tool_matches?(targets.acts_as))
|
|
55
57
|
return false if command && !(targets.command && command.match?(targets.command))
|
|
56
58
|
return false if path && !path_matches?(targets)
|
|
57
59
|
return false if git && !targets.git_outside_repo?
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../tools/tool_path"
|
|
4
4
|
require_relative "../tools/memory"
|
|
5
|
+
require_relative "../tool_declarations"
|
|
5
6
|
require_relative "../log"
|
|
6
7
|
require_relative "model_size"
|
|
7
8
|
require_relative "outside"
|
|
@@ -21,6 +22,9 @@ module Samagotchi
|
|
|
21
22
|
SHELL_TOOLS = %w[execute task_create].freeze
|
|
22
23
|
PATH_TOOLS = %w[write edit read memory_write].freeze
|
|
23
24
|
|
|
25
|
+
# chi's own tools, by name, whether this session has them or not.
|
|
26
|
+
CORE_TOOLS = ToolDeclarations::TOOL_SCHEMAS.map { |schema| schema[:name] }.freeze
|
|
27
|
+
|
|
24
28
|
attr_reader :tool, :command, :paths, :cwd, :repo_root
|
|
25
29
|
|
|
26
30
|
# @return [String, nil] the call's repository (Context#repo: its
|
|
@@ -37,11 +41,25 @@ module Samagotchi
|
|
|
37
41
|
# prefix) and its key (ModelOverlay.key_for), for a rule's models:
|
|
38
42
|
attr_reader :model_name, :model_key
|
|
39
43
|
|
|
40
|
-
# @return [Hash, nil] a plugin tool's arguments
|
|
41
|
-
#
|
|
42
|
-
#
|
|
44
|
+
# @return [Hash, nil] a plugin tool's arguments, for an approval of a
|
|
45
|
+
# call whose targets name no command or path: its targets:' args:
|
|
46
|
+
# when given (the arguments it acts with, a dispatcher's inner
|
|
47
|
+
# ones), else call[:args]; nil for chi's own tools
|
|
43
48
|
attr_reader :args
|
|
44
49
|
|
|
50
|
+
# @return [String, nil] the tool a plugin tool's call acts as (its
|
|
51
|
+
# targets:' acts_as:, an MCP tool behind mcp_call): a rule's tool:
|
|
52
|
+
# matches it as well as the call's name. Never a core tool's or
|
|
53
|
+
# another bundle's (.plugin_targets drops those). #tool stays the
|
|
54
|
+
# call's name.
|
|
55
|
+
attr_reader :acts_as
|
|
56
|
+
|
|
57
|
+
# @return [String, nil] what a plugin tool's targets: says to name the
|
|
58
|
+
# call by in the approval question (label:, "github: x"), in place of
|
|
59
|
+
# the tool's registered label; display only (no rule or approval key
|
|
60
|
+
# reads it)
|
|
61
|
+
attr_reader :label
|
|
62
|
+
|
|
45
63
|
# @return [Boolean] a memory_write call that removes a memory (remove:
|
|
46
64
|
# true), for a rule's memory: remove
|
|
47
65
|
attr_reader :memory_remove
|
|
@@ -61,6 +79,8 @@ module Samagotchi
|
|
|
61
79
|
paths = []
|
|
62
80
|
cwd = base
|
|
63
81
|
args = nil
|
|
82
|
+
acts_as = nil
|
|
83
|
+
label = nil
|
|
64
84
|
case tool
|
|
65
85
|
when *SHELL_TOOLS
|
|
66
86
|
command = call[:content].to_s
|
|
@@ -79,21 +99,27 @@ module Samagotchi
|
|
|
79
99
|
cwd = File.expand_path(given_cwd, base) unless given_cwd.empty?
|
|
80
100
|
paths.concat(given[:paths].map { |path| absolute(path, cwd) })
|
|
81
101
|
entry = registry && registry[tool]
|
|
82
|
-
args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
|
|
102
|
+
args = given[:args] || call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
|
|
103
|
+
acts_as = given[:acts_as]
|
|
104
|
+
label = given[:label]
|
|
83
105
|
end
|
|
84
106
|
memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
|
|
85
107
|
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
|
|
86
|
-
repo: context.repo(cwd), args: args, memory_remove: memory_remove,
|
|
108
|
+
repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, memory_remove: memory_remove,
|
|
87
109
|
model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
|
|
88
110
|
chi_dirs: -> { chi_dirs(context, base) })
|
|
89
111
|
end
|
|
90
112
|
|
|
91
113
|
# What a plugin tool's targets: callable says the call acts on:
|
|
92
|
-
# {command:, paths:, cwd:}, each optional; a callable
|
|
93
|
-
# gives something else counts as nothing (and is
|
|
114
|
+
# {command:, paths:, cwd:, acts_as:, args:, label:}, each optional; a callable
|
|
115
|
+
# that raises or gives something else counts as nothing (and is
|
|
116
|
+
# logged). args: counts when it is a Hash; acts_as: is dropped (and
|
|
117
|
+
# logged) when it names a core tool or another bundle's tool, so a
|
|
118
|
+
# call can't pass for one and match (or reuse the approvals of) its
|
|
119
|
+
# rules. label: counts when it is one short line (.label_of).
|
|
94
120
|
def self.plugin_targets(call, registry)
|
|
95
121
|
entry = registry && registry[call[:name].to_s]
|
|
96
|
-
none = { command: nil, paths: [], cwd: nil }
|
|
122
|
+
none = { command: nil, paths: [], cwd: nil, acts_as: nil, args: nil, label: nil }
|
|
97
123
|
return none unless entry && !entry.core? && entry.targets
|
|
98
124
|
|
|
99
125
|
given = entry.targets.call(call)
|
|
@@ -101,13 +127,50 @@ module Samagotchi
|
|
|
101
127
|
|
|
102
128
|
given = given.transform_keys(&:to_sym)
|
|
103
129
|
command = given[:command].to_s
|
|
104
|
-
{ command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), cwd: given[:cwd]
|
|
130
|
+
{ command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), cwd: given[:cwd],
|
|
131
|
+
acts_as: acts_as_of(given[:acts_as], entry, registry), args: given[:args].is_a?(Hash) ? given[:args] : nil,
|
|
132
|
+
label: label_of(given[:label], entry) }
|
|
105
133
|
rescue StandardError => e
|
|
106
134
|
Log.warn(:plugins, "plugin_targets_failed", tool: call[:name].to_s, error: e.class.name,
|
|
107
135
|
msg: "#{call[:name]} targets failed: #{e.message}")
|
|
108
136
|
none
|
|
109
137
|
end
|
|
110
138
|
|
|
139
|
+
# +name+ as the acts_as of +entry+'s calls: nil for none, and for a
|
|
140
|
+
# core tool or a tool another bundle registered (logged); the tool's
|
|
141
|
+
# own name is no alias either.
|
|
142
|
+
def self.acts_as_of(name, entry, registry)
|
|
143
|
+
name = name.to_s.strip
|
|
144
|
+
return nil if name.empty? || name == entry.name
|
|
145
|
+
|
|
146
|
+
other = registry[name]
|
|
147
|
+
whose = if CORE_TOOLS.include?(name) || other&.core? then "a core tool"
|
|
148
|
+
elsif other && other.source != entry.source then "bundle #{other.source}'s tool"
|
|
149
|
+
end
|
|
150
|
+
return name unless whose
|
|
151
|
+
|
|
152
|
+
Log.warn(:plugins, "plugin_acts_as_dropped", tool: entry.name, acts_as: name,
|
|
153
|
+
msg: "#{entry.name} targets: acts_as #{name} dropped: it is #{whose}")
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The longest label: the question takes from targets:.
|
|
158
|
+
LABEL_CHARS = 80
|
|
159
|
+
|
|
160
|
+
# +label+ as a call's question label: stripped; nil for none, and for
|
|
161
|
+
# anything but a String of one line up to LABEL_CHARS (logged).
|
|
162
|
+
def self.label_of(label, entry)
|
|
163
|
+
return nil if label.nil?
|
|
164
|
+
|
|
165
|
+
text = label.is_a?(String) ? label.strip : nil
|
|
166
|
+
return nil if text && text.empty?
|
|
167
|
+
return text if text && !text.include?("\n") && text.length <= LABEL_CHARS
|
|
168
|
+
|
|
169
|
+
Log.warn(:plugins, "plugin_label_ignored", tool: entry.name,
|
|
170
|
+
msg: "#{entry.name} targets: label ignored: not one line of at most #{LABEL_CHARS} characters")
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
111
174
|
# chi's own dirs (ParentApprovals.chi_dirs) and the session repo's
|
|
112
175
|
# git hooks dir.
|
|
113
176
|
def self.chi_dirs(context, base)
|
|
@@ -133,8 +196,10 @@ module Samagotchi
|
|
|
133
196
|
end
|
|
134
197
|
|
|
135
198
|
# @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
|
|
136
|
-
def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil,
|
|
137
|
-
session_root: nil, chi_dirs: nil, memory_remove: false)
|
|
199
|
+
def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, acts_as: nil, label: nil,
|
|
200
|
+
model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil, memory_remove: false)
|
|
201
|
+
@acts_as = acts_as
|
|
202
|
+
@label = label
|
|
138
203
|
@memory_remove = memory_remove
|
|
139
204
|
@repo = repo
|
|
140
205
|
@chi_dirs = chi_dirs
|