samagotchi 0.68.0 → 0.69.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +28 -6
  6. data/docs/guardrails.md +23 -1
  7. data/docs/hooks.md +4 -0
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +15 -8
  10. data/docs/internals/prompt-caching.md +9 -2
  11. data/docs/plugins.md +42 -19
  12. data/docs/releasing.md +14 -6
  13. data/docs/sessions.md +3 -3
  14. data/docs/testing.md +7 -1
  15. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  18. data/lib/samagotchi/config.rb +0 -19
  19. data/lib/samagotchi/engine.rb +5 -2
  20. data/lib/samagotchi/guardrails/targets.rb +9 -2
  21. data/lib/samagotchi/host_config.rb +1 -29
  22. data/lib/samagotchi/host_model.rb +0 -7
  23. data/lib/samagotchi/kernel_loop.rb +24 -7
  24. data/lib/samagotchi/llm/errors.rb +47 -1
  25. data/lib/samagotchi/llm/http.rb +20 -2
  26. data/lib/samagotchi/llm_context_stale.rb +6 -0
  27. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  28. data/lib/samagotchi/model_price.rb +2 -4
  29. data/lib/samagotchi/session_metrics.rb +4 -1
  30. data/lib/samagotchi/system_prompt.rb +19 -3
  31. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  32. data/lib/samagotchi/tool_activity.rb +3 -0
  33. data/lib/samagotchi/tool_row_fields.rb +4 -2
  34. data/lib/samagotchi/tool_runner.rb +24 -3
  35. data/lib/samagotchi/toolkit.rb +27 -14
  36. data/lib/samagotchi/toolkit_binding.rb +8 -2
  37. data/lib/samagotchi/toolkit_command.rb +20 -4
  38. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  39. data/lib/samagotchi/tools/registry.rb +95 -8
  40. data/lib/samagotchi/tools/tool_index.rb +223 -72
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +14 -9
  43. data/lib/samagotchi/web/message_parts.rb +5 -2
  44. data/lib/samagotchi/web/public/activity.js +3 -1
  45. data/lib/samagotchi/web/public/app.js +123 -22
  46. data/lib/samagotchi/web/public/chains.js +19 -2
  47. data/lib/samagotchi/web/public/families_view.js +15 -0
  48. data/lib/samagotchi/web/public/index.html +7 -5
  49. data/lib/samagotchi/web/public/model_picker.js +21 -6
  50. data/lib/samagotchi/web/public/session_state.js +5 -0
  51. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  52. data/lib/samagotchi/web/public/timing.js +3 -1
  53. data/lib/samagotchi/web/public/toast_place.js +16 -0
  54. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  55. data/lib/samagotchi/web/public/turn_html.js +4 -2
  56. metadata +2 -1
@@ -2,6 +2,9 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "args"
5
+ require_relative "builtin_calls"
6
+ require_relative "registry"
7
+ require_relative "../log"
5
8
  require_relative "../token_usage"
6
9
  require_relative "../tool_declarations"
7
10
  require_relative "../plugin/tool_spec"
@@ -9,24 +12,31 @@ require_relative "../plugin/tool_spec"
9
12
  module Samagotchi
10
13
  module Tools
11
14
  # 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
+ # tools that reach them (docs/plugins.md, "Publishing tools to the
16
+ # index"): find_tools, a keyword search that answers each match's
17
+ # inputSchema, and call_tool, which calls one as <group>/<tool>.
15
18
  # 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.
19
+ # MCP server each) and runs the calls; the index's own group, chi, is
20
+ # the built-ins the session's toolkit leaves out but doesn't remove
21
+ # (Registry#reachable_names), and a call_tool of one runs as that
22
+ # built-in (#unwrap, before the guardrail gate). The index searches the
23
+ # groups, looks a call's tool up and checks it.
24
+ #
25
+ # The two tools are registered once, after the plugins load, and
26
+ # declared per toolkit (Registry#declared?): when a source registered
27
+ # then, or the toolkit leaves a built-in reachable. The search tool's
28
+ # description has the source groups' lines as they were at load and a
29
+ # chi line per toolkit (#refresh!): nothing a source publishes later
30
+ # changes the request's tools, so the prompt cache keeps.
20
31
  #
