samagotchi 0.66.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/docs/architecture.md +50 -28
  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 +71 -29
  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 +17 -3
  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/engine.rb +96 -191
  51. data/lib/samagotchi/engine_peers.rb +3 -0
  52. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  53. data/lib/samagotchi/guardrails/approval.rb +1 -1
  54. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  55. data/lib/samagotchi/guardrails/rules.rb +1 -1
  56. data/lib/samagotchi/guardrails/targets.rb +1 -1
  57. data/lib/samagotchi/hook_routes.rb +151 -0
  58. data/lib/samagotchi/hooks/registry.rb +2 -2
  59. data/lib/samagotchi/host_keys.rb +147 -0
  60. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  61. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  62. data/lib/samagotchi/llm/api_key.rb +23 -7
  63. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  64. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  65. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  66. data/lib/samagotchi/llm_context_override.rb +1 -0
  67. data/lib/samagotchi/peer_sources.rb +58 -0
  68. data/lib/samagotchi/plugin/api.rb +64 -155
  69. data/lib/samagotchi/plugin/loader.rb +4 -2
  70. data/lib/samagotchi/plugin/peer.rb +394 -0
  71. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  72. data/lib/samagotchi/plugin_tasks.rb +2 -2
  73. data/lib/samagotchi/row_refs.rb +1 -1
  74. data/lib/samagotchi/self_report.rb +5 -3
  75. data/lib/samagotchi/send_command.rb +21 -11
  76. data/lib/samagotchi/session_continue.rb +58 -15
  77. data/lib/samagotchi/session_inbox.rb +0 -13
  78. data/lib/samagotchi/tool_call_parser.rb +1 -1
  79. data/lib/samagotchi/toolkit_binding.rb +138 -0
  80. data/lib/samagotchi/tools/context_read.rb +14 -0
  81. data/lib/samagotchi/tools/execute.rb +12 -12
  82. data/lib/samagotchi/tools/registry.rb +1 -1
  83. data/lib/samagotchi/tools/task_create.rb +6 -6
  84. data/lib/samagotchi/tools/task_get.rb +13 -13
  85. data/lib/samagotchi/tools/task_list.rb +6 -6
  86. data/lib/samagotchi/tools/task_record.rb +52 -0
  87. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  88. data/lib/samagotchi/tools/task_stop.rb +6 -6
  89. data/lib/samagotchi/tools/task_wait.rb +12 -13
  90. data/lib/samagotchi/tools/tool_index.rb +454 -0
  91. data/lib/samagotchi/update_command.rb +9 -6
  92. data/lib/samagotchi/version.rb +1 -1
  93. data/lib/samagotchi/web/app.rb +16 -9
  94. data/lib/samagotchi/web/capabilities.rb +1 -1
  95. data/lib/samagotchi/web/public/activity.js +2 -2
  96. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  97. data/lib/samagotchi/web/public/app.js +31 -36
  98. data/lib/samagotchi/web/public/chains.js +1 -1
  99. data/lib/samagotchi/web/public/commands.js +2 -2
  100. data/lib/samagotchi/web/public/composer_size.js +2 -2
  101. data/lib/samagotchi/web/public/copy.js +1 -1
  102. data/lib/samagotchi/web/public/ctx.js +1 -1
  103. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  104. data/lib/samagotchi/web/public/families_view.js +1 -1
  105. data/lib/samagotchi/web/public/format.js +11 -7
  106. data/lib/samagotchi/web/public/images.js +25 -2
  107. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  108. data/lib/samagotchi/web/public/notify.js +6 -4
  109. data/lib/samagotchi/web/public/question_card.js +1 -1
  110. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  111. data/lib/samagotchi/web/public/session_state.js +14 -0
  112. data/lib/samagotchi/web/public/session_title.js +2 -3
  113. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  114. data/lib/samagotchi/web/public/stage_model.js +2 -2
  115. data/lib/samagotchi/web/public/strip.js +3 -3
  116. data/lib/samagotchi/web/public/task_stop.js +3 -2
  117. data/lib/samagotchi/web/public/turn_events.js +1 -1
  118. data/lib/samagotchi/web/public/turn_html.js +2 -2
  119. data/lib/samagotchi/web/public/turn_model.js +2 -2
  120. data/lib/samagotchi/web/public/turn_view.js +1 -1
  121. data/lib/samagotchi/web/server.rb +0 -4
  122. data/lib/samagotchi/worker.rb +11 -8
  123. data/lib/samagotchi/worker_inbound.rb +36 -4
  124. data/lib/samagotchi/worker_wakes.rb +67 -7
  125. metadata +14 -1
@@ -2,11 +2,12 @@
2
2
 
3
3
  # The mcp bundle (docs/plugins.md, The mcp bundle): tools from MCP servers.
4
4
  # Each server in config.yml runs as a child process (stdio only in v1) for
