samagotchi 0.66.0 → 0.68.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 +80 -1
- data/docs/architecture.md +55 -30
- data/docs/cli.md +3 -1
- data/docs/configuration.md +50 -9
- data/docs/context.md +35 -1
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +129 -38
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +26 -11
- data/docs/plugins.md +126 -23
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +4 -1
- data/docs/testing.md +18 -4
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +15 -7
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +307 -549
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +64 -155
- data/lib/samagotchi/plugin/loader.rb +4 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +21 -11
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +1 -1
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +71 -93
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +1 -1
- data/lib/samagotchi/web/public/app.js +103 -539
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/commands.js +2 -2
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/index.html +15 -7
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
- data/lib/samagotchi/web/public/model_picker.js +26 -22
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +2 -8
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +14 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +35 -2
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +22 -24
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +27 -1
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "args"
|
|
5
|
+
require_relative "../token_usage"
|
|
6
|
+
require_relative "../tool_declarations"
|
|
7
|
+
require_relative "../plugin/tool_spec"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Tools
|
|
11
|
+
# The tools a session knows but doesn't declare one by one, and the two
|
|
12
|
+
# declared tools that reach them (docs/plugins.md, "Publishing tools to
|
|
13
|
+
# the index"): find_tools, a keyword search that answers each
|
|
14
|
+
# match's inputSchema, and call_tool, which calls one as <group>/<tool>.
|
|
15
|
+
# A source (the mcp bundle, chi.tool_source) publishes its groups (an
|
|
16
|
+
# MCP server each) and runs the calls; the index searches them, looks a
|
|
17
|
+
# call's tool up and checks it. The two tools are declared once, after
|
|
18
|
+
# the plugins load, and only when a source registered: nothing a source
|
|
19
|
+
# publishes later changes the request's tools, so the prompt cache keeps.
|
|
20
|
+
#
|
|
21
|
+
# They are registry entries of source SOURCE, not "core": a plugin
|
|
22
|
+
# tool's paths (guardrail targets, the row's label and preview, native
|
|
23
|
+
# flat schemas, every toolkit declaring them) are the ones call_tool has
|
|
24
|
+
# always had. MCP is the only source so far, so the texts speak of MCP
|
|
25
|
+
# servers.
|
|
26
|
+
class ToolIndex
|
|
27
|
+
SOURCE = "index"
|
|
28
|
+
FIND_NAME = "find_tools"
|
|
29
|
+
CALL_NAME = "call_tool"
|
|
30
|
+
# Matches a search answers, and names a miss suggests.
|
|
31
|
+
FIND_RESULTS = 5
|
|
32
|
+
CLOSEST = 3
|
|
33
|
+
# A query word in a tool's name (or its group's) counts this many
|
|
34
|
+
# times one in its description.
|
|
35
|
+
NAME_WEIGHT = 3
|
|
36
|
+
PREVIEW_CHARS = 60
|
|
37
|
+
|
|
38
|
+
FIND_PARAMS = {
|
|
39
|
+
query: { type: "string", required: true,
|
|
40
|
+
description: "keywords for what the tool does (\"screenshot page\"); empty lists every tool's name" },
|
|
41
|
+
server: { type: "string", description: "search only this server" }
|
|
42
|
+
}.freeze
|
|
43
|
+
CALL_DESCRIPTION = "Call an MCP server's tool that #{FIND_NAME} found.".freeze
|
|
44
|
+
CALL_PARAMS = {
|
|
45
|
+
tool: { type: "string", required: true, description: "the tool as <server>/<tool>" },
|
|
46
|
+
args: { type: "object", description: "the arguments, per the inputSchema #{FIND_NAME} gave" }
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# One tool in the index: +group+/+name+ is what the call tool takes,
|
|
50
|
+
# +schema+ its inputSchema, +acts_as+ the name guardrail rules match
|
|
51
|
+
# (mcp_<server>_<tool>; a bare name the call tool looks up too).
|
|
52
|
+
# +refusal+ is why it can't be called (nil when it can), +mark+ what a
|
|
53
|
+
# search shows beside its name then ("(bad schema)"). A blank
|
|
54
|
+
# +acts_as+ raises ArgumentError: no rule could match the tool's calls.
|
|
55
|
+
Tool = Data.define(:group, :name, :description, :schema, :acts_as, :refusal, :mark) do
|
|
56
|
+
def initialize(group:, name:, acts_as:, description: "", schema: nil, refusal: nil, mark: nil)
|
|
57
|
+
raise ArgumentError, "tool #{group}/#{name}: acts_as is blank" if acts_as.to_s.strip.empty?
|
|
58
|
+
|
|
59
|
+
super(group: group.to_s, name: name.to_s, description: description.to_s,
|
|
60
|
+
schema: schema || { "type" => "object", "properties" => {} }, acts_as: acts_as.to_s,
|
|
61
|
+
refusal: refusal, mark: mark)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def full_name = "#{group}/#{name}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A Group's states.
|
|
68
|
+
STATES = %i[ready starting failed].freeze
|
|
69
|
+
|
|
70
|
+
# One group of a source's tools (an MCP server): +summary+ its line in
|
|
71
|
+
# the search tool's description (nil: its name and tool count only),
|
|
72
|
+
# +state+ :ready, :starting (its tools may come: a lookup waits) or
|
|
73
|
+
# :failed (+error+ says why). A group exists before its tools do.
|
|
74
|
+
# Another state, or a tool of another group, raises ArgumentError (a
|
|
75
|
+
# lookup would neither wait on it nor find the tool's source).
|
|
76
|
+
Group = Data.define(:name, :summary, :state, :error, :tools) do
|
|
77
|
+
def initialize(name:, summary: nil, state: :ready, error: nil, tools: [])
|
|
78
|
+
raise ArgumentError, "group #{name}: state must be one of #{STATES.join(", ")}" unless STATES.include?(state)
|
|
79
|
+
|
|
80
|
+
tools = Array(tools)
|
|
81
|
+
raise ArgumentError, "group #{name}: tools must be ToolIndex::Tool values" unless tools.all?(Tool)
|
|
82
|
+
if (stray = tools.find { |tool| tool.group != name.to_s })
|
|
83
|
+
raise ArgumentError, "group #{name}: tool #{stray.full_name} belongs to group #{stray.group}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
summary = summary.to_s.strip
|
|
87
|
+
super(name: name.to_s, summary: summary.empty? ? nil : summary, state: state, error: error,
|
|
88
|
+
tools: tools.dup.freeze)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The tools the call tool can call.
|
|
92
|
+
def callable = tools.reject(&:refusal)
|
|
93
|
+
|
|
94
|
+
# What a search says about a group whose tools can't be called now.
|
|
95
|
+
def note
|
|
96
|
+
case state
|
|
97
|
+
when :failed then "(failed: #{error})"
|
|
98
|
+
when :starting then "(starting, try again)"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# What a source (a plugin, chi.tool_source) holds: its groups, which
|
|
104
|
+
# #publish swaps in whole, and how its calls run. Its hooks: +call+
|
|
105
|
+
# (tool, args, ctx) runs a checked call (args typed by the tool's
|
|
106
|
+
# schema; ctx the plugin's Context) and returns its result; +wait+
|
|
107
|
+
# (group) waits for a :starting group's start; +acts_as+ (group, tool)
|
|
108
|
+
# names a tool guardrails see from the names alone (one not listed
|
|
109
|
+
# yet). +prefix+: a bare name with it is taken as an acts_as as given.
|
|
110
|
+
# +label+ is the call tool's row label.
|
|
111
|
+
class Source
|
|
112
|
+
attr_reader :label, :prefix, :context, :groups, :declared_names, :declared_tokens
|
|
113
|
+
|
|
114
|
+
def initialize(label:, call:, acts_as:, wait: nil, prefix: nil, context: nil)
|
|
115
|
+
raise ArgumentError, "tool_source: call must respond to #call" unless call.respond_to?(:call)
|
|
116
|
+
raise ArgumentError, "tool_source: acts_as must respond to #call" unless acts_as.respond_to?(:call)
|
|
117
|
+
raise ArgumentError, "tool_source: wait must respond to #call" if wait && !wait.respond_to?(:call)
|
|
118
|
+
|
|
119
|
+
@label = label.to_s
|
|
120
|
+
@call = call
|
|
121
|
+
@acts_as = acts_as
|
|
122
|
+
@wait = wait
|
|
123
|
+
@prefix = prefix&.to_s
|
|
124
|
+
@context = context
|
|
125
|
+
@groups = [].freeze
|
|
126
|
+
@declared_names = []
|
|
127
|
+
@declared_tokens = 0
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def inspect = "#<ToolIndex::Source #{@label} groups=#{@groups.map(&:name).join(",")}>"
|
|
131
|
+
|
|
132
|
+
# Make +groups+ the source's: one swap, so a search on another
|
|
133
|
+
# thread sees the old list or the new one, never half.
|
|
134
|
+
# @param groups [Array<Group>]
|
|
135
|
+
def publish(groups)
|
|
136
|
+
groups = Array(groups)
|
|
137
|
+
raise ArgumentError, "publish takes ToolIndex::Group values" unless groups.all?(Group)
|
|
138
|
+
|
|
139
|
+
@groups = groups.dup.freeze
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def run(tool, args) = @call.call(tool, args, @context)
|
|
144
|
+
|
|
145
|
+
def wait(group)
|
|
146
|
+
@wait&.call(group)
|
|
147
|
+
rescue StandardError
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def acts_as(group, tool) = @acts_as.call(group, tool).to_s
|
|
152
|
+
|
|
153
|
+
# What ToolIndex#declare registered: the tools' names and their
|
|
154
|
+
# definitions' estimated tokens (the mcp bundle's /mcp says them).
|
|
155
|
+
def declared!(names, tokens)
|
|
156
|
+
@declared_names = names.dup.freeze
|
|
157
|
+
@declared_tokens = tokens
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def initialize
|
|
162
|
+
@sources = []
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A new source, not in the index yet (Plugin::Api#commit! #add's it).
|
|
166
|
+
def source(**) = Source.new(**)
|
|
167
|
+
|
|
168
|
+
def add(source)
|
|
169
|
+
@sources << source
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def sources = @sources.dup
|
|
174
|
+
|
|
175
|
+
# Every source's groups, now.
|
|
176
|
+
def groups = @sources.flat_map(&:groups)
|
|
177
|
+
|
|
178
|
+
# Register the search and call tools in +registry+, once, after the
|
|
179
|
+
# plugins load: nothing without a source. A name another tool has is
|
|
180
|
+
# left out (a line in +failures+).
|
|
181
|
+
# @return [Array<String>] the names registered
|
|
182
|
+
def declare(registry, failures: nil)
|
|
183
|
+
return [] if @sources.empty?
|
|
184
|
+
|
|
185
|
+
specs = [find_spec, call_spec]
|
|
186
|
+
declared = specs.filter_map do |spec|
|
|
187
|
+
if (taken = registry[spec.name])
|
|
188
|
+
failures&.add("tool #{spec.name}", "already registered (#{taken.source}); the tool index's is left out",
|
|
189
|
+
required: false)
|
|
190
|
+
next
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
registry.register(spec.name, source: SOURCE, **spec.entry_fields(nil))
|
|
194
|
+
spec
|
|
195
|
+
end
|
|
196
|
+
names = declared.map(&:name)
|
|
197
|
+
tokens = declared.sum { |spec| ToolIndex.spec_tokens(spec) }
|
|
198
|
+
@sources.each { |source| source.declared!(names, tokens) }
|
|
199
|
+
names
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A checked tool declaration's (Plugin::ToolSpec) definition as
|
|
203
|
+
# the chat path sends it (LLM::ChatLoop#tool_definitions: the chat
|
|
204
|
+
# schema, wrapped as a function), in estimated tokens
|
|
205
|
+
# (TokenUsage::CHARS_PER_TOKEN). The native prompts (Gemma, Qwen)
|
|
206
|
+
# render a flatter schema, so it is an upper bound there.
|
|
207
|
+
def self.spec_tokens(spec)
|
|
208
|
+
function = ToolDeclarations.chat_schemas([spec.schema]).first.slice(:name, :description, :parameters)
|
|
209
|
+
TokenUsage.estimate(JSON.generate({ type: "function", function: function }))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Appended to a failure the arguments may cause: the tool's
|
|
213
|
+
# inputSchema, so the next call can fix them (a call guessed without
|
|
214
|
+
# a search).
|
|
215
|
+
def self.schema_note(tool)
|
|
216
|
+
"\n\n#{tool.full_name}'s inputSchema: #{JSON.generate(tool.schema)}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The search tool's description: how to use it, and one line per
|
|
220
|
+
# group from what is known when it is declared (a first-run server's:
|
|
221
|
+
# its name, and its summary if it has one). Never every tool: knowing
|
|
222
|
+
# the names, a model searches less and guesses arguments.
|
|
223
|
+
def find_description
|
|
224
|
+
lines = groups.map do |group|
|
|
225
|
+
count = group.callable.size
|
|
226
|
+
line = "- #{group.name}"
|
|
227
|
+
line += ": #{group.summary}" if group.summary
|
|
228
|
+
line += " (#{count} tool#{"s" unless count == 1})" unless group.tools.empty?
|
|
229
|
+
line
|
|
230
|
+
end
|
|
231
|
+
"Search the tools of the MCP servers below by keywords. It answers up to #{FIND_RESULTS} tools, each with " \
|
|
232
|
+
"its name (<server>/<tool>) and inputSchema; call one with #{CALL_NAME}. An empty query lists every tool's " \
|
|
233
|
+
"name.\nServers:\n#{lines.join("\n")}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# ── Search ──────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
# The search tool: the best matches with their schemas, or with an
|
|
239
|
+
# empty query every group's tool names.
|
|
240
|
+
def find(query, only = nil)
|
|
241
|
+
all = groups
|
|
242
|
+
searched = all
|
|
243
|
+
if only && !only.strip.empty?
|
|
244
|
+
searched = all.select { |group| group.name == only.strip }
|
|
245
|
+
return "Error: no MCP server #{only.strip}. Servers: #{all.map(&:name).join(", ")}" if searched.empty?
|
|
246
|
+
end
|
|
247
|
+
return names_text(searched) if words(query).empty?
|
|
248
|
+
|
|
249
|
+
matches = ranked(query, searched.flat_map(&:tools)).take(FIND_RESULTS)
|
|
250
|
+
return "No MCP tool matches \"#{query.strip}\".\n\n#{names_text(searched)}" if matches.empty?
|
|
251
|
+
|
|
252
|
+
blocks = matches.map { |tool| match_text(tool, all) }
|
|
253
|
+
unsearched = searched.filter_map { |group| "#{group.name} #{group.note}" if group.tools.empty? && group.note }
|
|
254
|
+
blocks << "Not searched: #{unsearched.join("; ")}." unless unsearched.empty?
|
|
255
|
+
"Call one with #{CALL_NAME}(tool: \"<server>/<tool>\", args: {…}).\n\n#{blocks.join("\n\n")}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# ── The call ────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
# A call of the tool +name+ names, with +args+ typed by its schema,
|
|
261
|
+
# run by its source. It fails closed: a tool that isn't the one
|
|
262
|
+
# guardrails were given (#claimed) is refused.
|
|
263
|
+
def call(name, args)
|
|
264
|
+
tool = lookup(name)
|
|
265
|
+
return tool if tool.is_a?(String)
|
|
266
|
+
unless claimed(name.strip)&.first == tool.acts_as
|
|
267
|
+
return "Error: the MCP tools changed while the call waited for its server; call #{tool.full_name} again"
|
|
268
|
+
end
|
|
269
|
+
return "Error: #{tool.full_name} can't be called: #{tool.refusal}" if tool.refusal
|
|
270
|
+
|
|
271
|
+
inner = inner_args(args, tool)
|
|
272
|
+
return "Error: args must be an object.#{ToolIndex.schema_note(tool)}" unless inner
|
|
273
|
+
|
|
274
|
+
source_of(tool.group).run(tool, inner)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The call tool's activity row: <group>/<tool> and its arguments.
|
|
278
|
+
def call_preview(args)
|
|
279
|
+
tool = lookup_quietly(args["tool"].to_s)
|
|
280
|
+
name = tool ? tool.full_name : args["tool"].to_s.strip
|
|
281
|
+
inner = tool && inner_args(args["args"], tool)
|
|
282
|
+
inner ? [name, preview(inner)].reject(&:empty?).join(" ") : name
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# What guardrail rules match a call: the tool it calls, by its
|
|
286
|
+
# acts_as, its arguments, and the question's label. A tool not
|
|
287
|
+
# indexed yet (its server still starting) is named from what the
|
|
288
|
+
# model gave (#claimed), so a rule on it fires before the call waits
|
|
289
|
+
# for the server. Nothing for a name that can't be a tool (the call
|
|
290
|
+
# refuses it).
|
|
291
|
+
def call_targets(args)
|
|
292
|
+
name = args["tool"].to_s.strip
|
|
293
|
+
tool = lookup_quietly(name)
|
|
294
|
+
acts_as, label = tool ? [tool.acts_as, "#{tool.group}: #{tool.name}"] : claimed(name)
|
|
295
|
+
return {} unless acts_as
|
|
296
|
+
|
|
297
|
+
{ acts_as: acts_as, args: inner_args(args["args"], tool) || {}, label: label }.compact
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
private
|
|
301
|
+
|
|
302
|
+
def find_spec
|
|
303
|
+
Plugin::ToolSpec.build(FIND_NAME, find_description, params: FIND_PARAMS,
|
|
304
|
+
preview: lambda { |args|
|
|
305
|
+
[args["query"].to_s.strip, args["server"] && "in #{args["server"]}"].compact.join(" ")
|
|
306
|
+
}) do |args, _ctx|
|
|
307
|
+
find(args["query"].to_s, args["server"])
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def call_spec
|
|
312
|
+
Plugin::ToolSpec.build(CALL_NAME, CALL_DESCRIPTION, params: CALL_PARAMS, label: @sources.first.label,
|
|
313
|
+
preview: ->(args) { call_preview(args) },
|
|
314
|
+
targets: ->(args) { call_targets(args) }) do |args, _ctx|
|
|
315
|
+
call(args["tool"].to_s, args["args"])
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def source_of(group) = @sources.find { |source| source.groups.any? { |g| g.name == group } }
|
|
320
|
+
|
|
321
|
+
# One match: its name, why it can't be called (if so), its
|
|
322
|
+
# description and schema.
|
|
323
|
+
def match_text(tool, all)
|
|
324
|
+
group = all.find { |g| g.name == tool.group }
|
|
325
|
+
head = [tool.full_name, tool.mark, group&.note].compact.join(" ")
|
|
326
|
+
return "#{head}: #{tool.description}\nIt can't be called: #{tool.refusal}" if tool.refusal
|
|
327
|
+
|
|
328
|
+
"#{head}: #{tool.description}\ninputSchema: #{JSON.generate(tool.schema)}"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Each group's tool names, one line each.
|
|
332
|
+
def names_text(groups)
|
|
333
|
+
lines = groups.map do |group|
|
|
334
|
+
names = group.tools.map { |tool| [tool.name, tool.mark].compact.join(" ") }
|
|
335
|
+
head = [group.name, group.note].compact.join(" ")
|
|
336
|
+
names.empty? ? head : "#{head}: #{names.join(", ")}"
|
|
337
|
+
end
|
|
338
|
+
"MCP tools by server (call one as <server>/<tool>; search for its inputSchema):\n#{lines.join("\n")}"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# +tools+ that share a word with +query+, best first: a word scores
|
|
342
|
+
# its IDF over +tools+ (a word most of them have counts little), times
|
|
343
|
+
# NAME_WEIGHT in the tool's or its group's name. Ties keep the index's
|
|
344
|
+
# order.
|
|
345
|
+
def ranked(query, tools)
|
|
346
|
+
wanted = words(query).uniq
|
|
347
|
+
docs = tools.map { |tool| [words("#{tool.group} #{tool.name}"), words(tool.description)] }
|
|
348
|
+
df = Hash.new(0)
|
|
349
|
+
docs.each { |name, text| (name | text).each { |word| df[word] += 1 } }
|
|
350
|
+
count = tools.size.to_f
|
|
351
|
+
scored = tools.each_with_index.filter_map do |tool, i|
|
|
352
|
+
name, text = docs[i]
|
|
353
|
+
score = wanted.sum do |word|
|
|
354
|
+
idf = Math.log(((count - df[word] + 0.5) / (df[word] + 0.5)) + 1)
|
|
355
|
+
if name.include?(word) then NAME_WEIGHT * idf
|
|
356
|
+
elsif text.include?(word) then idf
|
|
357
|
+
else 0
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
[tool, score, i] if score.positive?
|
|
361
|
+
end
|
|
362
|
+
scored.sort_by { |_, score, i| [-score, i] }.map(&:first)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Lower-case words, split at camelCase too, a plural's s dropped.
|
|
366
|
+
def words(text)
|
|
367
|
+
text.to_s.gsub(/([a-z0-9])([A-Z])/, '\1 \2').downcase.scan(/[a-z0-9]+/).map do |word|
|
|
368
|
+
word.length > 3 && word.end_with?("s") && !word.end_with?("ss") ? word.chomp("s") : word
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# The Tool +name+ names: <group>/<tool>, or an acts_as looked up as
|
|
373
|
+
# it is (never parsed: group names may hold _, and long names are
|
|
374
|
+
# cut). A miss while a group is starting waits for it (its tools may
|
|
375
|
+
# be the one; for a <group>/ name, that group's start only); then a
|
|
376
|
+
# miss is the error text: a failed group's error, else the closest
|
|
377
|
+
# tools.
|
|
378
|
+
# @return [Tool, String]
|
|
379
|
+
def lookup(name, waited: false)
|
|
380
|
+
name = name.strip
|
|
381
|
+
all = groups
|
|
382
|
+
tool = all.lazy.filter_map do |group|
|
|
383
|
+
next unless name.start_with?("#{group.name}/")
|
|
384
|
+
|
|
385
|
+
rest = name.delete_prefix("#{group.name}/")
|
|
386
|
+
group.tools.find { |t| t.name == rest }
|
|
387
|
+
end.first
|
|
388
|
+
return tool if tool
|
|
389
|
+
|
|
390
|
+
same = all.flat_map(&:tools).select { |t| t.acts_as == name }
|
|
391
|
+
return same.first if same.size == 1
|
|
392
|
+
return "Error: #{name} names #{same.map(&:full_name).join(" and ")}; call one as <server>/<tool>" if same.size > 1
|
|
393
|
+
|
|
394
|
+
# A name with a group's <group>/ waits for that group only; any
|
|
395
|
+
# other name (an acts_as) for every one starting.
|
|
396
|
+
named = all.select { |group| name.start_with?("#{group.name}/") }
|
|
397
|
+
starting = (named.empty? ? all : named).select { |group| group.state == :starting }
|
|
398
|
+
unless waited || starting.empty?
|
|
399
|
+
starting.each { |group| source_of(group.name)&.wait(group.name) }
|
|
400
|
+
return lookup(name, waited: true)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
failed = all.find { |group| group.state == :failed && name.start_with?("#{group.name}/") }
|
|
404
|
+
return "Error: MCP server #{failed.name} didn't start: #{failed.error}" if failed
|
|
405
|
+
|
|
406
|
+
closest = ranked(name, all.flat_map(&:tools)).take(CLOSEST)
|
|
407
|
+
return "Error: no MCP tool #{name}. Search with #{FIND_NAME}." if closest.empty?
|
|
408
|
+
|
|
409
|
+
"Error: no MCP tool #{name}. Closest: #{closest.map(&:full_name).join(", ")}"
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# #lookup's tool, without waiting for a start: nil on a miss.
|
|
413
|
+
def lookup_quietly(name)
|
|
414
|
+
tool = lookup(name, waited: true)
|
|
415
|
+
tool.is_a?(Tool) ? tool : nil
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# The acts_as and label +name+ stands for, from the name alone:
|
|
419
|
+
# <group>/<tool> of a known group (the one that has the tool, else the
|
|
420
|
+
# first whose name it starts with), its source naming the acts_as; or
|
|
421
|
+
# a bare name with a source's prefix as it is (no label). Nil for
|
|
422
|
+
# anything else.
|
|
423
|
+
# @return [Array(String, String), Array(String, nil), nil]
|
|
424
|
+
def claimed(name)
|
|
425
|
+
all = groups
|
|
426
|
+
named = all.select { |group| name.start_with?("#{group.name}/") }
|
|
427
|
+
group = named.find { |g| g.tools.any? { |t| t.name == name.delete_prefix("#{g.name}/") } } || named.first
|
|
428
|
+
if group
|
|
429
|
+
rest = name.delete_prefix("#{group.name}/")
|
|
430
|
+
[source_of(group.name).acts_as(group.name, rest), "#{group.name}: #{rest}"]
|
|
431
|
+
elsif @sources.any? { |source| source.prefix && name.start_with?(source.prefix) }
|
|
432
|
+
[name, nil]
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# The inner arguments as a Hash typed by +tool+'s schema (a JSON text
|
|
437
|
+
# parsed; untyped without a tool), nil when they aren't an object;
|
|
438
|
+
# none is {}.
|
|
439
|
+
def inner_args(args, tool)
|
|
440
|
+
args = {} if args.nil? || (args.is_a?(String) && args.strip.empty?)
|
|
441
|
+
args = Args.coerce({ "args" => args }, { "properties" => { "args" => { "type" => "object" } } })["args"]
|
|
442
|
+
return nil unless args.is_a?(Hash)
|
|
443
|
+
|
|
444
|
+
Args.coerce(args, tool&.schema)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def preview(args)
|
|
448
|
+
line = args.map { |key, value| "#{key}=#{value.is_a?(String) ? value : JSON.generate(value)}" }.join(" ")
|
|
449
|
+
line = line.gsub(/\s+/, " ")
|
|
450
|
+
line.length > PREVIEW_CHARS ? "#{line[0, PREVIEW_CHARS - 1]}…" : line
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
end
|
|
@@ -225,13 +225,16 @@ module Samagotchi
|
|
|
225
225
|
row.from = helper.app_version
|
|
226
226
|
if helper.stale?
|
|
227
227
|
row.to = VERSION
|
|
228
|
-
|
|
228
|
+
done(row, :update, @dry_run ? "rebuilds and restarts it" : "restarted") { helper.upgrade { nil } }
|
|
229
|
+
elsif helper.launch_outdated?
|
|
230
|
+
helper.refresh_launch_file unless @dry_run
|
|
231
|
+
up_to_date(row).tap { |r| r.status += @dry_run ? " (would refresh the launch file)" : " (launch file refreshed)" }
|
|
232
|
+
else
|
|
233
|
+
up_to_date(row)
|
|
229
234
|
end
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
helper.refresh_launch_file unless @dry_run
|
|
234
|
-
up_to_date(row).tap { |r| r.status += @dry_run ? " (would refresh the launch file)" : " (launch file refreshed)" }
|
|
235
|
+
# The terminals' warnings only: the host key ones read the Keychain.
|
|
236
|
+
helper.terminal_warnings.each { |warning| row.status += "; #{warning}" }
|
|
237
|
+
row
|
|
235
238
|
rescue Desktop::MacOS::Error, SystemCallError => e
|
|
236
239
|
failed(row, e.message.lines.first.to_s.strip)
|
|
237
240
|
end
|