21
32
  # They are registry entries of source SOURCE, not "core": a plugin
22
33
  # 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.
34
+ # flat schemas) are the ones call_tool has always had.
26
35
  class ToolIndex
27
36
  SOURCE = "index"
28
37
  FIND_NAME = "find_tools"
29
- CALL_NAME = "call_tool"
38
+ # What an unwrapped call carries as via: too.
39
+ CALL_NAME = BuiltinCalls::VIA
30
40
  # Matches a search answers, and names a miss suggests.
31
41
  FIND_RESULTS = 5
32
42
  CLOSEST = 3
@@ -34,15 +44,20 @@ module Samagotchi
34
44
  # times one in its description.
35
45
  NAME_WEIGHT = 3
36
46
  PREVIEW_CHARS = 60
47
+ # The index's own group: chi's built-ins a toolkit leaves out.
48
+ CHI_GROUP = "chi"
49
+ CHI_SUMMARY = "chi's own tools not declared in this session"
50
+ # Group names a source can't publish: the index's own groups'.
51
+ RESERVED_GROUPS = [CHI_GROUP].freeze
37
52
 
38
53
  FIND_PARAMS = {
39
54
  query: { type: "string", required: true,
40
55
  description: "keywords for what the tool does (\"screenshot page\"); empty lists every tool's name" },
41
- server: { type: "string", description: "search only this server" }
56
+ group: { type: "string", description: "search only this group" }
42
57
  }.freeze
43
- CALL_DESCRIPTION = "Call an MCP server's tool that #{FIND_NAME} found.".freeze
58
+ CALL_DESCRIPTION = "Call a tool #{FIND_NAME} found.".freeze
44
59
  CALL_PARAMS = {
45
- tool: { type: "string", required: true, description: "the tool as <server>/<tool>" },
60
+ tool: { type: "string", required: true, description: "the tool as <group>/<tool>" },
46
61
  args: { type: "object", description: "the arguments, per the inputSchema #{FIND_NAME} gave" }
47
62
  }.freeze
48
63
 
@@ -50,15 +65,17 @@ module Samagotchi
50
65
  # +schema+ its inputSchema, +acts_as+ the name guardrail rules match
51
66
  # (mcp_<server>_<tool>; a bare name the call tool looks up too).
52
67
  # +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)
68
+ # search shows beside its name then ("(bad schema)"). +entry+ is the
69
+ # Registry entry of a tool the index's own groups list (nil for a
70
+ # source's). A blank +acts_as+ raises ArgumentError: no rule could
71
+ # match the tool's calls.
72
+ Tool = Data.define(:group, :name, :description, :schema, :acts_as, :refusal, :mark, :entry) do
73
+ def initialize(group:, name:, acts_as:, description: "", schema: nil, refusal: nil, mark: nil, entry: nil)
57
74
  raise ArgumentError, "tool #{group}/#{name}: acts_as is blank" if acts_as.to_s.strip.empty?
58
75
 
59
76
  super(group: group.to_s, name: name.to_s, description: description.to_s,
60
77
  schema: schema || { "type" => "object", "properties" => {} }, acts_as: acts_as.to_s,
61
- refusal: refusal, mark: mark)
78
+ refusal: refusal, mark: mark, entry: entry)
62
79
  end
63
80
 
64
81
  def full_name = "#{group}/#{name}"
@@ -160,6 +177,13 @@ module Samagotchi
160
177
 
161
178
  def initialize
162
179
  @sources = []
180
+ # The Registry #declare registered in: the chi group reads it.
181
+ @registry = nil
182
+ # The source groups' lines in the search tool's description, as
183
+ # #declare found them (#find_description).
184
+ @described = [].freeze
185
+ @dropped = []
186
+ @dropped_mutex = Mutex.new
163
187
  end
164
188
 
165
189
  # A new source, not in the index yet (Plugin::Api#commit! #add's it).
@@ -172,16 +196,33 @@ module Samagotchi
172
196
 
173
197
  def sources = @sources.dup
174
198
 
