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.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.5.0
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:b946862b2c74734dd5af9ffba12f14a79b760b993a1248630f9fc5ecaaa740f5
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 (the tools: filter applied, each with chi_name),
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
- tools = server.listed.map(&:dup)
520
- tools = tools.select { |tool| wanted.any? { |w| File.fnmatch(w, tool["name"], File::FNM_EXTGLOB) } } if wanted
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.each do |tool|
524
- name = tool_name(server.name, tool["name"])
525
- schema = tool["inputSchema"] || { "type" => "object", "properties" => {} }
526
- target.tool(name, description(tool), schema: schema,
527
- label: "#{server.name}: #{tool["name"]}", preview: ->(args) { preview(args) }) do |args, call_ctx|
528
- call(server, tool["name"], args, call_ctx)
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["chi_name"] = name
531
- tokens += definition_tokens(name, description(tool), schema)
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["name"]} left out: #{e.message}"
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: tools.count { |tool| tool["chi_name"] },
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 server starts
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
- cancelled: -> { ctx.cancelled? })
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.tools.map { |tool| tool["chi_name"] }.compact.sort
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.tools.count { |tool| tool["chi_name"] }
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}"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.36.0
3
+ version: 0.37.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:
@@ -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 @stderr and defines
9
- # #command_name ("chi send"); its usage text is USAGE unless it
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
@@ -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
@@ -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.new(model: target[:model], base_url: target[:base_url], api_key_env: target[:api_key_env],
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[:label], answer_model: answer.model, chars: answer.text.length,
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
- target = @host_registry.resolve(@effective_model_name)
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, as its row shows it.
230
- label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools_lookup.call)
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}:#{t.paths.sort.join("\n")}"
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 (call[:args]), for an
41
- # approval of a call whose targets name no command or path; nil for
42
- # chi's own tools
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 that raises or
93
- # gives something else counts as nothing (and is logged).
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, model_name: nil, model_key: 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