5
- # the session's life. Its tools aren't declared one by one: they are kept in
6
- # an index, and the model has two fixed tools, find_mcp_tools (a keyword
7
- # search that answers each match's inputSchema) and mcp_call (calls one as
8
- # <server>/<tool>). The request's tools never change with the servers', so
9
- # the prompt cache keeps.
5
+ # the session's life. Its tools aren't declared one by one: the bundle
6
+ # publishes them into chi's tool index (chi.tool_source, a group per
7
+ # server), and the model has the index's two fixed tools, find_tools (a
8
+ # keyword search that answers each match's inputSchema) and call_tool (calls
9
+ # one as <server>/<tool>, which the index hands back to #call here). The
10
+ # request's tools never change with the servers', so the prompt cache keeps.
10
11
  #
11
12
  # bundles:
12
13
  # mcp:
@@ -18,7 +19,7 @@
18
19
  # env: {DEBUG: "0"} # added to chi's environment
19
20
  # cwd: ~/scratch # default: where chi runs
20
21
  # tools: [echo, add] # optional: only these (globs work)
21
- # description: adds and echoes # optional: its line in find_mcp_tools
22
+ # description: adds and echoes # optional: its line in find_tools
22
23
  # timeout: 120 # optional: this server's per-call timeout
23
24
  # attach_image_paths: true # default: an image path in the text, in the
24
25
  # # temp dir or cwd, attaches it
@@ -40,7 +41,7 @@ require "json"
40
41
  require "open3"
41
42
  require "samagotchi/process_group"
42
43
  require "samagotchi/token_usage"
43
- require "samagotchi/tools/args"
44
+ require "samagotchi/tools/tool_index"
44
45
  require "samagotchi/tool_declarations"
45
46
  require "time"
46
47
  require "shellwords"
@@ -51,19 +52,12 @@ class Plugin
51
52
  CALL_TIMEOUT = 60
52
53
  STARTUP_TIMEOUT = 10
53
54
  DESCRIPTION_CHARS = 1024
54
- PREVIEW_CHARS = 60
55
55
  NAME_CHARS = 48
56
- # Matches a search answers, and names a miss suggests.
57
- FIND_RESULTS = 5
58
- CLOSEST = 3
59
- # A query word in a tool's name (or its server's) counts this many times
60
- # one in its description.
61
- NAME_WEIGHT = 3
62
56
  # A cached tool list older than this is refreshed in the background.
63
57
  CACHE_TTL = 24 * 60 * 60
64
58
  # Restarts of a server that exited, per session.
65
59
  MAX_RESTARTS = 3
66
- # The states whose server's tools mcp_call can reach (a cached or exited
60
+ # The states whose server's tools call_tool can reach (a cached or exited
67
61
  # server starts on the call).
68
62
  OFFERED = %i[running cached exited].freeze
69
63
  IMAGE_EXT = { "image/png" => "png", "image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp" }.freeze
@@ -263,7 +257,7 @@ class Plugin
263
257
 
264
258
  # One tool of a server in the index (the tools: filter applied): +name+
265
259
  # is the server's, +chi_name+ mcp_<server>_<tool> (what guardrail rules
266
- # match, and a name mcp_call takes too), +schema+ its inputSchema (an
260
+ # match, and a name call_tool takes too), +schema+ its inputSchema (an
267
261
  # empty object when it has none). +error+ is why it can't be called (a
268
262
  # bad schema), nil when it can.
269
263
  McpTool = Data.define(:server, :name, :chi_name, :description, :schema, :error)
@@ -297,6 +291,7 @@ class Plugin
297
291
  # index.
298
292
  @left_out = []
299
293
  @left_out_lock = Mutex.new
294
+ @publish = Mutex.new
300
295
  end
301
296
 
302
297
  # The servers' names, never @settings (the raw servers config, env values
@@ -312,13 +307,20 @@ class Plugin
312
307
  # in the background. Without a cache (the first run, a changed config)
313
308
  # the server starts in an init task (chi.init) that every UI shows; a
314
309
  # turn sent meanwhile waits for its tools. start: eager starts it with
315
- # every session. find_mcp_tools and mcp_call are declared once, when a
316
- # server is configured; nothing the servers do later changes them.
310
+ # every session. With a server configured the bundle is a tool source:
311
+ # chi declares find_tools and call_tool once, after the plugins load,
312
+ # from what #publish gave by then; nothing the servers do later changes
313
+ # them, only what a search answers.
317
314
  def register(chi)
318
315
  @chi = chi
319
316
  ctx = chi.ctx
320
317
  configs = @settings["servers"]
321
318
  configs = {} unless configs.is_a?(Hash)
319
+ unless configs.empty?
320
+ @source = chi.tool_source(label: "mcp", prefix: "mcp_", call: ->(tool, args, call_ctx) { run(tool, args, call_ctx) },
321
+ acts_as: ->(server, tool) { tool_name(server, tool) },
322
+ wait: ->(server) { wait_started(@servers.find { |s| s.name == server }) })
323
+ end
322
324
  @servers = configs.map do |name, config|
323
325
  server = Server.new(name: name.to_s, config: config.is_a?(Hash) ? config : {}, state: :starting, tools: [],
324
326
  restarts: 0, tokens: 0)
@@ -351,7 +353,7 @@ class Plugin
351
353
  end
352
354
  end
353
355
  end
