samagotchi 0.66.0 → 0.67.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 +60 -1
- data/docs/architecture.md +50 -28
- 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 +71 -29
- 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 +17 -3
- 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/engine.rb +96 -191
- 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/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/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/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_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- 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 +16 -9
- 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 +31 -36
- data/lib/samagotchi/web/public/chains.js +1 -1
- 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/llm_context_chip.js +2 -2
- 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 +1 -1
- 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_model.js +2 -2
- 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/server.rb +0 -4
- data/lib/samagotchi/worker.rb +11 -8
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +14 -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
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -719,9 +719,13 @@ module Samagotchi
|
|
|
719
719
|
|
|
720
720
|
# POST /api/sessions with continues: <id, prefix or last:id>: the next
|
|
721
721
|
# link of that session's chain (SessionManager.continue_session), in its
|
|
722
|
-
# folder
|
|
723
|
-
#
|
|
724
|
-
#
|
|
722
|
+
# folder and with its own setup (SessionSetup: llm_context, thinking
|
|
723
|
+
# and reasoning_history levels), so dir, llm_context and thinking are
|
|
724
|
+
# refused beside it (a create takes no reasoning_history). It runs on
|
|
725
|
+
# model (or model_name) when given, else on the previous link's: one
|
|
726
|
+
# whose host is gone falls back to the default, and the 201's
|
|
727
|
+
# model_warning (null when none) says so. A model it can't start on is
|
|
728
|
+
# 400 invalid_model, nothing archived.
|
|
725
729
|
# 409 continued (with next_id, the link that continues it already: the
|
|
726
730
|
# page opens that one), open_children (ids), folder_gone, busy, scratch
|
|
727
731
|
# or owned_by_tui.
|
|
@@ -730,14 +734,16 @@ module Samagotchi
|
|
|
730
734
|
unless given.is_a?(String) && !given.strip.empty?
|
|
731
735
|
return error_response(400, "invalid_continues", "continues is a session id")
|
|
732
736
|
end
|
|
733
|
-
if (fields = %w[dir
|
|
737
|
+
if (fields = %w[dir llm_context thinking].select { |key| body.key?(key) }).any?
|
|
734
738
|
return error_response(400, "invalid_continues",
|
|
735
|
-
"continues takes the previous session's folder,
|
|
739
|
+
"continues takes the previous session's folder, llm_context and thinking; " \
|
|
736
740
|
"leave out #{fields.join(", ")}")
|
|
737
741
|
end
|
|
738
742
|
|
|
739
743
|
preview = idle ? body["preview"].to_s.strip : ""
|
|
740
|
-
|
|
744
|
+
model = (body["model"] || body["model_name"]).to_s.strip
|
|
745
|
+
session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, title: preview.empty? ? nil : preview,
|
|
746
|
+
model: model.empty? ? nil : model)
|
|
741
747
|
return error if error
|
|
742
748
|
|
|
743
749
|
port = await_bridge_port(session.id)
|
|
@@ -745,14 +751,15 @@ module Samagotchi
|
|
|
745
751
|
# The moved delegates' cards fold under the new link now.
|
|
746
752
|
[session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
|
|
747
753
|
record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
|
|
748
|
-
json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved
|
|
754
|
+
json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved,
|
|
755
|
+
model_warning: session.model_warning))
|
|
749
756
|
end
|
|
750
757
|
|
|
751
758
|
# SessionManager.continue_session, its refusals as responses: [session,
|
|
752
759
|
# nil] or [nil, the response]. Only its own errors: what fails after
|
|
753
760
|
# the new link started is not a refusal of it.
|
|
754
|
-
def continue_from(given, prompt:, title:)
|
|
755
|
-
[@manager.continue_session(given, prompt: prompt, title: title, state_dir: @state_dir), nil]
|
|
761
|
+
def continue_from(given, prompt:, title:, model:)
|
|
762
|
+
[@manager.continue_session(given, prompt: prompt, title: title, model: model, state_dir: @state_dir), nil]
|
|
756
763
|
rescue SessionContinue::Refused => e
|
|
757
764
|
extra = e.reason == :continued ? { next_id: e.ids.first } : { ids: e.ids }
|
|
758
765
|
[nil, json_response(409, { error: e.reason.to_s, detail: e.message, **extra })]
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
module Web
|
|
5
5
|
# What this web page renders that a notice may stand in for
|
|
6
|
-
# (
|
|
6
|
+
# (HookRoutes#notify's fallback_for:), and what this viewer can do. The
|
|
7
7
|
# session and tail JSON carry it as +capabilities+; the page leaves out
|
|
8
8
|
# a notice whose fallback_for names a capability that is true here.
|
|
9
9
|
# +display+: the answer's display is rendered as markdown, so its links
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// state from :tool_call_started / :tool_call_completed events. Keeping the
|
|
7
7
|
// state pure makes it unit-testable under `node --test` like data.js/format.js.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
const ROW_STATUS = Object.freeze({
|
|
10
10
|
RUNNING: "running",
|
|
11
11
|
OK: "ok",
|
|
12
12
|
ERROR: "error",
|
|
@@ -161,7 +161,7 @@ function callDuration(row, event, now) {
|
|
|
161
161
|
|
|
162
162
|
// A completed call's row status from the server's: error, stopped,
|
|
163
163
|
// blocked, else ok.
|
|
164
|
-
|
|
164
|
+
function completedStatus(status) {
|
|
165
165
|
if (status === "error") return ROW_STATUS.ERROR;
|
|
166
166
|
if (status === "stopped") return ROW_STATUS.STOPPED;
|
|
167
167
|
if (status === "blocked") return ROW_STATUS.BLOCKED;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { quoteBlock } from "./annotations.js";
|
|
16
16
|
|
|
17
17
|
// How many plugin pills the bar shows; the rest go behind "⋯".
|
|
18
|
-
|
|
18
|
+
const INLINE_ACTIONS = 3;
|
|
19
19
|
|
|
20
20
|
// The session's commands' annotations, flattened in listing order: {label,
|
|
21
21
|
// line, template, send, command, anytime, source}. anytime: the command
|