175
- # Every source's groups, now.
176
- def groups = @sources.flat_map(&:groups)
199
+ # Every group, now: the index's own (#registry_groups), then the
200
+ # sources'. A source group with a reserved name, or one an earlier
201
+ # group has, is left out of search, lookup and the description (said
202
+ # once per name: a source publishes after load too).
203
+ def groups
204
+ own = registry_groups
205
+ taken = own.map(&:name) | RESERVED_GROUPS
206
+ kept = @sources.flat_map(&:groups).select do |group|
207
+ why = if RESERVED_GROUPS.include?(group.name) then "reserved"
208
+ elsif taken.include?(group.name) then "taken by another group"
209
+ end
210
+ dropped(group.name, why) if why
211
+ taken << group.name unless why
212
+ why.nil?
213
+ end
214
+ own + kept
215
+ end
177
216
 
178
217
  # 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+).
218
+ # plugins load (or without them: the built-ins a toolkit leaves out
219
+ # are reachable either way); +registry+ declares them per toolkit
220
+ # (Registry::Index). A name another tool has is left out (a line in
221
+ # +failures+).
181
222
  # @return [Array<String>] the names registered
182
223
  def declare(registry, failures: nil)
183
- return [] if @sources.empty?
184
-
224
+ @registry = registry
225
+ @described = description_lines(groups.reject { |group| own?(group) }).freeze
185
226
  specs = [find_spec, call_spec]
186
227
  declared = specs.filter_map do |spec|
187
228
  if (taken = registry[spec.name])
@@ -190,13 +231,24 @@ module Samagotchi
190
231
  next
191
232
  end
192
233
 
193
- registry.register(spec.name, source: SOURCE, **spec.entry_fields(nil))
234
+ unwrap = (->(call, layers) { unwrap(call, layers) } if spec.name == CALL_NAME)
235
+ registry.register(spec.name, source: SOURCE, unwrap: unwrap, **spec.entry_fields(nil))
194
236
  spec
195
237
  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
238
+ registry.index = Registry::Index.new(source: SOURCE, call_name: CALL_NAME, sourced: !@sources.empty?)
239
+ declared!(declared)
240
+ declared.map(&:name)
241
+ end
242
+
243
+ # The search tool's description again for the Registry's toolkit now
244
+ # (ToolkitBinding#apply!, at a turn's start): its chi line follows the
245
+ # toolkit, the source groups' are the ones #declare found.
246
+ def refresh!
247
+ entry = @registry && @registry[FIND_NAME]
248
+ return unless entry&.source == SOURCE
249
+
250
+ entry.schema = entry.schema.merge(description: find_description)
251
+ declared!([find_spec, call_spec].select { |spec| @registry[spec.name]&.source == SOURCE })
200
252
  end
201
253
 
202
254
  # A checked tool declaration's (Plugin::ToolSpec) definition as
@@ -217,49 +269,35 @@ module Samagotchi
217
269
  end
218
270
 
219
271
  # 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.
272
+ # group from what was known when it was declared (a first-run
273
+ # server's: its name, and its summary if it has one). Never every
274
+ # tool: knowing the names, a model searches less and guesses
275
+ # arguments.
223
276
  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")}"
277
+ "Search tools this session doesn't declare, by keywords. It answers up to #{FIND_RESULTS} tools, each with " \
278
+ "its name (<group>/<tool>) and inputSchema; call one with #{CALL_NAME}. An empty query lists every tool's " \
279
+ "name.\nGroups:\n#{(description_lines(registry_groups) + @described).join("\n")}"
234
280
  end
235
281
 
236
282
  # ── Search ──────────────────────────────────────────────────────────
237
283
 
238
284
  # 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")}"
285
+ # empty query every group's tool names. +server+ is the parameter's
286
+ # name before it was +group+: a model that still gives it is told
287
+ # every group was searched.
288
+ def find(query, only = nil, server: nil)
289
+ answer = find_in(query, only)
290
+ return answer unless (only.nil? || only.strip.empty?) && !server.to_s.strip.empty?
291
+
292
+ "(searched every group)\n#{answer}"
256
293
  end
257
294
 
258
295
  # ── The call ────────────────────────────────────────────────────────
259
296
 