354
- declare_tools(chi) unless @servers.empty?
356
+ publish
355
357
  chi.command "/mcp", "list the MCP servers, their state and their tools", anytime: true do |_args, command_ctx|
356
358
  command_ctx.card(title: "MCP servers", body: listing, id: "mcp-servers")
357
359
  nil
@@ -429,12 +431,14 @@ class Plugin
429
431
  def boot(server, ctx)
430
432
  client = server.service.value
431
433
  server.state = :running
434
+ publish
432
435
  ctx.log.info("mcp_server_started", server: server.name, pid: client.pid, tools: server.listed.size)
433
436
  count = server.listed.size
434
437
  "#{server.name} ready, #{count} tool#{"s" unless count == 1}"
435
438
  rescue StandardError => e
436
439
  server.state = :failed
437
440
  server.error = e.message
441
+ publish
438
442
  ctx.log.warn("mcp_server_failed", server: server.name, error: e.class.name, msg: e.message)
439
443
  raise Client::Error, "MCP server #{server.name} didn't start: #{e.message}; its calls fail until chi restarts"
440
444
  end
@@ -586,6 +590,7 @@ class Plugin
586
590
  end
587
591
  server.tools = tools.freeze
588
592
  say_clashes(ctx)
593
+ publish
589
594
  return if tokens == server.tokens
590
595
 
591
596
  server.tokens = tokens
@@ -620,7 +625,7 @@ class Plugin
620
625
  @servers.flat_map(&:tools).select { |t| t.chi_name == tool.chi_name && [t.server, t.name] != [tool.server, tool.name] }
621
626
  end
622
627
 
623
- # Why mcp_call refuses +tool+, nil when it can call it.
628
+ # Why call_tool refuses +tool+, nil when it can call it.
624
629
  def refusal(tool)
625
630
  return tool.error if tool.error
626
631
 
@@ -628,10 +633,10 @@ class Plugin
628
633
  "name clash with #{others.map { |t| "#{t.server}/#{t.name}" }.join(", ")} (#{tool.chi_name})" unless others.empty?
629
634
  end
630
635
 
631
- # The server's tools mcp_call can call (no bad schema, no name clash).
636
+ # The server's tools call_tool can call (no bad schema, no name clash).
632
637
  def offered(server) = server.tools.reject { |tool| refusal(tool) }
633
638
 
634
- # The marks a search and /mcp give a tool mcp_call refuses.
639
+ # The marks a search and /mcp give a tool call_tool refuses.
635
640
  def mark(tool)
636
641
  return "(bad schema)" if tool.error
637
642
 
@@ -645,62 +650,33 @@ class Plugin
645
650
  # flatter schema, so it is an upper bound there.
646
651
  # @raise [ArgumentError] a bad schema
647
652
  def definition_tokens(name, description, schema)
648
- spec_tokens(Samagotchi::Plugin::Api.tool_spec(name, description, schema: schema) { nil })
649
- end
650
-
651
- # A checked tool declaration's (Api.tool_spec) definition, in estimated
652
- # tokens.
653
- def spec_tokens(spec)
654
- function = Samagotchi::ToolDeclarations.chat_schemas([spec[:schema]]).first.slice(:name, :description, :parameters)
655
- Samagotchi::TokenUsage.estimate(JSON.generate({ type: "function", function: function }))
656
- end
657
-
658
- FIND_PARAMS = {
659
- query: { type: "string", required: true,
660
- description: "keywords for what the tool does (\"screenshot page\"); empty lists every tool's name" },
661
- server: { type: "string", description: "search only this server" }
662
- }.freeze
663
- CALL_DESCRIPTION = "Call an MCP server's tool that find_mcp_tools found."
664
- CALL_PARAMS = {
665
- tool: { type: "string", required: true, description: "the tool as <server>/<tool>" },
666
- args: { type: "object", description: "the arguments, per the inputSchema find_mcp_tools gave" }
667
- }.freeze
668
-
669
- # find_mcp_tools and mcp_call, the model's only MCP tools; their
670
- # definitions' estimated tokens are /mcp's.
671
- def declare_tools(chi)
672
- description = find_description
673
- chi.tool("find_mcp_tools", description, params: FIND_PARAMS,
674
- preview: ->(args) { [args["query"].to_s.strip, args["server"] && "in #{args["server"]}"].compact.join(" ") }) do |args, _ctx|
675
- find(args["query"].to_s, args["server"])
676
- end
677
- chi.tool("mcp_call", CALL_DESCRIPTION, params: CALL_PARAMS, label: "mcp", preview: ->(args) { call_preview(args) },
678
- targets: ->(args) { call_targets(args) }) do |args, ctx|
679
- mcp_call(args["tool"].to_s, args["args"], ctx)
680
- end
681
- @fixed_tokens = spec_tokens(Samagotchi::Plugin::Api.tool_spec("find_mcp_tools", description, params: FIND_PARAMS) { nil }) +
682
- spec_tokens(Samagotchi::Plugin::Api.tool_spec("mcp_call", CALL_DESCRIPTION, params: CALL_PARAMS) { nil })
683
- end
684
-
685
- # find_mcp_tools' description: how to use it, and one line per server
686
- # from what is known at load (a first run's server: its name, and its
687
- # description: if set). Never every tool: knowing the names, a model
688
- # searches less and guesses arguments.
689
- def find_description
690
- lines = @servers.map do |server|
691
- count = offered(server).size
692
- summary = server_summary(server)
693
- line = "- #{server.name}"
694
- line += ": #{summary}" if summary
695
- line += " (#{count} tool#{"s" unless count == 1})" unless server.tools.empty?
696
- line
653
+ Samagotchi::Tools::ToolIndex.spec_tokens(Samagotchi::Plugin::Api.tool_spec(name, description, schema: schema) { nil })
654
+ end
655
+
656
+ # Every server's tools into chi's index, a group each: its state (what a
657
+ # search's notes say), its summary line, and its tools with the marks
658
+ # and refusals of now (a clash with another server's tool too, so all of
659
+ # them each time). After each index and each state change a note reads;
660
+ # one at a time, so the last one published is the newest.
661
+ def publish
662
+ return unless @source
663
+
664
+ @publish.synchronize do
665
+ groups = @servers.map do |server|
666
+ state = { starting: :starting, failed: :failed }.fetch(server.state, :ready)
667
+ tools = Array(server.tools).map do |tool|
668
+ Samagotchi::Tools::ToolIndex::Tool.new(group: tool.server, name: tool.name, description: tool.description,
669
+ schema: tool.schema, acts_as: tool.chi_name, refusal: refusal(tool),
670
+ mark: mark(tool))
671
+ end
672
+ Samagotchi::Tools::ToolIndex::Group.new(name: server.name, summary: server_summary(server), state: state,
673
+ error: state == :failed ? server.error : nil, tools: tools)
674
+ end
675
+ @source.publish(groups)
697
676
  end
