samagotchi 0.35.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +2 -2
  4. data/bin/chi +1 -1
  5. data/docs/broadcast.md +91 -12
  6. data/docs/configuration.md +8 -1
  7. data/docs/context.md +37 -0
  8. data/docs/guardrails.md +26 -3
  9. data/docs/hooks.md +1 -1
  10. data/docs/memory.md +3 -0
  11. data/docs/plugins.md +51 -4
  12. data/lib/samagotchi/broadcast/triage.rb +267 -0
  13. data/lib/samagotchi/broadcast/triage_log.rb +165 -0
  14. data/lib/samagotchi/broadcast/triage_model.rb +56 -0
  15. data/lib/samagotchi/broadcast_command.rb +273 -69
  16. data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
  17. data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
  18. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
  19. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  20. data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  23. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/mcp/plugin.rb +72 -27
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
  27. data/lib/samagotchi/cli/command.rb +27 -3
  28. data/lib/samagotchi/config.rb +9 -0
  29. data/lib/samagotchi/context_command.rb +0 -17
  30. data/lib/samagotchi/engine.rb +4 -6
  31. data/lib/samagotchi/guardrail_wiring.rb +7 -2
  32. data/lib/samagotchi/guardrails/approval.rb +5 -2
  33. data/lib/samagotchi/guardrails/approvals.rb +9 -5
  34. data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
  35. data/lib/samagotchi/guardrails/rules.rb +19 -6
  36. data/lib/samagotchi/guardrails/targets.rb +83 -11
  37. data/lib/samagotchi/idle_client.rb +48 -28
  38. data/lib/samagotchi/idle_recap.rb +6 -5
  39. data/lib/samagotchi/idle_target.rb +95 -0
  40. data/lib/samagotchi/llm/openai_chat.rb +29 -3
  41. data/lib/samagotchi/log_line.rb +1 -1
  42. data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
  43. data/lib/samagotchi/note_command.rb +0 -21
  44. data/lib/samagotchi/recap_setup.rb +38 -52
  45. data/lib/samagotchi/send_command.rb +0 -22
  46. data/lib/samagotchi/session_commands.rb +1 -0
  47. data/lib/samagotchi/tool_activity.rb +14 -3
  48. data/lib/samagotchi/tool_declarations.rb +8 -4
  49. data/lib/samagotchi/tools/builtins.rb +1 -1
  50. data/lib/samagotchi/tools/memory.rb +103 -2
  51. data/lib/samagotchi/version.rb +1 -1
  52. data/lib/samagotchi/web/public/question_card.js +4 -3
  53. metadata +5 -1
@@ -27,11 +27,14 @@
27
27
  # A server that doesn't start, answer or list its tools is skipped with a
28
28
  # notice; the rest of chi works. One that exits mid-session starts again on
29
29
  # its next call, at most MAX_RESTARTS times a session. /mcp lists the
30
- # servers and their tools.
30
+ # servers and their tools, with an estimate of the tokens their
31
+ # definitions take in every request.
31
32
  require "digest"
32
33
  require "json"
33
34
  require "open3"
34
35
  require "samagotchi/process_group"
36
+ require "samagotchi/token_usage"
37
+ require "samagotchi/tool_declarations"
35
38
  require "time"
36
39
  require "shellwords"
37
40
  require "tmpdir"
@@ -47,6 +50,8 @@ class Plugin
47
50
  CACHE_TTL = 24 * 60 * 60
48
51
  # Restarts of a server that exited, per session.
49
52
  MAX_RESTARTS = 3
53
+ # The states whose server's tools the model has (what publish declares).
54
+ OFFERED = %i[running cached exited].freeze
50
55
  IMAGE_EXT = { "image/png" => "png", "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp" }.freeze
51
56
 
52
57
  # A JSON-RPC client for one MCP server over stdio: newline-delimited JSON
@@ -242,13 +247,21 @@ class Plugin
242
247
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
243
248
  end
244
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
+
245
257
  # One configured server: its service, state and tools. +listed+ is its
246
258
  # tools/list as the server answers it (what the cache keeps), +tools+