260
297
  # A call of the tool +name+ names, with +args+ typed by its schema,
261
298
  # run by its source. It fails closed: a tool that isn't the one
262
- # guardrails were given (#claimed) is refused.
299
+ # guardrails were given (#claimed) is refused, and so is one of the
300
+ # index's own (a built-in runs as itself, #unwrap, or not at all).
263
301
  def call(name, args)
264
302
  tool = lookup(name)
265
303
  return tool if tool.is_a?(String)
@@ -270,10 +308,35 @@ module Samagotchi
270
308
 
271
309
  inner = inner_args(args, tool)
272
310
  return "Error: args must be an object.#{ToolIndex.schema_note(tool)}" unless inner
311
+ return "Error: #{tool.full_name} can't be called through #{CALL_NAME} here" if tool.entry
273
312
 
274
313
  source_of(tool.group).run(tool, inner)
275
314
  end
276
315
 
316
+ # The built-in a call_tool +call+ names, as the call that runs in its
317
+ # place (ToolRunner#run's first step, before the guardrail gate):
318
+ # built from its arguments as a direct call would be
319
+ # (BuiltinCalls.build), with via: "call_tool". The name is a chi tool
320
+ # (a bare one only while the toolkit reaches it), or chi/<built-in>
321
+ # for any registered built-in: one the toolkit removes or declares
322
+ # is then refused by its name before the gate (ToolRunner#left_out?),
323
+ # with no hook or card. Nil, so the call runs as call_tool, when the
324
+ # turn under +layers+ doesn't offer call_tool, for an MCP tool, a miss
325
+ # or arguments that aren't an object.
326
+ # @return [Hash, nil]
327
+ def unwrap(call, layers = [])
328
+ return nil unless @registry&.offered(CALL_NAME, layers: layers)
329
+
330
+ args = call[:args].is_a?(Hash) ? call[:args] : {}
331
+ name = args["tool"].to_s.strip
332
+ tool = lookup_quietly(name)
333
+ entry = tool&.entry || builtin_named(name)
334
+ return nil unless entry
335
+
336
+ inner = inner_args(args["args"], tool)
337
+ inner && BuiltinCalls.build(entry.name, inner).merge(via: CALL_NAME)
338
+ end
339
+
277
340
  # The call tool's activity row: <group>/<tool> and its arguments.
278
341
  def call_preview(args)
279
342
  tool = lookup_quietly(args["tool"].to_s)
@@ -302,21 +365,101 @@ module Samagotchi
302
365
  def find_spec
303
366
  Plugin::ToolSpec.build(FIND_NAME, find_description, params: FIND_PARAMS,
304
367
  preview: lambda { |args|
305
- [args["query"].to_s.strip, args["server"] && "in #{args["server"]}"].compact.join(" ")
368
+ [args["query"].to_s.strip, args["group"] && "in #{args["group"]}"].compact.join(" ")
306
369
  }) do |args, _ctx|
307
- find(args["query"].to_s, args["server"])
370
+ find(args["query"].to_s, args["group"], server: args["server"])
308
371
  end
309
372
  end
310
373
 
311
374
  def call_spec
