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.
Files changed (151) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +55 -30
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +129 -38
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +18 -4
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/effective_model.rb +283 -0
  51. data/lib/samagotchi/engine.rb +307 -549
  52. data/lib/samagotchi/engine_peers.rb +3 -0
  53. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  54. data/lib/samagotchi/guardrails/approval.rb +1 -1
  55. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  56. data/lib/samagotchi/guardrails/rules.rb +1 -1
  57. data/lib/samagotchi/guardrails/targets.rb +1 -1
  58. data/lib/samagotchi/hook_routes.rb +151 -0
  59. data/lib/samagotchi/hooks/registry.rb +2 -2
  60. data/lib/samagotchi/host_keys.rb +147 -0
  61. data/lib/samagotchi/level_setting_command.rb +99 -0
  62. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  63. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  64. data/lib/samagotchi/llm/api_key.rb +23 -7
  65. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  66. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  67. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  68. data/lib/samagotchi/llm_context_override.rb +1 -0
  69. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  70. data/lib/samagotchi/peer_sources.rb +58 -0
  71. data/lib/samagotchi/plugin/api.rb +64 -155
  72. data/lib/samagotchi/plugin/loader.rb +4 -2
  73. data/lib/samagotchi/plugin/peer.rb +394 -0
  74. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  75. data/lib/samagotchi/plugin_tasks.rb +2 -2
  76. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  77. data/lib/samagotchi/row_refs.rb +1 -1
  78. data/lib/samagotchi/self_report.rb +5 -3
  79. data/lib/samagotchi/send_command.rb +21 -11
  80. data/lib/samagotchi/session.rb +22 -8
  81. data/lib/samagotchi/session_continue.rb +58 -15
  82. data/lib/samagotchi/session_inbox.rb +0 -13
  83. data/lib/samagotchi/session_setting.rb +86 -0
  84. data/lib/samagotchi/thinking_command.rb +24 -76
  85. data/lib/samagotchi/thinking_notices.rb +169 -0
  86. data/lib/samagotchi/tool_call_parser.rb +1 -1
  87. data/lib/samagotchi/toolkit_binding.rb +138 -0
  88. data/lib/samagotchi/tools/context_read.rb +14 -0
  89. data/lib/samagotchi/tools/execute.rb +12 -12
  90. data/lib/samagotchi/tools/registry.rb +1 -1
  91. data/lib/samagotchi/tools/task_create.rb +6 -6
  92. data/lib/samagotchi/tools/task_get.rb +13 -13
  93. data/lib/samagotchi/tools/task_list.rb +6 -6
  94. data/lib/samagotchi/tools/task_record.rb +52 -0
  95. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  96. data/lib/samagotchi/tools/task_stop.rb +6 -6
  97. data/lib/samagotchi/tools/task_wait.rb +12 -13
  98. data/lib/samagotchi/tools/tool_index.rb +454 -0
  99. data/lib/samagotchi/update_command.rb +9 -6
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +71 -93
  102. data/lib/samagotchi/web/capabilities.rb +1 -1
  103. data/lib/samagotchi/web/public/activity.js +2 -2
  104. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  105. data/lib/samagotchi/web/public/app.js +103 -539
  106. data/lib/samagotchi/web/public/bubbles.js +291 -0
  107. data/lib/samagotchi/web/public/cards_place.js +184 -0
  108. data/lib/samagotchi/web/public/chains.js +1 -1
  109. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  110. data/lib/samagotchi/web/public/command_list.js +12 -10
  111. data/lib/samagotchi/web/public/commands.js +2 -2
  112. data/lib/samagotchi/web/public/composer_size.js +2 -2
  113. data/lib/samagotchi/web/public/copy.js +1 -1
  114. data/lib/samagotchi/web/public/ctx.js +1 -1
  115. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  116. data/lib/samagotchi/web/public/families_view.js +1 -1
  117. data/lib/samagotchi/web/public/format.js +11 -7
  118. data/lib/samagotchi/web/public/images.js +25 -2
  119. data/lib/samagotchi/web/public/index.html +15 -7
  120. data/lib/samagotchi/web/public/info_bar.js +3 -3
  121. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  122. data/lib/samagotchi/web/public/init_row.js +21 -10
  123. data/lib/samagotchi/web/public/level_chip.js +29 -80
  124. data/lib/samagotchi/web/public/lists_view.js +64 -9
  125. data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
  126. data/lib/samagotchi/web/public/model_picker.js +26 -22
  127. data/lib/samagotchi/web/public/notify.js +6 -4
  128. data/lib/samagotchi/web/public/question_card.js +1 -1
  129. data/lib/samagotchi/web/public/ref_scan.js +2 -8
  130. data/lib/samagotchi/web/public/refs.js +13 -0
  131. data/lib/samagotchi/web/public/session_state.js +14 -0
  132. data/lib/samagotchi/web/public/session_title.js +2 -3
  133. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  134. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  135. data/lib/samagotchi/web/public/stage_model.js +35 -2
  136. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  137. data/lib/samagotchi/web/public/stage_view.js +56 -335
  138. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  139. data/lib/samagotchi/web/public/strip.js +3 -3
  140. data/lib/samagotchi/web/public/task_stop.js +3 -2
  141. data/lib/samagotchi/web/public/turn_events.js +1 -1
  142. data/lib/samagotchi/web/public/turn_html.js +2 -2
  143. data/lib/samagotchi/web/public/turn_model.js +2 -2
  144. data/lib/samagotchi/web/public/turn_view.js +1 -1
  145. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  146. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  147. data/lib/samagotchi/web/server.rb +0 -4
  148. data/lib/samagotchi/worker.rb +22 -24
  149. data/lib/samagotchi/worker_inbound.rb +36 -4
  150. data/lib/samagotchi/worker_wakes.rb +67 -7
  151. 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
- return done(row, :update, @dry_run ? "rebuilds and restarts it" : "restarted") { helper.upgrade { nil } }
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
- return up_to_date(row) unless helper.launch_outdated?
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.66.0"
4
+ VERSION = "0.68.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end