247
- # the ones chi offers (the tools: filter applied, each with chi_name).
259
+ # the ones chi offers (McpTool each; #declare swaps in a whole new
260
+ # list), +tokens+ their definitions' estimated size (#definition_tokens).
248
261
  # +client+ is the running process's (a restart replaces it; the
249
262
  # service's stop closes the current one), +restarts+ how many it had.
250
263
  Server = Struct.new(:name, :config, :service, :state, :error, :tools, :listed, :timeout, :cwd, :command, :env,
251
- :digest, :cached_at, :relist, :relisting, :client, :restarts, keyword_init: true)
264
+ :digest, :cached_at, :relist, :relisting, :client, :restarts, :tokens, keyword_init: true)
252
265
 
253
266
  def initialize(settings = {})
254
267
  @settings = settings
@@ -278,7 +291,7 @@ class Plugin
278
291
  configs = {} unless configs.is_a?(Hash)
279
292
  @servers = configs.map do |name, config|
280
293
  server = Server.new(name: name.to_s, config: config.is_a?(Hash) ? config : {}, state: :starting, tools: [],
281
- restarts: 0)
294
+ restarts: 0, tokens: 0)
282
295
  server.timeout = positive(server.config["timeout"]) || @timeout
283
296
  resolve(server, ctx)
284
297
  server.service = chi.service(name) { |svc| start(server, svc, ctx) }
@@ -506,24 +519,48 @@ class Plugin
506
519
  # ── Tools ─────────────────────────────────────────────────────────────
507
520
 
508
521
  # Declare the server's tools on +target+ (chi at load, or the set of
509
- # chi.replace_tools later), the tools: filter applied.
522
+ # chi.replace_tools later), the tools: filter applied. Their estimated
523
+ # tokens go to the log when they changed.
510
524
  def declare(target, server, ctx)
511
525
  wanted = server.config["tools"] && Array(server.config["tools"]).map(&:to_s)
512
- tools = server.listed.map(&:dup)
513
- tools = tools.select { |tool| wanted.any? { |w| File.fnmatch(w, tool["name"], File::FNM_EXTGLOB) } } if wanted
514
- server.tools = tools
515
- tools.each do |tool|
516
- name = tool_name(server.name, tool["name"])
517
- target.tool(name, description(tool), schema: tool["inputSchema"] || { "type" => "object", "properties" => {} },
518
- label: "#{server.name}: #{tool["name"]}", preview: ->(args) { preview(args) }) do |args, call_ctx|
519
- call(server, tool["name"], args, call_ctx)
526
+ listed = server.listed
527
+ listed = listed.select { |tool| wanted.any? { |w| File.fnmatch(w, tool["name"], File::FNM_EXTGLOB) } } if wanted
528
+ tokens = 0
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)
520
536
  end
521
- tool["chi_name"] = name
537
+ tokens += definition_tokens(tool.chi_name, tool.description, tool.schema)
538
+ tool
522
539
  rescue ArgumentError => e
523
- text = "MCP tool #{server.name}/#{tool["name"]} left out: #{e.message}"
540
+ text = "MCP tool #{server.name}/#{tool.name} left out: #{e.message}"
524
541
  ctx.notify(text, level: :warn) unless @left_out.include?(text)
525
542
  @left_out << text
543
+ tool.with(error: e.message)
526
544
  end
545
+ # Whole, in one swap: a reader on another thread never sees half a list.
546
+ server.tools = tools.freeze
547
+ return if tokens == server.tokens
548
+
549
+ server.tokens = tokens
550
+ ctx.log.info("mcp_tools_estimated", server: server.name, tools: offered(server).size, tokens: tokens)
551
+ end
552
+
553
+ # The server's tools the model has (declared without an error).
554
+ def offered(server) = server.tools.reject(&:error)
555
+
556
+ # A tool's definition as the chat path sends it (LLM::ChatLoop#tool_definitions:
557
+ # the chat schema, wrapped as a function), in estimated tokens
558
+ # (TokenUsage::CHARS_PER_TOKEN). The native prompts (Gemma, Qwen) render a
559
+ # flatter schema, so it is an upper bound there.
560
+ def definition_tokens(name, description, schema)
561
+ spec = Samagotchi::Plugin::Api.tool_spec(name, description, schema: schema) { nil }
562
+ function = Samagotchi::ToolDeclarations.chat_schemas([spec[:schema]]).first.slice(:name, :description, :parameters)
563
+ Samagotchi::TokenUsage.estimate(JSON.generate({ type: "function", function: function }))
527
564
  end