312
- Plugin::ToolSpec.build(CALL_NAME, CALL_DESCRIPTION, params: CALL_PARAMS, label: @sources.first.label,
375
+ Plugin::ToolSpec.build(CALL_NAME, CALL_DESCRIPTION, params: CALL_PARAMS, label: @sources.first&.label,
313
376
  preview: ->(args) { call_preview(args) },
314
377
  targets: ->(args) { call_targets(args) }) do |args, _ctx|
315
378
  call(args["tool"].to_s, args["args"])
316
379
  end
317
380
  end
318
381
 
319
- def source_of(group) = @sources.find { |source| source.groups.any? { |g| g.name == group } }
382
+ # The source whose group +group+ is: nil for the index's own (a
383
+ # reserved name).
384
+ def source_of(group)
385
+ return nil if RESERVED_GROUPS.include?(group)
386
+
387
+ @sources.find { |source| source.groups.any? { |g| g.name == group } }
388
+ end
389
+
390
+ def find_in(query, only)
391
+ all = groups
392
+ searched = all
393
+ if only && !only.strip.empty?
394
+ searched = all.select { |group| group.name == only.strip }
395
+ return "Error: no group #{only.strip}. Groups: #{all.map(&:name).join(", ")}" if searched.empty?
396
+ end
397
+ return names_text(searched) if words(query).empty?
398
+
399
+ matches = ranked(query, searched.flat_map(&:tools)).take(FIND_RESULTS)
400
+ return "No tool matches \"#{query.strip}\".\n\n#{names_text(searched)}" if matches.empty?
401
+
402
+ blocks = matches.map { |tool| match_text(tool, all) }
403
+ unsearched = searched.filter_map { |group| "#{group.name} #{group.note}" if group.tools.empty? && group.note }
404
+ blocks << "Not searched: #{unsearched.join("; ")}." unless unsearched.empty?
405
+ "Call one with #{CALL_NAME}(tool: \"<group>/<tool>\", args: {…}).\n\n#{blocks.join("\n\n")}"
406
+ end
407
+
408
+ # What the sources hear of the declared tools (/mcp's last line).
409
+ def declared!(specs)
410
+ names = specs.map(&:name)
411
+ tokens = specs.sum { |spec| ToolIndex.spec_tokens(spec) }
412
+ @sources.each { |source| source.declared!(names, tokens) }
413
+ end
414
+
415
+ def own?(group) = RESERVED_GROUPS.include?(group.name)
416
+
417
+ # The registered built-in chi/<name> names, reachable or not.
418
+ def builtin_named(name)
419
+ return nil unless name.start_with?("#{CHI_GROUP}/")
420
+
421
+ entry = @registry[name.delete_prefix("#{CHI_GROUP}/")]
422
+ entry if entry&.core?
423
+ end
424
+
425
+ # The index's own groups: chi, the built-ins the Registry's toolkit
426
+ # leaves out but reaches (none: no group). Each tool's schema is its
427
+ # registered entry's (a config's execute description included), and
428
+ # it acts as itself.
429
+ # @return [Array<Group>]
430
+ def registry_groups
431
+ names = @registry ? @registry.reachable_names : []
432
+ return [] if names.empty?
433
+
434
+ tools = names.map do |name|
435
+ entry = @registry[name]
436
+ schema = ToolDeclarations.chat_schemas([entry.schema]).first
437
+ Tool.new(group: CHI_GROUP, name: name, acts_as: name, description: schema[:description].to_s,
438
+ schema: JSON.parse(JSON.generate(schema[:parameters])), entry: entry)
439
+ end
440
+ [Group.new(name: CHI_GROUP, summary: CHI_SUMMARY, tools: tools)]
441
+ end
442
+
443
+ # One line per group in the search tool's description: its name, its
444
+ # summary and how many tools it can call.
445
+ def description_lines(groups)
446
+ groups.map do |group|
447
+ count = group.callable.size
448
+ line = "- #{group.name}"
449
+ line += ": #{group.summary}" if group.summary
450
+ line += " (#{count} tool#{"s" unless count == 1})" unless group.tools.empty?
451
+ line
452
+ end
453
+ end
454
+
455
+ # Say once per name that a source's group was left out (#groups).
456
+ def dropped(name, why)
457
+ first = @dropped_mutex.synchronize { @dropped.include?(name) ? false : (@dropped << name) }
458
+ return unless first
459
+
460
+ Log.warn(:plugins, "tool_group_dropped", group: name, why: why,
461
+ msg: "tool group #{name} is left out of the tool index: #{why}")
462
+ end
320
463
 
321
464
  # One match: its name, why it can't be called (if so), its
322
465
  # description and schema.
@@ -335,7 +478,7 @@ module Samagotchi
335
478
  head = [group.name, group.note].compact.join(" ")
336
479
  names.empty? ? head : "#{head}: #{names.join(", ")}"
337
480
  end
338
- "MCP tools by server (call one as <server>/<tool>; search for its inputSchema):\n#{lines.join("\n")}"
481
+ "Tools by group (call one as <group>/<tool>; search for its inputSchema):\n#{lines.join("\n")}"
339
482
  end
340
483
 
341
484
  # +tools+ that share a word with +query+, best first: a word scores
@@ -389,7 +532,7 @@ module Samagotchi
389
532
 
390
533
  same = all.flat_map(&:tools).select { |t| t.acts_as == name }
391
534
  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
535
+ return "Error: #{name} names #{same.map(&:full_name).join(" and ")}; call one as <group>/<tool>" if same.size > 1
393
536
 
394
537
  # A name with a group's <group>/ waits for that group only; any
395
538
  # other name (an acts_as) for every one starting.
@@ -404,9 +547,9 @@ module Samagotchi
404
547
  return "Error: MCP server #{failed.name} didn't start: #{failed.error}" if failed
405
548
 
406
549
  closest = ranked(name, all.flat_map(&:tools)).take(CLOSEST)
407
- return "Error: no MCP tool #{name}. Search with #{FIND_NAME}." if closest.empty?
550
+ return "Error: no tool #{name}. Search with #{FIND_NAME}." if closest.empty?
408
551
 
409
- "Error: no MCP tool #{name}. Closest: #{closest.map(&:full_name).join(", ")}"
552
+ "Error: no tool #{name}. Closest: #{closest.map(&:full_name).join(", ")}"
410
553
  end
411
554
 
412
555
  # #lookup's tool, without waiting for a start: nil on a miss.
@@ -417,17 +560,25 @@ module Samagotchi
417
560
 
418
561
  # The acts_as and label +name+ stands for, from the name alone:
419
562
  # <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
563
+ # first whose name it starts with), its source naming the acts_as; a
564
+ # bare name of one of the index's own tools (it acts as itself); or a
565
+ # bare name with a source's prefix as it is (no label). Nil for
422
566
  # anything else.
423
567
  # @return [Array(String, String), Array(String, nil), nil]
424
568
  def claimed(name)
425
569
  all = groups
570
+ if (own = all.select { |g| own?(g) }.find { |g| g.tools.any? { |t| t.acts_as == name } })
571
+ return [name, "#{own.name}: #{name}"]
572
+ end
573
+
426
574
  named = all.select { |group| name.start_with?("#{group.name}/") }
427
575
  group = named.find { |g| g.tools.any? { |t| t.name == name.delete_prefix("#{g.name}/") } } || named.first
428
576
  if group
429
577
  rest = name.delete_prefix("#{group.name}/")
430
- [source_of(group.name).acts_as(group.name, rest), "#{group.name}: #{rest}"]
578
+ # A group of the index's own has no source: its tools act as
579
+ # themselves.
580
+ source = source_of(group.name)
581
+ [source ? source.acts_as(group.name, rest) : rest, "#{group.name}: #{rest}"]
431
582
  elsif @sources.any? { |source| source.prefix && name.start_with?(source.prefix) }
432
583
  [name, nil]
433
584
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.68.0"
4
+ VERSION = "0.69.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -799,16 +799,16 @@ module Samagotchi
799
799
  cwd: session.working_directory),