698
- "Search the tools of the MCP servers below by keywords. It answers up to #{FIND_RESULTS} tools, each with " \
699
- "its name (<server>/<tool>) and inputSchema; call one with mcp_call. An empty query lists every tool's " \
700
- "name.\nServers:\n#{lines.join("\n")}"
701
677
  end
702
678
 
703
- # The server's line: its description: from config, else the first
679
+ # The server's line in find_tools: its description: from config, else the first
704
680
  # sentence of its instructions, else nothing (its line has the tool
705
681
  # count). Never its tool names: a model that knows them calls them
706
682
  # blind, without a search (a smoke run's Splash did).
@@ -711,222 +687,25 @@ class Plugin
711
687
  server.info
712
688
  end
713
689
 
714
- # ── Search ────────────────────────────────────────────────────────────
715
-
716
- # find_mcp_tools: the best matches with their schemas, or with an empty
717
- # query every server's tool names.
718
- def find(query, only)
719
- servers = @servers
720
- if only && !only.strip.empty?
721
- servers = @servers.select { |server| server.name == only.strip }
722
- return "Error: no MCP server #{only.strip}. Servers: #{@servers.map(&:name).join(", ")}" if servers.empty?
723
- end
724
- return names_text(servers) if words(query).empty?
725
-
726
- matches = ranked(query, servers.flat_map(&:tools)).take(FIND_RESULTS)
727
- return "No MCP tool matches \"#{query.strip}\".\n\n#{names_text(servers)}" if matches.empty?
728
-
729
- blocks = matches.map { |tool| match_text(tool) }
730
- unsearched = servers.filter_map { |server| "#{server.name} #{note(server)}" if server.tools.empty? && note(server) }
731
- blocks << "Not searched: #{unsearched.join("; ")}." unless unsearched.empty?
732
- "Call one with mcp_call(tool: \"<server>/<tool>\", args: {…}).\n\n#{blocks.join("\n\n")}"
733
- end
690
+ # ── Calls ─────────────────────────────────────────────────────────────
734
691
 
735
- # One match: its name, why it can't be called (if so), its description
736
- # and schema.
737
- def match_text(tool)
738
- server = @servers.find { |s| s.name == tool.server }
739
- head = ["#{tool.server}/#{tool.name}", mark(tool), note(server)].compact.join(" ")
740
- refused = refusal(tool)
741
- return "#{head}: #{tool.description}\nIt can't be called: #{refused}" if refused
692
+ # A call the index looked up and checked (call_tool): +tool+ a
693
+ # ToolIndex::Tool, +args+ typed by its schema.
694
+ def run(tool, args, ctx)
695
+ server = @servers.find { |s| s.name == tool.group }
696
+ return "Error: no MCP server #{tool.group}" unless server
742
697
 