528
565
 
529
566
  # The servers' tools changed after load (a live list that differs from
@@ -532,7 +569,7 @@ class Plugin
532
569
  def publish(ctx)
533
570
  @publish.synchronize do
534
571
  @chi.replace_tools do |set|
535
- @servers.each { |server| declare(set, server, ctx) if %i[running cached exited].include?(server.state) }
572
+ @servers.each { |server| declare(set, server, ctx) if OFFERED.include?(server.state) }
536
573
  end
537
574
  end
538
575
  end
@@ -554,8 +591,8 @@ class Plugin
554
591
  line.length > PREVIEW_CHARS ? "#{line[0, PREVIEW_CHARS - 1]}…" : line
555
592
  end
556
593
 
557
- # A tools/call, as the model's tool result. A cached server starts
558
- # 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.
559
596
  def call(server, tool, args, ctx)
560
597
  return "Error: MCP server #{server.name} didn't start: #{server.error}" if server.state == :failed
561
598
 
@@ -568,9 +605,9 @@ class Plugin
568
605
  end
569
606
  return client if client.is_a?(String)
570
607
 
571
- result = client.request("tools/call", { name: tool, arguments: args }, timeout: server.timeout,
572
- cancelled: -> { ctx.cancelled? })
573
- 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)
574
611
  return "Error: #{text.empty? ? "the tool failed" : text}" if result["isError"]
575
612
 
576
613
  images.empty? ? text : Samagotchi::Plugin::ToolResult.new(text, images: images)
@@ -750,20 +787,24 @@ class Plugin
750
787
  def listing
751
788
  return "No servers. Add them in config.yml under `bundles: mcp: servers:` (docs/plugins.md, The mcp bundle)." if @servers.empty?
752
789
 
753
- @servers.map do |server|
790
+ servers = @servers.map do |server|
754
791
  head = "**#{server.name}**: #{state_text(server)}"
755
792
  # Sorted: a server lists its tools in its own (often grouped) order.
756
- tools = server.tools.map { |tool| tool["chi_name"] }.compact.sort
793
+ tools = offered(server).map(&:chi_name).sort
757
794
  tools.empty? ? head : "#{head}\n#{tools.map { |t| "- `#{t}`" }.join("\n")}"
758
- end.join("\n\n")
795
+ end
796
+ total = @servers.sum { |server| OFFERED.include?(server.state) ? server.tokens : 0 }
797
+ [*servers, "Total: ~#{thousands(total)} tokens of tool definitions in every request " \
798
+ "(estimated: their JSON as the chat API gets it, ÷ #{format("%g", Samagotchi::TokenUsage::CHARS_PER_TOKEN)})."]
799
+ .join("\n\n")
759
800
  end
760
801
 
761
802
  def state_text(server)
762
803
  case server.state
763
- when :cached then "cached (not started), #{server.tools.size} tool#{"s" unless server.tools.size == 1}"
804
+ when :cached then "cached (not started), #{server.tools.size} tool#{"s" unless server.tools.size == 1}, #{tokens_text(server)}"
764
805
  when :running
765
- count = server.tools.count { |tool| tool["chi_name"] }
766
- "running (pid #{server.client.pid}), #{count} tool#{"s" unless count == 1}"
806
+ count = offered(server).size
807
+ "running (pid #{server.client.pid}), #{count} tool#{"s" unless count == 1}, #{tokens_text(server)}"
767
808
  when :starting then "starting"
768
809
  else "#{server.state == :failed ? "failed" : "stopped"}: #{server.error}"
769
810
  end
@@ -771,6 +812,10 @@ class Plugin
771
812
  "stopped"
772
813
  end
773
814
 