800
800
  failed_turn: failed_turn_for(session, raw_messages),
801
801
  current_turn: current_turn,
802
- queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
802
+ queued: snapshot_list(turn_snapshot, "queued"),
803
803
  # Commands waiting for the running turn's end (their bubbles).
804
- queued_commands: turn_snapshot ? Array(turn_snapshot["queued_commands"]) : [],
804
+ queued_commands: snapshot_list(turn_snapshot, "queued_commands"),
805
805
  recap: turn_snapshot && turn_snapshot["recap"],
806
806
  saved_recap: saved_recap_for(id, session, turn_snapshot),
807
807
  continue_offer: turn_snapshot && turn_snapshot["continue_offer"],
808
808
  guardrail_warning: turn_snapshot && turn_snapshot["guardrail_warning"],
809
809
  plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
810
810
  # Plugins' slow setup still running (chi.init): the page's init row.
811
- init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
811
+ init_tasks: snapshot_list(turn_snapshot, "init_tasks"),
812
812
  cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }),
813
813
  # The composer's / autocomplete; the built-ins until a worker
814
814
  # names its plugins' too.
@@ -826,6 +826,10 @@ module Samagotchi
826
826
  error_response(404, "not_found", e.message)
827
827
  end
828
828
 
829
+ # A list in the worker's turn +snapshot+ (queued prompts, commands,
830
+ # init tasks); [] without a worker.
831
+ def snapshot_list(snapshot, key) = Array(snapshot&.[](key))
832
+
829
833
  # A live worker's +key+ as its reading (+live+: a snapshot, a tail)
