samagotchi 0.65.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.
Files changed (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +50 -28
  6. data/docs/cli.md +14 -2
  7. data/docs/configuration.md +101 -16
  8. data/docs/context.md +39 -2
  9. data/docs/desktop.md +90 -22
  10. data/docs/guardrails.md +8 -5
  11. data/docs/hooks.md +2 -2
  12. data/docs/internals/anthropic.md +156 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bundles.md +2 -0
  15. data/docs/internals/engine.md +75 -30
  16. data/docs/internals/gemma4-contract.md +1 -1
  17. data/docs/internals/llm-context-bench.md +5 -4
  18. data/docs/internals/prompt-caching.md +36 -13
  19. data/docs/memory.md +1 -1
  20. data/docs/plugins.md +289 -25
  21. data/docs/releasing.md +1 -1
  22. data/docs/sessions.md +16 -6
  23. data/docs/sub-agent.md +6 -1
  24. data/docs/testing.md +17 -3
  25. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  26. data/lib/samagotchi/bootstrap_command.rb +13 -3
  27. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  28. data/lib/samagotchi/bridge.rb +6 -3
  29. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
  31. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  33. data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
  34. data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
  35. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
  36. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  37. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
  38. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  39. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  40. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  44. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  45. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  46. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  47. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  48. data/lib/samagotchi/cache_confirm.rb +9 -6
  49. data/lib/samagotchi/commands/registry.rb +25 -8
  50. data/lib/samagotchi/config.rb +23 -8
  51. data/lib/samagotchi/context_absorber.rb +66 -13
  52. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  53. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  54. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  55. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  56. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  57. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  58. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  59. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  60. data/lib/samagotchi/desktop/macos.rb +84 -13
  61. data/lib/samagotchi/desktop_command.rb +14 -3
  62. data/lib/samagotchi/engine.rb +120 -107
  63. data/lib/samagotchi/engine_peers.rb +3 -0
  64. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  65. data/lib/samagotchi/guardrails/approval.rb +1 -1
  66. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  67. data/lib/samagotchi/guardrails/rules.rb +1 -1
  68. data/lib/samagotchi/guardrails/targets.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +151 -0
  70. data/lib/samagotchi/hooks/registry.rb +2 -2
  71. data/lib/samagotchi/host_keys.rb +147 -0
  72. data/lib/samagotchi/kernel_loop.rb +9 -1
  73. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  74. data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
  75. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  76. data/lib/samagotchi/llm/api_key.rb +23 -7
  77. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  78. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  79. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  80. data/lib/samagotchi/llm_context_override.rb +1 -0
  81. data/lib/samagotchi/peer_sources.rb +58 -0
  82. data/lib/samagotchi/plugin/api.rb +138 -156
  83. data/lib/samagotchi/plugin/context.rb +22 -1
  84. data/lib/samagotchi/plugin/loader.rb +5 -2
  85. data/lib/samagotchi/plugin/peer.rb +394 -0
  86. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  87. data/lib/samagotchi/plugin_tasks.rb +2 -2
  88. data/lib/samagotchi/row_refs.rb +1 -1
  89. data/lib/samagotchi/self_report.rb +5 -3
  90. data/lib/samagotchi/send_command.rb +41 -15
  91. data/lib/samagotchi/session.rb +18 -1
  92. data/lib/samagotchi/session_commands.rb +16 -1
  93. data/lib/samagotchi/session_continue.rb +58 -15
  94. data/lib/samagotchi/session_inbox.rb +0 -13
  95. data/lib/samagotchi/session_manager.rb +4 -1
  96. data/lib/samagotchi/session_setup.rb +14 -7
  97. data/lib/samagotchi/system_prompt.rb +16 -1
  98. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  99. data/lib/samagotchi/terminal_ui.rb +18 -2
  100. data/lib/samagotchi/tool_call_parser.rb +1 -1
  101. data/lib/samagotchi/tool_runner.rb +13 -1
  102. data/lib/samagotchi/toolkit.rb +246 -0
  103. data/lib/samagotchi/toolkit_binding.rb +138 -0
  104. data/lib/samagotchi/toolkit_command.rb +117 -0
  105. data/lib/samagotchi/tools/context_read.rb +14 -0
  106. data/lib/samagotchi/tools/execute.rb +12 -12
  107. data/lib/samagotchi/tools/registry.rb +43 -6
  108. data/lib/samagotchi/tools/task_create.rb +6 -6
  109. data/lib/samagotchi/tools/task_get.rb +13 -13
  110. data/lib/samagotchi/tools/task_list.rb +6 -6
  111. data/lib/samagotchi/tools/task_record.rb +52 -0
  112. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  113. data/lib/samagotchi/tools/task_stop.rb +6 -6
  114. data/lib/samagotchi/tools/task_wait.rb +12 -13
  115. data/lib/samagotchi/tools/tool_index.rb +454 -0
  116. data/lib/samagotchi/update_command.rb +9 -6
  117. data/lib/samagotchi/version.rb +1 -1
  118. data/lib/samagotchi/web/app.rb +28 -9
  119. data/lib/samagotchi/web/capabilities.rb +1 -1
  120. data/lib/samagotchi/web/public/activity.js +2 -2
  121. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  122. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  123. data/lib/samagotchi/web/public/app.js +52 -43
  124. data/lib/samagotchi/web/public/chains.js +1 -1
  125. data/lib/samagotchi/web/public/commands.js +6 -4
  126. data/lib/samagotchi/web/public/composer_size.js +2 -2
  127. data/lib/samagotchi/web/public/copy.js +1 -1
  128. data/lib/samagotchi/web/public/ctx.js +1 -1
  129. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  130. data/lib/samagotchi/web/public/families_view.js +1 -1
  131. data/lib/samagotchi/web/public/format.js +11 -7
  132. data/lib/samagotchi/web/public/images.js +25 -2
  133. data/lib/samagotchi/web/public/index.html +9 -0
  134. data/lib/samagotchi/web/public/info_bar.js +5 -3
  135. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  136. data/lib/samagotchi/web/public/notify.js +6 -4
  137. data/lib/samagotchi/web/public/question_card.js +1 -1
  138. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  139. data/lib/samagotchi/web/public/session_state.js +17 -0
  140. data/lib/samagotchi/web/public/session_title.js +2 -3
  141. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  142. data/lib/samagotchi/web/public/stage_model.js +2 -2
  143. data/lib/samagotchi/web/public/stream_handlers.js +13 -0
  144. data/lib/samagotchi/web/public/strip.js +3 -3
  145. data/lib/samagotchi/web/public/task_stop.js +3 -2
  146. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  147. data/lib/samagotchi/web/public/turn_events.js +1 -1
  148. data/lib/samagotchi/web/public/turn_html.js +2 -2
  149. data/lib/samagotchi/web/public/turn_model.js +2 -2
  150. data/lib/samagotchi/web/public/turn_view.js +1 -1
  151. data/lib/samagotchi/web/server.rb +0 -4
  152. data/lib/samagotchi/worker.rb +13 -8
  153. data/lib/samagotchi/worker_inbound.rb +36 -4
  154. data/lib/samagotchi/worker_wakes.rb +67 -7
  155. metadata +23 -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.65.0"
4
+ VERSION = "0.67.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -566,6 +566,14 @@ module Samagotchi
566
566
  LLMContextStrategy.for_session(session, registry: host_registry)
567
567
  end
568
568
 
569
+ # A session's toolkit as its file names it ({name:, label:}), nil on
570
+ # full: without a worker nothing resolves it (a bundle's kits load in
571
+ # the worker), so the tools it leaves out aren't known here.
572
+ def toolkit_for(session)
573
+ toolkit = session.toolkit
574
+ toolkit && !toolkit.full? ? { name: toolkit.name, label: toolkit.label } : nil
575
+ end
576
+
569
577
  # A session's thinking level with where it came from and its own
570
578
  # (SettingExplained#summary), as its worker would resolve it; nil
571
579
  # when it can't be. Config and the registry only: no network.
@@ -711,9 +719,13 @@ module Samagotchi
711
719
 
712
720
  # POST /api/sessions with continues: <id, prefix or last:id>: the next
713
721
  # link of that session's chain (SessionManager.continue_session), in its
714
- # folder, on its model and with its own setup (SessionSetup: llm_context,
715
- # thinking and reasoning_history levels), so dir, model, llm_context and
716
- # thinking are refused beside it (a create takes no reasoning_history).
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.
717
729
  # 409 continued (with next_id, the link that continues it already: the
718
730
  # page opens that one), open_children (ids), folder_gone, busy, scratch
719
731
  # or owned_by_tui.
@@ -722,14 +734,16 @@ module Samagotchi
722
734
  unless given.is_a?(String) && !given.strip.empty?
723
735
  return error_response(400, "invalid_continues", "continues is a session id")
724
736
  end
725
- if (fields = %w[dir model model_name llm_context thinking].select { |key| body.key?(key) }).any?
737
+ if (fields = %w[dir llm_context thinking].select { |key| body.key?(key) }).any?
726
738
  return error_response(400, "invalid_continues",
727
- "continues takes the previous session's folder, model, llm_context and thinking; " \
739
+ "continues takes the previous session's folder, llm_context and thinking; " \
728
740
  "leave out #{fields.join(", ")}")
729
741
  end
730
742
 
731
743
  preview = idle ? body["preview"].to_s.strip : ""
732
- session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, title: preview.empty? ? nil : preview)
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)
733
747
  return error if error
