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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -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/context.md +37 -0
- data/docs/guardrails.md +26 -3
- data/docs/hooks.md +1 -1
- data/docs/memory.md +3 -0
- data/docs/plugins.md +51 -4
- 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/coordinator/manifest.yml +7 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +72 -27
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
- 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/parent_approvals.rb +6 -0
- data/lib/samagotchi/guardrails/rules.rb +19 -6
- data/lib/samagotchi/guardrails/targets.rb +83 -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/memory_bundle/index_updater.rb +6 -3
- 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/session_commands.rb +1 -0
- data/lib/samagotchi/tool_activity.rb +14 -3
- data/lib/samagotchi/tool_declarations.rb +8 -4
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +103 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/question_card.js +4 -3
- 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 (
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
tools.
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
793
|
+
tools = offered(server).map(&:chi_name).sort
|
|
757
794
|
tools.empty? ? head : "#{head}\n#{tools.map { |t| "- `#{t}`" }.join("\n")}"
|
|
758
|
-
end
|
|
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.
|
|
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.
|
|
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:
|
|
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;
|
|
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
|
|
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)
|
|
@@ -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),
|
|
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
|
|