743
- "#{head}: #{tool.description}\ninputSchema: #{JSON.generate(tool.schema)}"
744
- end
745
-
746
- # What a search says about a server whose tools can't be called now.
747
- def note(server)
748
- case server.state
749
- when :failed then "(failed: #{server.error})"
750
- when :starting then "(starting, try again)"
751
- end
752
- end
753
-
754
- # Each server's tool names, one line each.
755
- def names_text(servers)
756
- lines = servers.map do |server|
757
- names = server.tools.map { |tool| [tool.name, mark(tool)].compact.join(" ") }
758
- head = [server.name, note(server)].compact.join(" ")
759
- names.empty? ? head : "#{head}: #{names.join(", ")}"
760
- end
761
- "MCP tools by server (call one as <server>/<tool>; search for its inputSchema):\n#{lines.join("\n")}"
762
- end
763
-
764
- # +tools+ that share a word with +query+, best first: a word scores its
765
- # IDF over +tools+ (a word most of them have counts little), times
766
- # NAME_WEIGHT in the tool's or its server's name. Ties keep the index's
767
- # order.
768
- def ranked(query, tools)
769
- wanted = words(query).uniq
770
- docs = tools.map { |tool| [words("#{tool.server} #{tool.name}"), words(tool.description)] }
771
- df = Hash.new(0)
772
- docs.each { |name, text| (name | text).each { |word| df[word] += 1 } }
773
- count = tools.size.to_f
774
- scored = tools.each_with_index.filter_map do |tool, i|
775
- name, text = docs[i]
776
- score = wanted.sum do |word|
777
- idf = Math.log(((count - df[word] + 0.5) / (df[word] + 0.5)) + 1)
778
- if name.include?(word) then NAME_WEIGHT * idf
779
- elsif text.include?(word) then idf
780
- else 0
781
- end
782
- end
783
- [tool, score, i] if score.positive?
784
- end
785
- scored.sort_by { |_, score, i| [-score, i] }.map(&:first)
786
- end
787
-
788
- # Lower-case words, split at camelCase too, a plural's s dropped.
789
- def words(text)
790
- text.to_s.gsub(/([a-z0-9])([A-Z])/, '\1 \2').downcase.scan(/[a-z0-9]+/).map do |word|
791
- word.length > 3 && word.end_with?("s") && !word.end_with?("ss") ? word.chomp("s") : word
792
- end
793
- end
794
-
795
- # ── mcp_call ──────────────────────────────────────────────────────────
796
-
797
- # A call of the tool +name+ names, with +args+ typed by its schema. A
798
- # failure the arguments may cause answers its inputSchema too. It fails
799
- # closed: a tool that isn't the one guardrails were given (#claimed) is
800
- # refused.
801
- def mcp_call(name, args, ctx)
802
- tool = lookup(name)
803
- return tool if tool.is_a?(String)
804
- unless claimed(name.strip)&.first == tool.chi_name
805
- return "Error: the MCP tools changed while the call waited for its server; call #{tool.server}/#{tool.name} again"
806
- end
807
-
808
- server = @servers.find { |s| s.name == tool.server }
809
- refused = refusal(tool)
810
- return "Error: #{tool.server}/#{tool.name} can't be called: #{refused}" if refused
811
-
812
- inner = inner_args(args, tool)
813
- return "Error: args must be an object.#{schema_note(tool)}" unless inner
814
-
815
- call(server, tool, inner, ctx)
816
- end
817
-
818
- # The McpTool +name+ names: <server>/<tool>, or mcp_<server>_<tool>
819
- # looked up by chi_name (never parsed: server names may hold _, and long
820
- # names are cut). A miss while a server is starting waits for it (its
821
- # tools may be the one; for a <server>/ name, that server's start only);
822
- # then a miss is the error text: a failed
823
- # server's error, else the closest tools.
824
- # @return [McpTool, String]
825
- def lookup(name, waited: false)
826
- name = name.strip
827
- tool = @servers.lazy.filter_map do |server|
828
- next unless name.start_with?("#{server.name}/")
829
-
830
- rest = name.delete_prefix("#{server.name}/")
831
- server.tools.find { |t| t.name == rest }
832
- end.first
833
- return tool if tool
834
-
835
- same = @servers.flat_map(&:tools).select { |t| t.chi_name == name }
836
- return same.first if same.size == 1
837
- if same.size > 1
838
- return "Error: #{name} names #{same.map { |t| "#{t.server}/#{t.name}" }.join(" and ")}; " \
839
- "call one as <server>/<tool>"
840
- end
841
-
842
- # A name with a configured server's <server>/ waits for that server
843
- # only; any other name (mcp_<server>_<tool>) for every one starting.
844
- named = @servers.select { |server| name.start_with?("#{server.name}/") }
845
- starting = (named.empty? ? @servers : named).select { |server| server.state == :starting }
846
- unless waited || starting.empty?
847
- starting.each { |server| wait_started(server) }
848
- return lookup(name, waited: true)
849
- end
850
-
851
- failed = @servers.find { |server| server.state == :failed && name.start_with?("#{server.name}/") }
852
- return "Error: MCP server #{failed.name} didn't start: #{failed.error}" if failed
853
-
854
- closest = ranked(name, @servers.flat_map(&:tools)).take(CLOSEST)
855
- return "Error: no MCP tool #{name}. Search with find_mcp_tools." if closest.empty?
856
-
857
- "Error: no MCP tool #{name}. Closest: #{closest.map { |t| "#{t.server}/#{t.name}" }.join(", ")}"
698
+ call(server, tool, args, ctx)
858
699
  end