830
834
  # says it, nil included; without a worker (+live+ nil) the block's,
831
835
  # the server's own reading of the saved session. A nil value from a
@@ -902,17 +906,18 @@ module Samagotchi
902
906
  turn_id = nil if turn_id.empty?
903
907
  tail = live_tail(id, turn_id: turn_id)
904
908
  state = tail && tail["session_state_snapshot"]
905
- if state.is_a?(Hash)
906
- session_json = { id: id, status: state["status"], used_memory_names: Array(state["used_memory_names"]),
907
- prompt_notes: Array(state["prompt_notes"]) }
909
+ state = nil unless state.is_a?(Hash)
910
+ if state
911
+ status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
908
912
  messages = last_assistant_for_display([tail["answer"]].compact)
909
913
  else
910
914
  session = @session_class.load(id, state_dir: default_state_dir)
911
- state = nil
912
- session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names),
913
- prompt_notes: session.prompt_notes.map(&:to_h) }
915
+ status = displayed_status(session)
916
+ used = session.used_memory_names
917
+ notes = session.prompt_notes.map(&:to_h)
914
918
  messages = last_assistant_for_display(session.messages, turn_id: turn_id, cwd: session.working_directory)
915
919
  end
920
+ session_json = { id: id, status: status, used_memory_names: Array(used), prompt_notes: Array(notes) }
916
921
  live_metrics = state && state["metrics"]
917
922
  # Without recent=1: a tab opened before this release (it replaces its
918
923
  # timing with the reply's). Remove this branch after the next release.
@@ -234,12 +234,15 @@ module Samagotchi
234
234
  # +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
235
235
  def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
236
236
  tool_id: nil, marks: {})
237
+ # A built-in reached through call_tool reloads as that built-in's
238
+ # row, with via (its saved params and label are the built-in's).
239
+ call = Tools::BuiltinCalls.unwrap_saved(call, output)
237
240
  name = call[:name].to_s
238
241
  params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
239
242
  part = { tool: name, params: params.to_s }
240
- # Its row fields (ToolRowFields: title, called_as, view, ref), built from the saved
243
+ # Its row fields (ToolRowFields: title, called_as, via, view, ref), built from the saved
241
244
  # call (never saved themselves), so older sessions get them too. The
242
- # saved call is rebuilt by BuiltinCalls, which sets called_as again.
245
+ # saved call is rebuilt by BuiltinCalls, which sets called_as and via again.
243
246
  part.merge!(ToolRowFields.for(name, call, cwd: cwd))
244
247
  part[:label] = label if label.is_a?(String) && !label.empty?
245
248
  unless output.nil?
@@ -40,6 +40,8 @@ export function newActivity() {
40
40
  // it in place of the params when there is one.
41
41
  // called_as: the name the model called the tool by when it was an
42
42
  // alias's ("bash" run as execute); the row says so.
43
+ // via: the tool it was reached through ("call_tool": a built-in the
44
+ // session's toolkit doesn't declare); the row says so.
43
45
  // view: the call's full arguments for the expanded row and the hovers
44
46
  // (the server's ToolView: an execute/task_create's command, its cwd).
45
47
  // ref: what the row traces to (the server's FileRef: {kind: "file",
@@ -48,7 +50,7 @@ export function newActivity() {
48
50
  // refs: a plugin tool's refs (the server's RowRefs: [{kind, target,
49
51
  // label?, actions}]): the first on the row's title, the others in its
50
52
  // menu (refs.js bindRowRefs).
51
- export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as"]);
53
+ export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as", "via"]);
52
54
  export const EVENT_FIELDS = Object.freeze(["view", "ref", "refs"]);
53
55
  const ROW_FIELDS = Object.freeze([...ACTIVITY_FIELDS, ...EVENT_FIELDS]);
54
56