734
748
 
735
749
  port = await_bridge_port(session.id)
@@ -737,14 +751,15 @@ module Samagotchi
737
751
  # The moved delegates' cards fold under the new link now.
738
752
  [session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
739
753
  record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
740
- 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))
741
756
  end
742
757
 
743
758
  # SessionManager.continue_session, its refusals as responses: [session,
744
759
  # nil] or [nil, the response]. Only its own errors: what fails after
745
760
  # the new link started is not a refusal of it.
746
- def continue_from(given, prompt:, title:)
747
- [@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]
748
763
  rescue SessionContinue::Refused => e
749
764
  extra = e.reason == :continued ? { next_id: e.ids.first } : { ids: e.ids }
750
765
  [nil, json_response(409, { error: e.reason.to_s, detail: e.message, **extra })]
@@ -848,6 +863,10 @@ module Samagotchi
848
863
  # native host), else the server's reading (an older worker, none).
849
864
  history = snapshot&.key?("reasoning_history") ? snapshot["reasoning_history"] : reasoning_history_for(session)
850
865
  session_json = session_json.merge(reasoning_history: history)
866
+ # Its toolkit when it isn't full (the tools chip): the worker's, which
867
+ # resolved it, else the name saved in the session file.
868
+ toolkit = snapshot&.key?("toolkit") ? snapshot["toolkit"] : toolkit_for(session)
869
+ session_json = session_json.merge(toolkit: toolkit)
851
870
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
852
871
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
853
872
  json_response(200, {
@@ -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
- # (Engine#hook_notify's fallback_for:), and what this viewer can do. The
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
- export const ROW_STATUS = Object.freeze({
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
- export function completedStatus(status) {
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;