859
700
 
860
701
  # Wait for a first-run server's start (its init task's); a failure is
861
702
  # its init card's.
862
703
  def wait_started(server)
863
- server.service.value
704
+ server&.service&.value
864
705
  rescue StandardError
865
706
  nil
866
707
  end
867
708
 
868
- # The inner arguments as a Hash typed by +tool+'s schema (a JSON text
869
- # parsed; untyped without a tool), nil when they aren't an object; none
870
- # is {}.
871
- def inner_args(args, tool)
872
- args = {} if args.nil? || (args.is_a?(String) && args.strip.empty?)
873
- args = Samagotchi::Tools::Args.coerce({ "args" => args }, { "properties" => { "args" => { "type" => "object" } } })["args"]
874
- return nil unless args.is_a?(Hash)
875
-
876
- Samagotchi::Tools::Args.coerce(args, tool&.schema)
877
- end
878
-
879
- # Appended to a failure the arguments may cause: the tool's inputSchema,
880
- # so the next call can fix them (a call guessed without a search).
881
- def schema_note(tool)
882
- "\n\n#{tool.server}/#{tool.name}'s inputSchema: #{JSON.generate(tool.schema)}"
883
- end
884
-
885
- # The activity row: <server>/<tool> and its arguments.
886
- def call_preview(args)
887
- tool = lookup_quietly(args["tool"].to_s)
888
- name = tool ? "#{tool.server}/#{tool.name}" : args["tool"].to_s.strip
889
- inner = tool && inner_args(args["args"], tool)
890
- inner ? [name, preview(inner)].reject(&:empty?).join(" ") : name
891
- end
892
-
893
- # What guardrail rules match an mcp_call: the tool it calls, by today's
894
- # mcp_<server>_<tool> (rules written for it still fire), its arguments,
895
- # and the question's label. A tool not indexed yet (its server still
896
- # starting) is named from what the model gave (#claimed), so a rule on
897
- # it fires before the call waits for the server. Nothing for a name
898
- # that can't be a tool (mcp_call refuses it).
899
- def call_targets(args)
900
- name = args["tool"].to_s.strip
901
- tool = lookup_quietly(name)
902
- acts_as, label = tool ? [tool.chi_name, "#{tool.server}: #{tool.name}"] : claimed(name)
903
- return {} unless acts_as
904
-
905
- { acts_as: acts_as, args: inner_args(args["args"], tool) || {}, label: label }.compact
906
- end
907
-
908
- # The mcp_<server>_<tool> and label +name+ stands for, from the name
909
- # alone: <server>/<tool> of a configured server (the one whose index has
910
- # the tool, else the first whose name it starts with), or an
911
- # mcp_<server>_<tool> as it is (no label). Nil for anything else.
912
- # @return [Array(String, String), Array(String, nil), nil]
913
- def claimed(name)
914
- servers = @servers.select { |server| name.start_with?("#{server.name}/") }
915
- server = servers.find { |s| s.tools.any? { |t| t.name == name.delete_prefix("#{s.name}/") } } || servers.first
916
- if server
917
- rest = name.delete_prefix("#{server.name}/")
918
- [tool_name(server.name, rest), "#{server.name}: #{rest}"]
919
- elsif name.start_with?("mcp_")
920
- [name, nil]
921
- end
922
- end
923
-
924
- # #lookup's tool, without waiting for a start: nil on a miss.
925
- def lookup_quietly(name)
926
- tool = lookup(name, waited: true)
927
- tool.is_a?(McpTool) ? tool : nil
928
- end
929
-
930
709
  # mcp_<server>_<tool> in the tool name rule: a-z, 0-9 and _, at most 48.
931
710
  def tool_name(server, tool)
932
711
  "mcp_#{server}_#{tool}".downcase.gsub(/[^a-z0-9_]+/, "_").squeeze("_")[0, NAME_CHARS].sub(/_+\z/, "")
@@ -938,15 +717,9 @@ class Plugin
938
717
  text.length > DESCRIPTION_CHARS ? "#{text[0, DESCRIPTION_CHARS - 1]}…" : text
939
718
  end
940
719
 
941
- def preview(args)
942
- line = args.map { |key, value| "#{key}=#{value.is_a?(String) ? value : JSON.generate(value)}" }.join(" ")
943
- line = line.gsub(/\s+/, " ")
944
- line.length > PREVIEW_CHARS ? "#{line[0, PREVIEW_CHARS - 1]}…" : line
945
- end
946
-
947
- # A tools/call of +tool+ (McpTool), as the model's tool result. A cached
720
+ # A tools/call of +tool+ (ToolIndex::Tool), as the model's tool result. A cached
948
721
  # server starts here, on its first call. The server's isError and a
949
- # JSON-RPC error answer the tool's inputSchema too (#schema_note).
722
+ # JSON-RPC error answer the tool's inputSchema too (ToolIndex.schema_note).
950
723
  def call(server, tool, args, ctx)
951
724
  return "Error: MCP server #{server.name} didn't start: #{server.error}" if server.state == :failed
952
725
 