815
+ def tokens_text(server) = "~#{thousands(server.tokens)} tokens"
816
+
817
+ def thousands(number) = number.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
818
+
774
819
  def positive(value)
775
820
  number = Float(value.to_s, exception: false)
776
821
  number&.positive? ? number : nil
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.35.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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:37063eb74fdcb4cde748f17193ff23f832723eb38f81aff086f48f410ce7497b
9
9
  config_modification_protocol.md: sha256:ec0b0c7023dbf03b93879e51cd5cdc38b22d43a2e5984ee11e8542ace077c3be
10
- memory_guide.md: sha256:63d5d7f7d704e6ce7e28dd99e532b714c1b9f7acf548092404248d8ca5385b80
10
+ memory_guide.md: sha256:f7049304a333be842b761037bab5c8e31e890b8f4ad12f092b14ee8deb053c51
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -33,14 +33,16 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
33
33
  - Parameters: `name: "entry_name"`, `content: "..."`, `scope: "project"|"system"`, optional `description: "one-liner"`, optional `current_model_only: true`.
34
34
  - `name` is the entry name **without** `.md` (the tool adds it). Use `name`, **not** `path` — `path` belongs to the file tools and is ignored here. `name: "index"` is a verbatim write to `index.md` (no auto-index update) — rarely needed.
35
35
  - `scope` is **required** — never omit. Prefer `project` for repo conventions, `system` for user preferences.
36
- - `description` is appended to the managed `index.md` line (`— description`). Replaces previous description if given; otherwise preserves existing one.
36
+ - `description` is appended to the managed `index.md` line (`— description`): one line, at most 200 characters (longer is refused; newlines become spaces). Replaces previous description if given; a missing or blank one preserves the existing one.
37
+ - To change only the description, pass `name`, `scope` and `description` without `content`: the index line changes (its date becomes today), the file stays as it is. The memory must exist in that scope.
38
+ - To remove a memory, pass `name`, `scope` and `remove: true` (nothing else): its file and model overlays move to the bundle trash (`chi bundle trash` lists and empties it) and its index line goes, a dangling line too. Only when the user asked or a skill's steps say so; the guardrails ask the user first. A memory a bundle installed is refused: `chi bundle uninstall` owns it.
37
39
  - For a small change to an existing memory (a step, a line), `edit` its file instead of rewriting it all: the path is in `memory_write`'s result and the scope dirs are in the prompt. Its index line is refreshed either way.
38
40
  - On success returns `Memory 'name' saved to <scope> scope (N bytes). File written: <path> Index line refreshed automatically.` — confirm `bytes` and `scope`; the index needs nothing from you.
39
41
 
40
42
  **When to write:**
41
43
  - After learning a durable preference (e.g. commit style, test command, coding guideline) that the user confirmed or you observed repeatedly — ask before overwriting existing entries where appropriate.
42
44
  - Keep entries small and focused (one topic per file). Use clear filenames: `commit_preferences`, `testing_guide`, `project_conventions`.
43
- - Never store secrets, tokens, or transient state. Memories are shared via bundles.
45
+ - Never store secrets, tokens, or transient state. Memories are shared via bundles. One exception: a skill may keep a working handoff memory (its steps say so) whose status is its description, removed when the work is done.
44
46
 
45
47
  **Placeholders:**
46
48
  - Content may contain placeholder hints written as double-curly braces around a name (e.g., test_command, language). Detected by `Placeholder` (`Placeholder::PLACEHOLDER_RE`) — install warns but does not fail. Fill them when you write. The placeholder syntax is two opening braces, a name, two closing braces.
@@ -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)
@@ -46,6 +46,9 @@ module Samagotchi
46
46
  # one). samagotchi/context stops before a word character or a dot, so
47
47
  # chi's own lib/samagotchi/context_*.rb isn't one.
48
48
  CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|samagotchi/context(?![\w.])|memories/\.bundles}
49
+ # The guardrails bundle's ask before a memory removal: its paths are in
50
+ # the config dir, yet a parent may answer it.
51
+ MEMORY_REMOVE_RULE = "memory-remove"
49
52
  # The guardrails bundle's shell-touches-chi, for a word it can't
