samagotchi 0.36.0 → 0.38.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 +63 -1
- data/README.md +3 -3
- data/bin/chi +1 -1
- data/docs/broadcast.md +95 -13
- data/docs/configuration.md +9 -2
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +17 -2
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/plugins.md +107 -34
- data/lib/samagotchi/broadcast/scope_card.rb +15 -2
- data/lib/samagotchi/broadcast/triage.rb +284 -0
- data/lib/samagotchi/broadcast/triage_log.rb +170 -0
- data/lib/samagotchi/broadcast/triage_model.rb +56 -0
- data/lib/samagotchi/broadcast_command.rb +275 -69
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/plugin.rb +439 -92
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/cli/command.rb +27 -3
- data/lib/samagotchi/config.rb +9 -0
- data/lib/samagotchi/context_command.rb +0 -17
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/Broadcast.swift +97 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +12 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +123 -26
- data/lib/samagotchi/desktop/macos.rb +27 -5
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +7 -8
- data/lib/samagotchi/guardrail_wiring.rb +7 -2
- data/lib/samagotchi/guardrails/approval.rb +5 -2
- data/lib/samagotchi/guardrails/approvals.rb +9 -5
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/targets.rb +76 -11
- data/lib/samagotchi/idle_client.rb +48 -28
- data/lib/samagotchi/idle_recap.rb +6 -5
- data/lib/samagotchi/idle_target.rb +95 -0
- data/lib/samagotchi/kernel_loop.rb +6 -4
- data/lib/samagotchi/llm/openai_chat.rb +29 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/note_command.rb +0 -21
- data/lib/samagotchi/plugin/api.rb +5 -3
- data/lib/samagotchi/recap_setup.rb +38 -52
- data/lib/samagotchi/send_command.rb +0 -22
- data/lib/samagotchi/tool_response.rb +5 -5
- data/lib/samagotchi/tool_runner.rb +18 -9
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/question_card.js +4 -3
- metadata +6 -1
|
@@ -41,6 +41,10 @@ module Samagotchi
|
|
|
41
41
|
ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME GEM_HOME GEM_PATH RUBYLIB].freeze
|
|
42
42
|
# Without it Ruby reads stdin as US-ASCII and non-ASCII notes break.
|
|
43
43
|
LANG = "en_US.UTF-8"
|
|
44
|
+
# chi broadcast's default triage deadline, and the helper's seconds on
|
|
45
|
+
# top of it (MacOS.broadcast_timeout).
|
|
46
|
+
BROADCAST_TRIAGE_DEADLINE = 20
|
|
47
|
+
BROADCAST_MARGIN = 10
|
|
44
48
|
|
|
45
49
|
# Runs a command without a shell; [output, success].
|
|
46
50
|
class Runner
|
|
@@ -62,11 +66,25 @@ module Samagotchi
|
|
|
62
66
|
{ "listen_on" => listen_on, "binary" => get.call("kitty.binary").to_s, "agents" => agents }
|
|
63
67
|
end
|
|
64
68
|
|
|
69
|
+
# The helper as chi desktop and chi update make it: the settings it
|
|
70
|
+
# bakes into the launch file read from the config.
|
|
71
|
+
def self.from_config(register: true)
|
|
72
|
+
new(register: register, kitty: kitty_settings, broadcast_timeout: broadcast_timeout)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Seconds the helper gives `chi broadcast` before it stops it: the
|
|
76
|
+
# triage deadline, then the deliveries and chi's own start. Baked into
|
|
77
|
+
# the launch file: a changed deadline reaches the helper with chi update.
|
|
78
|
+
def self.broadcast_timeout(get = Config.method(:get))
|
|
79
|
+
(get.call("broadcast.triage_deadline") || BROADCAST_TRIAGE_DEADLINE).to_f + BROADCAST_MARGIN
|
|
80
|
+
end
|
|
81
|
+
|
|
65
82
|
attr_reader :app_dir, :support_dir
|
|
66
83
|
|
|
67
84
|
def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
|
|
68
85
|
source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
|
|
69
|
-
arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, kitty: nil
|
|
86
|
+
arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, kitty: nil,
|
|
87
|
+
broadcast_timeout: nil)
|
|
70
88
|
home = env["HOME"] || Dir.home
|
|
71
89
|
@app_dir = app_dir || File.join(home, "Applications")
|
|
72
90
|
@support_dir = support_dir || File.join(home, "Library", "Application Support", APP_NAME)
|
|
@@ -80,6 +98,7 @@ module Samagotchi
|
|
|
80
98
|
@sources_dir = sources_dir
|
|
81
99
|
@register = register
|
|
82
100
|
@kitty = kitty
|
|
101
|
+
@broadcast_timeout = broadcast_timeout
|
|
83
102
|
end
|
|
84
103
|
|
|
85
104
|
def app_path = File.join(@app_dir, "#{APP_NAME}.app")
|
|
@@ -93,12 +112,13 @@ module Samagotchi
|
|
|
93
112
|
# shell's PATH), and the allowlisted env. chi is an installed gem's
|
|
94
113
|
# RubyGems wrapper (it activates the gem and outlives upgrades and
|
|
95
114
|
# `gem cleanup`), else this checkout's bin/chi. The kitty section only
|
|
96
|
-
# when kitty.listen_on is set.
|
|
115
|
+
# when kitty.listen_on is set; the broadcast timeout when given.
|
|
97
116
|
def launch_config
|
|
98
117
|
env = { "LANG" => LANG }
|
|
99
118
|
ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
|
|
100
119
|
config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
|
|
101
120
|
config["kitty"] = @kitty if @kitty
|
|
121
|
+
config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
|
|
102
122
|
config
|
|
103
123
|
end
|
|
104
124
|
|
|
@@ -122,13 +142,15 @@ module Samagotchi
|
|
|
122
142
|
end
|
|
123
143
|
|
|
124
144
|
# The launch file names another chi version or path than this one, or
|
|
125
|
-
# other kitty settings: refresh_launch_file fixes
|
|
145
|
+
# other kitty settings or broadcast timeout: refresh_launch_file fixes
|
|
146
|
+
# that without a rebuild.
|
|
126
147
|
def launch_outdated?
|
|
127
148
|
launch = read_launch
|
|
128
|
-
launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] || launch["kitty"] != @kitty
|
|
149
|
+
launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] || launch["kitty"] != @kitty ||
|
|
150
|
+
launch["broadcast_timeout"] != @broadcast_timeout
|
|
129
151
|
end
|
|
130
152
|
|
|
131
|
-
# Rewrite the launch file for this chi and
|
|
153
|
+
# Rewrite the launch file for this chi and its settings, keeping
|
|
132
154
|
# the env it was installed with. The app reads it at each send: no restart.
|
|
133
155
|
def refresh_launch_file
|
|
134
156
|
config = launch_config
|
|
@@ -41,7 +41,7 @@ module Samagotchi
|
|
|
41
41
|
@argv = argv.dup
|
|
42
42
|
@stdout = stdout
|
|
43
43
|
@stderr = stderr
|
|
44
|
-
@platform = platform ||
|
|
44
|
+
@platform = platform || Desktop::MacOS.method(:from_config)
|
|
45
45
|
@supported = supported
|
|
46
46
|
end
|
|
47
47
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1531,8 +1531,9 @@ module Samagotchi
|
|
|
1531
1531
|
# @param on_event [Proc, nil] receives event hashes
|
|
1532
1532
|
# @param max_iterations [Integer, nil] max kernel iterations (nil: IterationLimit.for)
|
|
1533
1533
|
# @param cancel_controller [CancellationController, nil]
|
|
1534
|
-
# @param max_tool_output_chars [Integer, nil] per-output char cap
|
|
1535
|
-
# :tool_call_completed event's `output:`
|
|
1534
|
+
# @param max_tool_output_chars [Integer, nil] per-output char cap on what
|
|
1535
|
+
# the model gets back and the :tool_call_completed event's `output:`
|
|
1536
|
+
# (nil → max_tool_output_chars)
|
|
1536
1537
|
# @return [LLM::ModelResult]
|
|
1537
1538
|
# @param pending_input [#call, nil] optional drain proc returning
|
|
1538
1539
|
# Array<String> of steering messages queued while the turn runs; drained
|
|
@@ -2352,11 +2353,10 @@ module Samagotchi
|
|
|
2352
2353
|
# @return [String] the answer
|
|
2353
2354
|
def ask_side_model(request, timeout:, max_tokens:, cancel_controller:)
|
|
2354
2355
|
target = session_model_recap_target
|
|
2355
|
-
client = IdleClient.
|
|
2356
|
-
timeout: timeout)
|
|
2356
|
+
client = IdleClient.for(target, timeout: timeout)
|
|
2357
2357
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
2358
2358
|
answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller)
|
|
2359
|
-
Log.info(:plugins, "ask_model", model: target
|
|
2359
|
+
Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
|
|
2360
2360
|
ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
|
|
2361
2361
|
answer.text
|
|
2362
2362
|
end
|
|
@@ -2372,10 +2372,9 @@ module Samagotchi
|
|
|
2372
2372
|
# The session's current model as a recap target: its host's OpenAI API
|
|
2373
2373
|
# (native llama.cpp hosts serve /v1/chat/completions too), key variable
|
|
2374
2374
|
# and bare model name, as a turn resolves them.
|
|
2375
|
+
# @return [IdleTarget]
|
|
2375
2376
|
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 }
|
|
2377
|
+
IdleTarget.of_model(@host_registry, @effective_model_name)
|
|
2379
2378
|
end
|
|
2380
2379
|
|
|
2381
2380
|
# Build the idle reminders job. Always created (reminders are opt-in
|
|
@@ -226,8 +226,13 @@ module Samagotchi
|
|
|
226
226
|
return verdict.settle!(:deny, decided_by: "no one", note: "No one to approve it (non-interactive run).")
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
-
# A plugin tool is asked about by its label
|
|
230
|
-
label
|
|
229
|
+
# A plugin tool is asked about by its targets:' label:, else its
|
|
230
|
+
# label, as its row shows it; a call that acts as another tool (an MCP
|
|
231
|
+
# tool behind mcp_call) by that tool's label, else (none registered)
|
|
232
|
+
# by its name (Approval.payload).
|
|
233
|
+
acts_as = verdict.targets&.acts_as
|
|
234
|
+
label = verdict.targets&.label ||
|
|
235
|
+
ToolActivity.plugin_label(acts_as || verdict.call[:name].to_s, registry: @tools_lookup.call)
|
|
231
236
|
# verdict.call is the call that will run (a hook may have replaced it).
|
|
232
237
|
payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call),
|
|
233
238
|
only: (SCRATCH_SCOPES if @scratch))
|
|
@@ -18,7 +18,8 @@ module Samagotchi
|
|
|
18
18
|
|
|
19
19
|
# @param verdict [Verdict] an ask, with its targets and context
|
|
20
20
|
# @param label [String, nil] a plugin tool's label ("chrome:
|
|
21
|
-
# screenshot"): the question names the tool by it, as its row does
|
|
21
|
+
# screenshot"): the question names the tool by it, as its row does
|
|
22
|
+
# (without one, by the tool the call acts as, else its name);
|
|
22
23
|
# approval[:tool] stays the raw name
|
|
23
24
|
# @param preview [Hash, nil] EditPreview.for(verdict.call) for edit and
|
|
24
25
|
# write: the card shows its diff, the question text one change line
|
|
@@ -37,6 +38,8 @@ module Samagotchi
|
|
|
37
38
|
kind: KIND,
|
|
38
39
|
approval: {
|
|
39
40
|
tool: targets&.tool || verdict.call[:name].to_s,
|
|
41
|
+
# The tool the call acts as (an MCP tool behind mcp_call).
|
|
42
|
+
acts_as: targets&.acts_as,
|
|
40
43
|
label: label,
|
|
41
44
|
command: targets&.command,
|
|
42
45
|
paths: targets && !targets.paths.empty? ? targets.paths : nil,
|
|
@@ -126,7 +129,7 @@ module Samagotchi
|
|
|
126
129
|
# change: +3 −1 (edit/write, from the preview)
|
|
127
130
|
def question_text(verdict, label: nil, preview: nil)
|
|
128
131
|
targets = verdict.targets
|
|
129
|
-
tool = label || targets&.tool || verdict.call[:name].to_s
|
|
132
|
+
tool = label || targets&.acts_as || targets&.tool || verdict.call[:name].to_s
|
|
130
133
|
what = targets&.command || (targets && targets.paths.join(", "))
|
|
131
134
|
what = Approval.args_text(targets&.args, limit: ARGS_CHARS) if what.to_s.empty?
|
|
132
135
|
lines = ["#{tool}: #{what}"]
|
|
@@ -90,7 +90,7 @@ module Samagotchi
|
|
|
90
90
|
return nil unless STORED_SCOPES.include?(scope)
|
|
91
91
|
|
|
92
92
|
entry = { "scope" => scope, "tool" => verdict.targets&.tool || verdict.call[:name].to_s,
|
|
93
|
-
"rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
|
|
93
|
+
"acts_as" => verdict.targets&.acts_as, "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
|
|
94
94
|
case scope
|
|
95
95
|
when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
|
|
96
96
|
when "repo"
|
|
@@ -110,14 +110,18 @@ module Samagotchi
|
|
|
110
110
|
|
|
111
111
|
# tool + the normalized command, or the sorted paths; for a plugin
|
|
112
112
|
# tool with neither (an MCP tool), its arguments, so "this call" is
|
|
113
|
-
# this call.
|
|
113
|
+
# this call. A call that acts as another tool keys as
|
|
114
|
+
# `mcp_call>mcp_github_x`: the real name stays in, so it never reuses
|
|
115
|
+
# that tool's own approvals (nor they its).
|
|
114
116
|
def self.key_for(verdict)
|
|
115
117
|
t = verdict.targets
|
|
116
118
|
return "#{verdict.call[:name]}:" unless t
|
|
117
|
-
return "#{t.tool}:#{t.command.to_s.strip.gsub(/\s+/, " ")}" if t.command
|
|
118
|
-
return "#{t.tool}:#{Approval.args_text(t.args)}" if t.paths.empty?
|
|
119
119
|
|
|
120
|
-
"#{t.tool}
|
|
120
|
+
tool = t.acts_as ? "#{t.tool}>#{t.acts_as}" : t.tool
|
|
121
|
+
return "#{tool}:#{t.command.to_s.strip.gsub(/\s+/, " ")}" if t.command
|
|
122
|
+
return "#{tool}:#{Approval.args_text(t.args)}" if t.paths.empty?
|
|
123
|
+
|
|
124
|
+
"#{tool}:#{t.paths.sort.join("\n")}"
|
|
121
125
|
end
|
|
122
126
|
|
|
123
127
|
def self.place_for(verdict)
|
|
@@ -9,7 +9,9 @@ module Samagotchi
|
|
|
9
9
|
# Declarative rules from config.yml's `guardrails:` section (and, later,
|
|
10
10
|
# installed bundles). All the fields a rule gives must match:
|
|
11
11
|
# tool: a tool name, a list, or "shell" (execute + task_create);
|
|
12
|
-
# a name may be a glob ("mcp_*", "mcp_{git,gh}_*")
|
|
12
|
+
# a name may be a glob ("mcp_*", "mcp_{git,gh}_*"); it
|
|
13
|
+
# matches the call's name or the tool a plugin tool's call
|
|
14
|
+
# acts as (Targets#acts_as: an MCP tool behind mcp_call)
|
|
13
15
|
# command: a Ruby regex on a shell tool's command
|
|
14
16
|
# path: "outside_repo", or a glob on the resolved paths (absolute
|
|
15
17
|
# or "**/…" globs match the absolute path; others the path
|
|
@@ -51,7 +53,7 @@ module Samagotchi
|
|
|
51
53
|
def matches?(targets)
|
|
52
54
|
return false unless targets
|
|
53
55
|
return false unless for_model?(targets.model_name, targets.model_key, -> { targets.small_model? })
|
|
54
|
-
return false if tools && !tool_matches?(targets.tool)
|
|
56
|
+
return false if tools && !tool_matches?(targets.tool) && !(targets.acts_as && tool_matches?(targets.acts_as))
|
|
55
57
|
return false if command && !(targets.command && command.match?(targets.command))
|
|
56
58
|
return false if path && !path_matches?(targets)
|
|
57
59
|
return false if git && !targets.git_outside_repo?
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../tools/tool_path"
|
|
4
4
|
require_relative "../tools/memory"
|
|
5
|
+
require_relative "../tool_declarations"
|
|
5
6
|
require_relative "../log"
|
|
6
7
|
require_relative "model_size"
|
|
7
8
|
require_relative "outside"
|
|
@@ -21,6 +22,9 @@ module Samagotchi
|
|
|
21
22
|
SHELL_TOOLS = %w[execute task_create].freeze
|
|
22
23
|
PATH_TOOLS = %w[write edit read memory_write].freeze
|
|
23
24
|
|
|
25
|
+
# chi's own tools, by name, whether this session has them or not.
|
|
26
|
+
CORE_TOOLS = ToolDeclarations::TOOL_SCHEMAS.map { |schema| schema[:name] }.freeze
|
|
27
|
+
|
|
24
28
|
attr_reader :tool, :command, :paths, :cwd, :repo_root
|
|
25
29
|
|
|
26
30
|
# @return [String, nil] the call's repository (Context#repo: its
|
|
@@ -37,11 +41,25 @@ module Samagotchi
|
|
|
37
41
|
# prefix) and its key (ModelOverlay.key_for), for a rule's models:
|
|
38
42
|
attr_reader :model_name, :model_key
|
|
39
43
|
|
|
40
|
-
# @return [Hash, nil] a plugin tool's arguments
|
|
41
|
-
#
|
|
42
|
-
#
|
|
44
|
+
# @return [Hash, nil] a plugin tool's arguments, for an approval of a
|
|
45
|
+
# call whose targets name no command or path: its targets:' args:
|
|
46
|
+
# when given (the arguments it acts with, a dispatcher's inner
|
|
47
|
+
# ones), else call[:args]; nil for chi's own tools
|
|
43
48
|
attr_reader :args
|
|
44
49
|
|
|
50
|
+
# @return [String, nil] the tool a plugin tool's call acts as (its
|
|
51
|
+
# targets:' acts_as:, an MCP tool behind mcp_call): a rule's tool:
|
|
52
|
+
# matches it as well as the call's name. Never a core tool's or
|
|
53
|
+
# another bundle's (.plugin_targets drops those). #tool stays the
|
|
54
|
+
# call's name.
|
|
55
|
+
attr_reader :acts_as
|
|
56
|
+
|
|
57
|
+
# @return [String, nil] what a plugin tool's targets: says to name the
|
|
58
|
+
# call by in the approval question (label:, "github: x"), in place of
|
|
59
|
+
# the tool's registered label; display only (no rule or approval key
|
|
60
|
+
# reads it)
|
|
61
|
+
attr_reader :label
|
|
62
|
+
|
|
45
63
|
# @return [Boolean] a memory_write call that removes a memory (remove:
|
|
46
64
|
# true), for a rule's memory: remove
|
|
47
65
|
attr_reader :memory_remove
|
|
@@ -61,6 +79,8 @@ module Samagotchi
|
|
|
61
79
|
paths = []
|
|
62
80
|
cwd = base
|
|
63
81
|
args = nil
|
|
82
|
+
acts_as = nil
|
|
83
|
+
label = nil
|
|
64
84
|
case tool
|
|
65
85
|
when *SHELL_TOOLS
|
|
66
86
|
command = call[:content].to_s
|
|
@@ -79,21 +99,27 @@ module Samagotchi
|
|
|
79
99
|
cwd = File.expand_path(given_cwd, base) unless given_cwd.empty?
|
|
80
100
|
paths.concat(given[:paths].map { |path| absolute(path, cwd) })
|
|
81
101
|
entry = registry && registry[tool]
|
|
82
|
-
args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
|
|
102
|
+
args = given[:args] || call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
|
|
103
|
+
acts_as = given[:acts_as]
|
|
104
|
+
label = given[:label]
|
|
83
105
|
end
|
|
84
106
|
memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
|
|
85
107
|
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
|
|
86
|
-
repo: context.repo(cwd), args: args, memory_remove: memory_remove,
|
|
108
|
+
repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, memory_remove: memory_remove,
|
|
87
109
|
model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
|
|
88
110
|
chi_dirs: -> { chi_dirs(context, base) })
|
|
89
111
|
end
|
|
90
112
|
|
|
91
113
|
# What a plugin tool's targets: callable says the call acts on:
|
|
92
|
-
# {command:, paths:, cwd:}, each optional; a callable
|
|
93
|
-
# gives something else counts as nothing (and is
|
|
114
|
+
# {command:, paths:, cwd:, acts_as:, args:, label:}, each optional; a callable
|
|
115
|
+
# that raises or gives something else counts as nothing (and is
|
|
116
|
+
# logged). args: counts when it is a Hash; acts_as: is dropped (and
|
|
117
|
+
# logged) when it names a core tool or another bundle's tool, so a
|
|
118
|
+
# call can't pass for one and match (or reuse the approvals of) its
|
|
119
|
+
# rules. label: counts when it is one short line (.label_of).
|
|
94
120
|
def self.plugin_targets(call, registry)
|
|
95
121
|
entry = registry && registry[call[:name].to_s]
|
|
96
|
-
none = { command: nil, paths: [], cwd: nil }
|
|
122
|
+
none = { command: nil, paths: [], cwd: nil, acts_as: nil, args: nil, label: nil }
|
|
97
123
|
return none unless entry && !entry.core? && entry.targets
|
|
98
124
|
|
|
99
125
|
given = entry.targets.call(call)
|
|
@@ -101,13 +127,50 @@ module Samagotchi
|
|
|
101
127
|
|
|
102
128
|
given = given.transform_keys(&:to_sym)
|
|
103
129
|
command = given[:command].to_s
|
|
104
|
-
{ command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), cwd: given[:cwd]
|
|
130
|
+
{ command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), cwd: given[:cwd],
|
|
131
|
+
acts_as: acts_as_of(given[:acts_as], entry, registry), args: given[:args].is_a?(Hash) ? given[:args] : nil,
|
|
132
|
+
label: label_of(given[:label], entry) }
|
|
105
133
|
rescue StandardError => e
|
|
106
134
|
Log.warn(:plugins, "plugin_targets_failed", tool: call[:name].to_s, error: e.class.name,
|
|
107
135
|
msg: "#{call[:name]} targets failed: #{e.message}")
|
|
108
136
|
none
|
|
109
137
|
end
|
|
110
138
|
|
|
139
|
+
# +name+ as the acts_as of +entry+'s calls: nil for none, and for a
|
|
140
|
+
# core tool or a tool another bundle registered (logged); the tool's
|
|
141
|
+
# own name is no alias either.
|
|
142
|
+
def self.acts_as_of(name, entry, registry)
|
|
143
|
+
name = name.to_s.strip
|
|
144
|
+
return nil if name.empty? || name == entry.name
|
|
145
|
+
|
|
146
|
+
other = registry[name]
|
|
147
|
+
whose = if CORE_TOOLS.include?(name) || other&.core? then "a core tool"
|
|
148
|
+
elsif other && other.source != entry.source then "bundle #{other.source}'s tool"
|
|
149
|
+
end
|
|
150
|
+
return name unless whose
|
|
151
|
+
|
|
152
|
+
Log.warn(:plugins, "plugin_acts_as_dropped", tool: entry.name, acts_as: name,
|
|
153
|
+
msg: "#{entry.name} targets: acts_as #{name} dropped: it is #{whose}")
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The longest label: the question takes from targets:.
|
|
158
|
+
LABEL_CHARS = 80
|
|
159
|
+
|
|
160
|
+
# +label+ as a call's question label: stripped; nil for none, and for
|
|
161
|
+
# anything but a String of one line up to LABEL_CHARS (logged).
|
|
162
|
+
def self.label_of(label, entry)
|
|
163
|
+
return nil if label.nil?
|
|
164
|
+
|
|
165
|
+
text = label.is_a?(String) ? label.strip : nil
|
|
166
|
+
return nil if text && text.empty?
|
|
167
|
+
return text if text && !text.include?("\n") && text.length <= LABEL_CHARS
|
|
168
|
+
|
|
169
|
+
Log.warn(:plugins, "plugin_label_ignored", tool: entry.name,
|
|
170
|
+
msg: "#{entry.name} targets: label ignored: not one line of at most #{LABEL_CHARS} characters")
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
111
174
|
# chi's own dirs (ParentApprovals.chi_dirs) and the session repo's
|
|
112
175
|
# git hooks dir.
|
|
113
176
|
def self.chi_dirs(context, base)
|
|
@@ -133,8 +196,10 @@ module Samagotchi
|
|
|
133
196
|
end
|
|
134
197
|
|
|
135
198
|
# @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
|
|
136
|
-
def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil,
|
|
137
|
-
session_root: nil, chi_dirs: nil, memory_remove: false)
|
|
199
|
+
def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, acts_as: nil, label: nil,
|
|
200
|
+
model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil, memory_remove: false)
|
|
201
|
+
@acts_as = acts_as
|
|
202
|
+
@label = label
|
|
138
203
|
@memory_remove = memory_remove
|
|
139
204
|
@repo = repo
|
|
140
205
|
@chi_dirs = chi_dirs
|
|
@@ -24,10 +24,14 @@ module Samagotchi
|
|
|
24
24
|
# Raised when summarization fails (server down, timeout, malformed body…).
|
|
25
25
|
class SummarizeError < StandardError; end
|
|
26
26
|
|
|
27
|
-
# What #summarize
|
|
28
|
-
# (the server's name for it; nil when the reply names none).
|
|
29
|
-
# the
|
|
30
|
-
|
|
27
|
+
# What #summarize and #ask return: the text and the model that
|
|
28
|
+
# answered (the server's name for it; nil when the reply names none).
|
|
29
|
+
# +top_logprobs+: the first token's alternatives (LLM::TokenLogprob)
|
|
30
|
+
# when #ask asked for logprobs and the server sent them, else [].
|
|
31
|
+
# #to_s is the text.
|
|
32
|
+
Summary = Data.define(:text, :model, :top_logprobs) do
|
|
33
|
+
def initialize(top_logprobs: [].freeze, **) = super
|
|
34
|
+
|
|
31
35
|
def to_s = text
|
|
32
36
|
end
|
|
33
37
|
|
|
@@ -40,12 +44,21 @@ module Samagotchi
|
|
|
40
44
|
# @param timeout [Numeric] HTTP request timeout. Kept to the recap's own
|
|
41
45
|
# wait budget (not the chat's global request_timeout) so an abandoned
|
|
42
46
|
# summarize thread can't outlive the recap attempt by minutes.
|
|
43
|
-
|
|
47
|
+
# @param purpose [String] what the requests are for, in the HTTP log
|
|
48
|
+
# lines and error messages ("recap", "broadcast")
|
|
49
|
+
def initialize(model:, base_url: nil, api_key_env: nil, timeout: DEFAULT_TIMEOUT_SECONDS, env: ENV, purpose: "recap")
|
|
44
50
|
@model = model
|
|
45
51
|
# A recap is best-effort: one short attempt, no retries. The idle job
|
|
46
52
|
# tries again after the next activity, never on its own.
|
|
47
|
-
@chat = LLM::OpenAIChat.new(base_url: base_url.to_s, host_name:
|
|
48
|
-
stream: false, retries: false, timeout: timeout, env: env, purpose:
|
|
53
|
+
@chat = LLM::OpenAIChat.new(base_url: base_url.to_s, host_name: purpose, api_key_env: api_key_env,
|
|
54
|
+
stream: false, retries: false, timeout: timeout, env: env, purpose: purpose)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A client for +target+ (an IdleTarget: a recap's, ctx.ask_model's, a
|
|
58
|
+
# broadcast's triage model).
|
|
59
|
+
# @return [IdleClient]
|
|
60
|
+
def self.for(target, **)
|
|
61
|
+
new(model: target.model, base_url: target.base_url, api_key_env: target.api_key_env, **)
|
|
49
62
|
end
|
|
50
63
|
|
|
51
64
|
THINK_RE = /<\|think\|.*?\|think\|>/m
|
|
@@ -77,25 +90,31 @@ module Samagotchi
|
|
|
77
90
|
messages = prompt.is_a?(Array) ? prompt : [{ role: "user", content: prompt.to_s.strip }]
|
|
78
91
|
return nil if messages.all? { |m| m[:content].to_s.strip.empty? }
|
|
79
92
|
|
|
80
|
-
|
|
81
|
-
cleaned =
|
|
82
|
-
cleaned.empty? ? nil : Summary.new(text: cleaned, model:
|
|
93
|
+
response = generate(messages)
|
|
94
|
+
cleaned = response.text.to_s.strip
|
|
95
|
+
cleaned.empty? ? nil : Summary.new(text: cleaned, model: response.model)
|
|
83
96
|
rescue SummarizeError
|
|
84
97
|
raise
|
|
85
98
|
rescue StandardError => e
|
|
86
99
|
raise SummarizeError, "recap summarization failed: #{e.class}: #{e.message}"
|
|
87
100
|
end
|
|
88
101
|
|
|
89
|
-
# One side answer (a plugin's ctx.ask_model
|
|
90
|
-
# tools, thinking off. An answer cut off by
|
|
91
|
-
# is, marked with "…". Cancelling
|
|
102
|
+
# One side answer (a plugin's ctx.ask_model, a broadcast's triage):
|
|
103
|
+
# +messages+ as they are, no tools, thinking off. An answer cut off by
|
|
104
|
+
# +max_tokens+ is kept as it is, marked with "…". Cancelling
|
|
105
|
+
# +cancel_controller+ aborts the request.
|
|
106
|
+
# @param kind [String] what it is for, in the usage log line
|
|
107
|
+
# @param options [Hash] more request body fields (logprobs: true,
|
|
108
|
+
# top_logprobs: 5)
|
|
92
109
|
# @return [Summary] the answer ("" when the model said nothing)
|
|
93
|
-
# @raise [SummarizeError] any failure but a cancel
|
|
110
|
+
# @raise [SummarizeError] any failure but a cancel; its #cause is an
|
|
111
|
+
# LLM::BadRequest when the host refused the request (+options+ it
|
|
112
|
+
# doesn't take, say)
|
|
94
113
|
# @raise [LLM::RequestCancelled] +cancel_controller+ was cancelled
|
|
95
|
-
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
Summary.new(text:
|
|
114
|
+
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {})
|
|
115
|
+
response = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
|
|
116
|
+
kind: kind, options: options)
|
|
117
|
+
Summary.new(text: response.text.to_s.strip, model: response.model, top_logprobs: response.top_logprobs)
|
|
99
118
|
rescue SummarizeError, LLM::RequestCancelled
|
|
100
119
|
raise
|
|
101
120
|
rescue StandardError => e
|
|
@@ -104,15 +123,16 @@ module Samagotchi
|
|
|
104
123
|
|
|
105
124
|
private
|
|
106
125
|
|
|
107
|
-
# One plain /chat/completions request. Returns the cleaned
|
|
108
|
-
# ("" when there is nothing after stripping)
|
|
109
|
-
# (nil when the reply names none)
|
|
126
|
+
# One plain /chat/completions request. Returns a Summary of the cleaned
|
|
127
|
+
# assistant text ("" when there is nothing after stripping), the served
|
|
128
|
+
# model's name (nil when the reply names none) and the first token's
|
|
129
|
+
# logprobs. Raises SummarizeError when
|
|
110
130
|
# the reply has neither content nor reasoning_content. Cut off by
|
|
111
131
|
# +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
|
|
112
132
|
# or all of it, with "…".
|
|
113
|
-
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap")
|
|
133
|
+
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap", options: {})
|
|
114
134
|
response = begin
|
|
115
|
-
request(messages, max_tokens, cancel_controller)
|
|
135
|
+
request(messages, max_tokens, cancel_controller, options)
|
|
116
136
|
rescue LLM::BadRequest => e
|
|
117
137
|
raise unless e.reasoning_refused? && !@thinking_refused
|
|
118
138
|
|
|
@@ -120,7 +140,7 @@ module Samagotchi
|
|
|
120
140
|
# fields, and leave them out from now on.
|
|
121
141
|
@thinking_refused = true
|
|
122
142
|
Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
|
|
123
|
-
request(messages, max_tokens, cancel_controller)
|
|
143
|
+
request(messages, max_tokens, cancel_controller, options)
|
|
124
144
|
end
|
|
125
145
|
log_usage(response, kind)
|
|
126
146
|
content = response.text
|
|
@@ -130,7 +150,7 @@ module Samagotchi
|
|
|
130
150
|
cut_off = response.finish_reason == "length"
|
|
131
151
|
# Reasoning cut off by max_tokens is the model's thinking, not a recap
|
|
132
152
|
# (a server that ignores the thinking switch thinks until the limit).
|
|
133
|
-
return
|
|
153
|
+
return Summary.new(text: "", model: response.model, top_logprobs: response.top_logprobs) if content.empty? && cut_off
|
|
134
154
|
|
|
135
155
|
# Prefer content, fall back to a finished reasoning_content (e.g.
|
|
136
156
|
# Qwen3.6), and strip thinking tokens some models (Qwen, Gemma) leave
|
|
@@ -138,7 +158,7 @@ module Samagotchi
|
|
|
138
158
|
text = self.class.strip_thinking(content.empty? ? reasoning : content)
|
|
139
159
|
# Cut off by max_tokens: keep the sentences that finished ("" if none).
|
|
140
160
|
text = whole_sentences ? self.class.full_sentences(text) : "#{text}…" if cut_off && !text.empty?
|
|
141
|
-
|
|
161
|
+
Summary.new(text: text, model: response.model, top_logprobs: response.top_logprobs)
|
|
142
162
|
rescue LLM::ProtocolError => e
|
|
143
163
|
raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
|
|
144
164
|
end
|
|
@@ -153,9 +173,9 @@ module Samagotchi
|
|
|
153
173
|
cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens])
|
|
154
174
|
end
|
|
155
175
|
|
|
156
|
-
def request(messages, max_tokens, cancel_controller)
|
|
176
|
+
def request(messages, max_tokens, cancel_controller, options = {})
|
|
157
177
|
@chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
|
|
158
|
-
options: { max_tokens: max_tokens, **thinking_fields })
|
|
178
|
+
options: { max_tokens: max_tokens, **thinking_fields, **options })
|
|
159
179
|
end
|
|
160
180
|
|
|
161
181
|
# A recap or side answer is short and tool-less: thinking off, whatever
|
|
@@ -6,6 +6,7 @@ require "monitor"
|
|
|
6
6
|
require "time"
|
|
7
7
|
|
|
8
8
|
require_relative "idle_client"
|
|
9
|
+
require_relative "idle_target"
|
|
9
10
|
require_relative "output_formatter"
|
|
10
11
|
require_relative "steer"
|
|
11
12
|
require_relative "recap_store"
|
|
@@ -279,7 +280,7 @@ module Samagotchi
|
|
|
279
280
|
# attempt (the session's current model, so a /model switch counts).
|
|
280
281
|
# @param base_url [String] the OpenAI API base the recap asks
|
|
281
282
|
# @param api_key_env [String, nil] the variable holding its key
|
|
282
|
-
# @param target [#call, nil] ->
|
|
283
|
+
# @param target [#call, nil] -> IdleTarget
|
|
283
284
|
def initialize(engine:, model: nil, base_url: nil, api_key_env: nil, target: nil,
|
|
284
285
|
inactivity: DEFAULT_INACTIVITY_SECONDS,
|
|
285
286
|
min_user_turns: DEFAULT_MIN_USER_TURNS,
|
|
@@ -292,7 +293,7 @@ module Samagotchi
|
|
|
292
293
|
|
|
293
294
|
@engine = engine
|
|
294
295
|
@target = target || lambda {
|
|
295
|
-
|
|
296
|
+
IdleTarget.new(base_url: base_url, api_key_env: api_key_env, model: model, label: model)
|
|
296
297
|
}
|
|
297
298
|
@inactivity = inactivity
|
|
298
299
|
@min_user_turns = min_user_turns
|
|
@@ -455,7 +456,7 @@ module Samagotchi
|
|
|
455
456
|
|
|
456
457
|
asked = target
|
|
457
458
|
@in_flight = { thread: spawn_summarize(client_for(asked), prompt), generation: gen, deadline: @clock.call + @timeout,
|
|
458
|
-
covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked
|
|
459
|
+
covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked.label }
|
|
459
460
|
:started
|
|
460
461
|
rescue StandardError
|
|
461
462
|
:failed
|
|
@@ -534,9 +535,9 @@ module Samagotchi
|
|
|
534
535
|
def client_for(asked)
|
|
535
536
|
return @client_override if @client_override
|
|
536
537
|
|
|
537
|
-
key = asked.
|
|
538
|
+
key = [asked.base_url, asked.api_key_env, asked.model]
|
|
538
539
|
unless @client && @client_key == key
|
|
539
|
-
@client = IdleClient.
|
|
540
|
+
@client = IdleClient.for(asked, timeout: @timeout)
|
|
540
541
|
@client_key = key
|
|
541
542
|
end
|
|
542
543
|
@client
|