@@ -962,7 +735,7 @@ class Plugin
962
735
  result = client.request("tools/call", { name: tool.name, arguments: args }, timeout: server.timeout,
963
736
  cancelled: -> { ctx.cancelled? })
964
737
  text, images = content(result, server, tool.name)
965
- return "Error: #{text.empty? ? "the tool failed" : text}#{schema_note(tool)}" if result["isError"]
738
+ return "Error: #{text.empty? ? "the tool failed" : text}#{Samagotchi::Tools::ToolIndex.schema_note(tool)}" if result["isError"]
966
739
 
967
740
  images.empty? ? text : Samagotchi::Plugin::ToolResult.new(text, images: images)
968
741
  rescue Client::Dead => e
@@ -970,7 +743,7 @@ class Plugin
970
743
  rescue Client::Timeout, Client::Cancelled, Samagotchi::Plugin::Service::Stopped => e
971
744
  "Error: #{e.message}"
972
745
  rescue Client::Error => e
973
- "Error: #{e.message}#{schema_note(tool)}"
746
+ "Error: #{e.message}#{Samagotchi::Tools::ToolIndex.schema_note(tool)}"
974
747
  end
975
748
 
976
749
  # Start a cached server for a call (#spawn indexes the live list). A
@@ -981,6 +754,7 @@ class Plugin
981
754
  client = server.service.value
982
755
  started = server.state == :cached
983
756
  server.state = :running
757
+ publish
984
758
  ctx.log.info("mcp_server_started", server: server.name, pid: client.pid, tools: server.listed.size, lazy: true) if started
985
759
  client
986
760
  rescue Client::Cancelled => e
@@ -992,6 +766,7 @@ class Plugin
992
766
 
993
767
  server.state = :failed
994
768
  server.error = e.message
769
+ publish
995
770
  ctx.log.warn("mcp_server_failed", server: server.name, error: e.class.name, msg: e.message, lazy: true)
996
771
  ctx.notify("MCP server #{server.name} didn't start: #{e.message}; its calls fail until chi restarts", level: :warn)
997
772
  "Error: MCP server #{server.name} didn't start: #{e.message}"
@@ -1020,6 +795,7 @@ class Plugin
1020
795
  return "Error: MCP server #{server.name} didn't restart: #{e.message}"
1021
796
  end
1022
797
  server.state = :running
798
+ publish
1023
799
  ctx.log.info("mcp_server_restarted", server: server.name, pid: client.pid, restarts: server.restarts)
1024
800
  client
1025
801
  end
@@ -1122,6 +898,7 @@ class Plugin
1122
898
 
1123
899
  server.state = :exited
1124
900
  server.error = reason
901
+ publish
1125
902
  ctx.log.warn("mcp_server_exited", server: server.name, msg: reason, restarts: server.restarts)
1126
903
  after = if server.restarts < MAX_RESTARTS
1127
904
  "it restarts on its next call"
@@ -1140,11 +917,22 @@ class Plugin
1140
917
  tools = server.tools.sort_by(&:name).map { |t| ["- `#{t.server}/#{t.name}`", mark(t)].compact.join(" ") }
1141
918
  tools.empty? ? head : "#{head}\n#{tools.join("\n")}"
1142
919
  end
920
+ [*servers, totals_text].join("\n\n")
921
+ end
922
+
923
+ # /mcp's last line: what every request carries, and the servers' tools'
924
+ # tokens. When another tool took both names (chi's tool index left its
925
+ # tools out: a load warning), that the model can't reach them.
926
+ def totals_text
927
+ if @source.declared_names.empty?
928
+ return "The search and call tools aren't declared in this session (other tools have their names; see the " \
929
+ "load warnings), so the model can't reach the tools above."
930
+ end
931
+
1143
932
  total = @servers.sum { |server| OFFERED.include?(server.state) ? server.tokens : 0 }
1144
- [*servers, "Every request carries find_mcp_tools and mcp_call: ~#{thousands(@fixed_tokens.to_i)} tokens. " \
1145
- "The tools above, ~#{thousands(total)} tokens, reach the model only in a search's answer " \
1146
- "(estimated: their JSON as the chat API gets it, ÷ #{format("%g", Samagotchi::TokenUsage::CHARS_PER_TOKEN)})."]
1147
- .join("\n\n")
933
+ "Every request carries #{@source.declared_names.join(" and ")}: ~#{thousands(@source.declared_tokens)} tokens. " \
934
+ "The tools above, ~#{thousands(total)} tokens, reach the model only in a search's answer " \
935
+ "(estimated: their JSON as the chat API gets it, ÷ #{format("%g", Samagotchi::TokenUsage::CHARS_PER_TOKEN)})."
1148
936
  end
1149
937
 
1150
938
  def state_text(server)
@@ -99,7 +99,7 @@ bundles:
99
99
  after: 50 # tool calls in one turn with no answer before the first check
100
100
  every: 50 # then again every N more
101
101
  mode: ask # ask (a card) | nudge (nudge the model by itself) | notify; message:, ignore_tools: [...]