50
53
  # resolve to a path (Guardrails::ShellPaths): CHI_TEXT and .git/hooks.
51
54
  CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/hooks})
@@ -94,6 +97,9 @@ module Samagotchi
94
97
  return false unless facts.is_a?(Hash)
95
98
  return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
96
99
  return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
100
+ # A memory's file sits in the config dir, but removing one is the
101
+ # memory-remove rule's ask, which a parent may answer.
102
+ return false if fetch(facts, :rule).to_s == MEMORY_REMOVE_RULE
97
103
 
98
104
  dirs = chi_dirs
99
105
  paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
@@ -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
@@ -28,6 +30,7 @@ module Samagotchi
28
30
  # modes: "strict", "auto" or a list of them: the rule only votes
29
31
  # in those guardrails.mode values (missing = every mode;
30
32
  # a rule from config.yml without it votes in every mode)
33
+ # memory: "remove": a memory_write call with remove: true
31
34
  # skip_read_only: true: a shell command that only reads
32
35
  # (ReadOnlyShell) doesn't match
33
36
  # verdict ask|deny, reason, scopes (for an ask; default all).
@@ -38,24 +41,25 @@ module Samagotchi
38
41
  class Rules
39
42
  class ParseError < StandardError; end
40
43
 
41
- KEYS = %w[id tool command path git touches rm models modes skip_read_only verdict reason scopes].freeze
42
- MATCH_KEYS = %w[tool command path git touches rm].freeze
44
+ KEYS = %w[id tool command path git touches rm memory models modes skip_read_only verdict reason scopes].freeze
45
+ MATCH_KEYS = %w[tool command path git touches rm memory].freeze
43
46
  VERDICTS = %w[ask deny].freeze
44
47
  # guardrails.mode's values.
45
48
  MODES = %w[auto strict].freeze
46
49
  GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
47
50
 
48
- Rule = Struct.new(:id, :tools, :command, :path, :git, :touches, :rm, :models, :modes, :skip_read_only, :verdict, :reason,
51
+ Rule = Struct.new(:id, :tools, :command, :path, :git, :touches, :rm, :memory, :models, :modes, :skip_read_only, :verdict, :reason,
49
52
  :scopes, :source, keyword_init: true) do
50
53
  def matches?(targets)
51
54
  return false unless targets
52
55
  return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
53
- 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))
54
57
  return false if command && !(targets.command && command.match?(targets.command))
55
58
  return false if path && !path_matches?(targets)
56
59
  return false if git && !targets.git_outside_repo?
57
60
  return false if touches && !targets.touches_chi?
58
61
  return false if rm && !targets.rm_outside_tmp?
62
+ return false if memory && !targets.memory_remove?
59
63
  return false if skip_read_only && targets.read_only?
60
64
 
61
65
  true
@@ -128,7 +132,8 @@ module Samagotchi
128
132
 
129
133
  Rule.new(id: id, tools: tools_of(raw["tool"], label), command: regex_of(raw["command"], label),
130
134
  path: path_of(raw["path"], label), git: git_of(raw["git"], label),
131
- touches: touches_of(raw["touches"], label), rm: rm_of(raw["rm"], label), models: models_of(raw["models"], label),
135
+ touches: touches_of(raw["touches"], label), rm: rm_of(raw["rm"], label), memory: memory_of(raw["memory"], label),
136
+ models: models_of(raw["models"], label),
132
137
  modes: modes_of(raw["modes"], label),
133
138
  skip_read_only: flag_of(raw, "skip_read_only", label), verdict: verdict.to_sym,
134
139
  reason: (raw["reason"] || "rule #{id}").to_s, scopes: scopes_of(raw["scopes"], label), source: source)
@@ -185,6 +190,14 @@ module Samagotchi
185
190
  value
186
191
  end
187
192
 
193
+ # Only "remove" for now.
194
+ def self.memory_of(value, label)
195
+ return nil if value.nil?
196
+ raise ParseError, "#{label}: memory must be remove" unless value == "remove"
197
+
198
+ value
199
+ end
200
+
188
201
  def self.modes_of(value, label)
189
202
  return nil if value.nil?
190
203