102
- mcp: # the model searches the tools (find_mcp_tools) and calls one (mcp_call, <server>/<tool>); /mcp lists them
102
+ mcp: # the model searches the tools (find_tools) and calls one (call_tool, <server>/<tool>); /mcp lists them
103
103
  timeout: 60 # per call, seconds; startup_timeout: 10
104
104
  servers:
105
105
  files: # server name
@@ -107,15 +107,19 @@ bundles:
107
107
  env: {NODE_OPTIONS: "--no-warnings"} # optional, added to chi's env
108
108
  cwd: ~/scratch # optional; default the session's cwd
109
109
  tools: [read_*, list_directory] # optional filter (globs)
110
- description: files in ~/scratch # optional: the server's line in find_mcp_tools (default: its instructions' first sentence)
110
+ description: files in ~/scratch # optional: the server's line in find_tools (default: its instructions' first sentence)
111
111
  attach_image_paths: true # default: an answer that is only an image's path (temp dir/cwd) is attached as a picture
112
112
  start: lazy # default: tools from the saved tools/list, the server starts on the first call; eager: with every session
113
113
  ```
114
114
 
115
- A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cover every MCP tool: an `mcp_call` acts as `mcp_<server>_<tool>`, so `tool: "mcp_github_*"` covers one server's (a `find_mcp_tools` search is never asked about); see `docs/guardrails.md`. A server's optional `description:` is its line in `find_mcp_tools`' description. The mcp bundle saves each server's tool list in `$XDG_STATE_HOME/samagotchi/plugins/mcp/tools-<server>.json` (keyed by a digest of command/env/cwd): a changed server config is picked up by the next session start, which shows "Starting MCP server x (config changed, …)".
115
+ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cover every MCP tool: a `call_tool` acts as `mcp_<server>_<tool>`, so `tool: "mcp_github_*"` covers one server's (a `find_tools` search is never asked about); see `docs/guardrails.md`. A server's optional `description:` is its line in `find_tools`' description. The mcp bundle saves each server's tool list in `$XDG_STATE_HOME/samagotchi/plugins/mcp/tools-<server>.json` (keyed by a digest of command/env/cwd): a changed server config is picked up by the next session start, which shows "Starting MCP server x (config changed, …)".
116
116
 
117
117
  **Preservation rule**: `ConfigFile.write_default_model!` and `ConfigFile.write_model_alias!` (`/model --default`, `/model --alias`) change one key on its own line of the text (`ConfigTextEdit`: the user's comments and layout stay; a new key goes at the end of its section, a missing section at the end of the file), check that the result parses to the old data plus that key (else they dump the whole file from it), and write through `AtomicFile` (a unique temp file renamed over config.yml, through a symlink, keeping its mode). Never overwrite the file with only scalar keys — that would clobber `hooks:` / `model_aliases:` / `hosts:` / `recap:` / `guardrails:` / `bundles:`.
118
118
 
119
+ ## Host API keys
120
+
121
+ A key never goes into config.yml: `api_key_env: VAR` names it. chi reads `VAR` from the environment, else (macOS) the login Keychain item with service `chi`, account `VAR` (`HostKeys`; `SAMAGOTCHI_KEYCHAIN=off` skips it). Don't store or read a key yourself: tell the user to run `security add-generic-password -s chi -a VAR -w` (it prompts for the key). `chi self` shows `VAR (set|keychain|unset)`; `chi desktop status` which keys the desktop helper gets.
122
+
119
123
  ## Workflow for any config edit
120
124
 
121
125
  1. **Read** the current file via `read` tool (or `ConfigFile.global_path`). If `File.file?` false, start from `{}`.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.66.0
3
+ version: 0.67.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
- config_modification_protocol.md: sha256:301895777916dd998d8946aaa2e8f3a6e7f2b18eaf9362eef11471ae602d54ab
8
+ self_map.md: sha256:9528cc4c7d512a3fdfb103bf966a0359acad11c218666512d628a8d00378aaab
9
+ config_modification_protocol.md: sha256:c7decf27af999066195f83e71222a6ae294f13174ced9664a21cd1dabf8b854a
10
10
  memory_guide.md: sha256:c48eea744db69e4f3e9bb415e9a03a8c78bbcaa1205ad3b1fb157a079f92d22e
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -50,7 +50,7 @@
50
50
  commands (`anytime:` ones run mid-turn), model tools (they can return images I see), hooks,
51
51
  cards, side answers (`ask_model`),
52
52
  child sessions and services (e.g. MCP server processes). Shipped: `btw` (`/btw` side question),
53
- `mcp` (MCP server tools, searched with find_mcp_tools and called with mcp_call; a screenshot comes as a picture), `guardrails` (rules), `known-names` (typo guard), `source-links`
53
+ `mcp` (MCP server tools, searched with find_tools and called with call_tool; a screenshot comes as a picture), `guardrails` (rules), `known-names` (typo guard), `source-links`
54
54
  (turns source refs like JIRA-123 in an answer into links in the web, and a one-line note), `loop-guard`
55
55
  (denies a repeated tool call with the same result, stops the turn after a few, and cuts thinking that
56
56
  repeats itself, asking me again), `check-in` (after N tool