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
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require_relative "../toolkit"
4
4
  require_relative "../log"
5
5
  require_relative "../hooks/registry"
6
- require_relative "../tools/args"
6
+ require_relative "../commands/registry"
7
7
  require_relative "service"
8
8
  require_relative "tool_result"
9
+ require_relative "tool_spec"
9
10
 
10
11
  module Samagotchi
11
12
  module Plugin
@@ -14,8 +15,8 @@ module Samagotchi
14
15
  # #register returns, so a plugin that raises halfway adds nothing.
15
16
  class Api
16
17
  COMMAND_NAME = %r{\A/[a-z][a-z0-9_-]{0,31}\z}
17
- TOOL_NAME = /\A[a-z][a-z0-9_]{0,47}\z/
18
- PARAM_NAME = /\A[a-z_][a-z0-9_]*\z/i
18
+ ANNOTATION_LABEL_MAX = 40
19
+ ANNOTATIONS_MAX = 5
19
20
 
20
21
  # @param bundle [String]
21
22
  # @param label [String] "<file> (bundle <name>)": what hooks and
@@ -29,9 +30,13 @@ module Samagotchi
29
30
  @context = context
30
31
  @hooks = []
31
32
  @commands = []
33
+ @annotations = []
32
34
  @tools = []
33
35
  @services = []
34
36
  @inits = []
37
+ @toolkits = []
38
+ @peer_sources = []
39
+ @tool_source = nil
35
40
  @committed = false
36
41
  end
37
42
 
@@ -63,6 +68,35 @@ module Samagotchi
63
68
  nil
64
69
  end
65
70
 
71
+ # A pill in the web's selection bar: it prepares +command+ (a command
72
+ # line) with +template+ filled from the selected text on the lines
73
+ # under it ({quote}: the labelled quote block Annotate makes, {text}:
74
+ # the selection alone, {source}: answer, thinking, step, tool or
75
+ # user), and fills the composer with it, or sends it at once with
76
+ # +send+ (docs/plugins.md). Web only.
77
+ # @param label [String] the pill's text, 1–40 characters
78
+ # @param command [String] "/name" or "/name words": a command this
79
+ # plugin registers (checked once #register returns, so the order
80
+ # doesn't matter; another's is a load error)
81
+ # @param template [String] what goes under the command line
82
+ # @param send [Boolean] send at once instead of filling the composer
83
+ def annotation(label, command:, template: "{quote}", send: false)
84
+ label = label.to_s.strip
85
+ unless label.length.between?(1, ANNOTATION_LABEL_MAX)
86
+ raise ArgumentError, "annotation label #{label.inspect} must be 1 to #{ANNOTATION_LABEL_MAX} characters"
87
+ end
88
+
89
+ line = command.to_s.strip
90
+ name = line.split(/\s/, 2).first.to_s
91
+ raise ArgumentError, "annotation #{label}: command #{line.inspect} must start with a /name" unless name.match?(COMMAND_NAME)
92
+ raise ArgumentError, "annotation #{label}: template must be a non-blank String" unless template.is_a?(String) && !template.strip.empty?
93
+ raise ArgumentError, "at most #{ANNOTATIONS_MAX} annotations per plugin" if @annotations.size >= ANNOTATIONS_MAX
94
+
95
+ @annotations << { name: name, annotation: Commands::Registry::Annotation.new(label: label, line: line, template: template,
96
+ sends: send ? true : false) }
97
+ nil
98
+ end
99
+
66
100
  # A tool the model can call: the block gets the call's arguments (a
67
101
  # frozen Hash, string keys, typed by the schema: see Tools::Args) and
68
102
  # the Context, and returns the result text ("Error: …" marks a
@@ -83,11 +117,11 @@ module Samagotchi
83
117
  # a list of {kind:, target:, label:, actions:} (RowRefs, Ref). Run
84
118
  # once per call on the tool path, so it must be cheap (no I/O)
85
119
  def tool(name, description, **, &)
86
- spec = Api.tool_spec(name, description, **, &)
87
- if (taken = @registries.tools[spec[:name]])
88
- raise ArgumentError, "tool #{spec[:name]} is already registered (#{taken.source})"
120
+ spec = ToolSpec.build(name, description, **, &)
121
+ if (taken = @registries.tools[spec.name])
122
+ raise ArgumentError, "tool #{spec.name} is already registered (#{taken.source})"
89
123
  end
90
- raise ArgumentError, "tool #{spec[:name]} is registered twice" if @tools.any? { |tool| tool[:name] == spec[:name] }
124
+ raise ArgumentError, "tool #{spec.name} is registered twice" if @tools.any? { |tool| tool.name == spec.name }
91
125
 
92
126
  @tools << spec
93
127
  nil
@@ -111,7 +145,7 @@ module Samagotchi
111
145
  yield set
112
146
  if (stage = @registries.stage_tools)
113
147
  stage.call(@bundle, set.specs, @context)
114
- elsif Api.apply_tools(@registries.tools, @bundle, set.specs, @context)[:changed]
148
+ elsif ToolSpec.apply(@registries.tools, @bundle, set.specs, @context)[:changed]
115
149
  tools_changed!
116
150
  end
117
151
  nil
@@ -119,7 +153,7 @@ module Samagotchi
119
153
 
120
154
  # What #replace_tools' block declares tools on.
121
155
  class ToolSet
122
- # @return [Array<Hash>]
156
+ # @return [Array<ToolSpec>]
123
157
  attr_reader :specs
124
158
 
125
159
  def initialize
@@ -128,8 +162,8 @@ module Samagotchi
128
162
 
129
163
  # As Api#tool.
130
164
  def tool(name, description, **, &)
131
- spec = Api.tool_spec(name, description, **, &)
132
- raise ArgumentError, "tool #{spec[:name]} is registered twice" if @specs.any? { |s| s[:name] == spec[:name] }
165
+ spec = ToolSpec.build(name, description, **, &)
166
+ raise ArgumentError, "tool #{spec.name} is registered twice" if @specs.any? { |s| s.name == spec.name }
133
167
 
134
168
  @specs << spec
135
169
  nil
@@ -215,16 +249,88 @@ module Samagotchi
215
249
  nil
216
250
  end
217
251
 
218
- # Register what was staged. Called by Loader after #register.
252
+ # Publish tools into the session's tool index instead of declaring
253
+ # them one by one (docs/plugins.md, "Publishing tools to the index"):
254
+ # the model searches them with find_tools and calls one with
255
+ # call_tool (<group>/<tool>), which chi declares once, after the
256
+ # plugins load, when a source registered. Returns the source: its
257
+ # #publish(groups) (ToolIndex::Group values, each with its
258
+ # ToolIndex::Tool values) swaps in the plugin's whole list, from any
259
+ # thread, at any time, and changes only what searches answer.
260
+ # @param label [String] the call tool's row label (default: the bundle)
261
+ # @param call [#call] (tool, args, ctx) → the call's result, as a
262
+ # tool's block returns it; +tool+ a ToolIndex::Tool the index looked
263
+ # up and checked, +args+ typed by its schema
264
+ # @param acts_as [#call] (group, tool) → the name guardrail rules
265
+ # match for a tool from its names alone (one not published yet)
266
+ # @param wait [#call, nil] (group) waits for a :starting group's start
267
+ # @param prefix [String, nil] a bare name with it is taken as an
268
+ # acts_as as given
269
+ # @return [Tools::ToolIndex::Source]
270
+ def tool_source(call:, acts_as:, label: @bundle, wait: nil, prefix: nil)
271
+ raise ArgumentError, "this chi has no tool index (plugins: false)" unless @registries.tool_index
272
+ raise ArgumentError, "tool_source is declared twice" if @tool_source
273
+
274
+ @tool_source = @registries.tool_index.source(label: label, call: call, acts_as: acts_as, wait: wait,
275
+ prefix: prefix, context: @context)
276
+ end
277
+
278
+ TOOLKIT_NAME = /\A[a-z][a-z0-9_-]{0,31}\z/
279
+
280
+ # A named toolkit sessions can be put on (/toolkit NAME, chi --toolkit,
281
+ # toolkit.default, ctx.use_toolkit): +base+'s tools (a toolkit chi or
282
+ # a bundle has) with +add+ declared and +remove+ left out. A name chi
283
+ # (full, lean, child) or another bundle has is a load error; a base
284
+ # that doesn't resolve leaves the sessions on it with every tool
285
+ # (Toolkit#resolve).
286
+ def toolkit(name, base:, add: [], remove: [])
287
+ name = name.to_s
288
+ raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
289
+ raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
290
+ if Toolkit::BUILTIN_KITS.key?(name)
291
+ raise ArgumentError, "toolkit #{name} is chi's own"
292
+ elsif (taken = @registries.toolkits.find { |kit| kit.name == name })
293
+ raise ArgumentError, "toolkit #{name} is already declared (#{taken.source})"
294
+ end
295
+ raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
296
+
297
+ @toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, source: @bundle)
298
+ nil
299
+ end
300
+
301
+ # A context source that holds a peer agent's answers (the peer
302
+ # bundles' claude-review, codex-review): +label+ is the peer's name
303
+ # ("Codex", PeerSources::LABEL), said in chi's own lines. Such a
304
+ # source, when it is a pushed one of the session's own, wakes on
305
+ # context.peer_turns rather than the 10-minute window, with the peer
306
+ # line (docs/context.md, Waking). Declared here, in-process, never by
307
+ # a source file; gone with the bundle.
308
+ def peer_source(name, label:)
309
+ raise ArgumentError, "this chi has no peer sources (plugins: false)" unless @registries.peer_sources
310
+
311
+ name = @registries.peer_sources.check!(name, label: label, bundle: @bundle)
312
+ raise ArgumentError, "peer source #{name} is declared twice" if @peer_sources.any? { |item| item[:name] == name }
313
+
314
+ @peer_sources << { name: name, label: label.to_s }
315
+ nil
316
+ end
317
+
318
+ # Register what was staged. Called by Loader after #register. What
319
+ # can only be checked now (an annotation's command) is checked before
320
+ # anything is registered: a raise here leaves the registries as they were.
219
321
  def commit!
322
+ check_annotations!
323
+ @peer_sources.each { |item| @registries.peer_sources.check!(item[:name], label: item[:label], bundle: @bundle) }
220
324
  context = @context
221
325
  @commands.each do |cmd|
222
326
  block = cmd[:block]
223
- @registries.commands.register(cmd[:name], cmd[:description], anytime: cmd[:anytime], source: @bundle) do |args|
327
+ annotations = @annotations.select { |item| item[:name] == cmd[:name] }.map { |item| item[:annotation] }
328
+ @registries.commands.register(cmd[:name], cmd[:description], anytime: cmd[:anytime], source: @bundle,
329
+ annotations: annotations) do |args|
224
330
  block.call(args, context)
225
331
  end
226
332
  end
227
- @tools.each { |tool| @registries.tools.register(tool[:name], source: @bundle, **Api.entry_fields(tool, context)) }
333
+ @tools.each { |tool| @registries.tools.register(tool.name, source: @bundle, **tool.entry_fields(context)) }
228
334
  @hooks.each do |hook|
229
335
  block = hook[:block]
230
336
  # Logged only (echo: false): a turn's live region is on screen.
@@ -243,156 +349,32 @@ module Samagotchi
243
349
  block.call(context)
244
350
  end
245
351
  end
352
+ @registries.toolkits&.concat(@toolkits)
353
+ @registries.tool_index.add(@tool_source) if @tool_source
354
+ @peer_sources.each { |item| @registries.peer_sources.declare(item[:name], label: item[:label], bundle: @bundle) }
246
355
  @committed = true
247
356
  end
248
357
 
249
358
  # @return [Hash] how many of each it registered (for the log)
250
- def counts = { commands: @commands.size, tools: @tools.size, hooks: @hooks.size, services: @services.size,
251
- inits: @inits.size }
252
-
253
- # A checked tool declaration (#tool's arguments: Api#tool and
254
- # ToolSet#tool pass theirs on, so a new keyword is added here).
255
- # @return [Hash] {name:, schema:, label:, preview:, targets:, refs:, block:}
256
- def self.tool_spec(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, refs: nil,
257
- &block)
258
- raise ArgumentError, "tool #{name.inspect} needs a block" unless block
259
-
260
- name = name.to_s
261
- raise ArgumentError, "tool name #{name.inspect} must be a-z, 0-9 and _ (at most 48)" unless name.match?(TOOL_NAME)
262
- raise ArgumentError, "tool #{name}: preview must respond to #call" if preview && !preview.respond_to?(:call)
263
- raise ArgumentError, "tool #{name}: targets must respond to #call" if targets && !targets.respond_to?(:call)
264
- raise ArgumentError, "tool #{name}: refs must respond to #call" if refs && !refs.respond_to?(:call)
265
-
266
- parameters = schema ? schema_parameters(name, schema) : params_schema(name, params)
267
- { name: name, schema: { name: name, description: description.to_s, parameters: parameters },
268
- label: label&.to_s, preview: preview, targets: targets, refs: refs, block: block }
269
- end
359
+ def counts = { commands: @commands.size, annotations: @annotations.size, tools: @tools.size, hooks: @hooks.size,
360
+ services: @services.size, inits: @inits.size, toolkits: @toolkits.size,
361
+ tool_sources: @tool_source ? 1 : 0, peer_sources: @peer_sources.size }
270
362
 
271
- # Make +bundle+'s tools in +registry+ the +specs+: its tools not in
272
- # them go; a new one, or one whose schema or label changed, is
273
- # registered (a changed one again, in its place: Tools::Registry
274
- # orders bundle tools by bundle and name); an unchanged one is kept
275
- # as it is, but for its block, preview:, targets: and refs:, swapped
276
- # in place: none of them reaches the prompt, so not a change (that
277
- # would build the prompts again). A name
278
- # another source has is left out. Called on the turn thread
279
- # (Engine#apply_staged_tools!).
280
- # @return [Hash] {changed: Boolean, skipped: [String] (why)}
281
- def self.apply_tools(registry, bundle, specs, context)
282
- wanted = specs.to_h { |spec| [spec[:name], spec] }
283
- changed = false
284
- registry.entries.each do |entry|
285
- next unless entry.source == bundle
286
-
287
- if (spec = wanted[entry.name]) && spec[:schema] == entry.schema && spec[:label] == entry.label
288
- entry_fields(spec, context).slice(:handler, :preview, :targets, :refs).each { |key, value| entry[key] = value }
289
- next
290
- end
363
+ # Each annotation's command is one #command staged.
364
+ def check_annotations!
365
+ names = @commands.map { |cmd| cmd[:name] }
366
+ @annotations.each do |item|
367
+ next if names.include?(item[:name])
291
368
 
292
- registry.unregister(entry.name)
293
- changed = true
369
+ raise ArgumentError, "annotation #{item[:annotation].label}: #{item[:name]} is not a command this plugin registers"
294
370
  end
295
- skipped = []
296
- specs.each do |spec|
297
- if (taken = registry[spec[:name]])
298
- skipped << "tool #{spec[:name]} is already registered (#{taken.source})" unless taken.source == bundle
299
- next
300
- end
301
-
302
- registry.register(spec[:name], source: bundle, **entry_fields(spec, context))
303
- changed = true
304
- end
305
- { changed: changed, skipped: skipped }
306
- end
307
-
308
- # Registry#register's keywords for a tool declaration: its block
309
- # wrapped as a handler (typed args, the Context), preview, targets, refs.
310
- def self.entry_fields(spec, context)
311
- block = spec[:block]
312
- parameters = spec[:schema][:parameters]
313
- preview = per_call(spec[:preview], parameters)
314
- {
315
- schema: spec[:schema], label: spec[:label],
316
- handler: lambda { |call, _kctx|
317
- result = block.call(Api.args_of(call, parameters), context)
318
- Api.result_text(result)
319
- },
320
- preview: preview && ->(call) { preview.call(call)&.to_s },
321
- targets: per_call(spec[:targets], parameters),
322
- refs: per_call(spec[:refs], parameters)
323
- }
324
- end
325
-
326
- # A plugin's args → value lambda (+fn+) as call → value: it gets the
327
- # call's typed args (Api.args_of), as the tool's block does. nil
328
- # without one.
329
- def self.per_call(fn, parameters)
330
- fn && ->(call) { fn.call(Api.args_of(call, parameters)) }
331
- end
332
-
333
- # A tool's result as the model reads it: a String (a ToolResult too,
334
- # with its images) as it is, a Hash or Array as JSON (its #to_s is
335
- # Hash#inspect, which changes with the Ruby version), else #to_s.
336
- def self.result_text(result)
337
- case result
338
- when String then result
339
- when Hash, Array then JSON.generate(result)
340
- else result.to_s
341
- end
342
- rescue JSON::GeneratorError
343
- result.to_s
344
- end
345
-
346
- # A tool call's arguments as a plugin sees them: the parsers' args:
347
- # (a call built without one, in a spec say: the call without its
348
- # name), string keys, typed by +parameters+, frozen.
349
- def self.args_of(call, parameters = nil)
350
- given = call[:args].is_a?(Hash) ? call[:args] : call.reject { |key, _| %i[name args].include?(key) }
351
- Tools::Args.coerce(given, parameters).freeze
352
371
  end
372
+ private :check_annotations!
353
373
 
354
- # A JSON Schema with symbol keys (property names included), as
355
- # ToolDeclarations::TOOL_SCHEMAS has them.
356
- def self.symbolize(value)
357
- case value
358
- when Hash then value.to_h { |key, item| [key.to_sym, symbolize(item)] }
359
- when Array then value.map { |item| symbolize(item) }
360
- else value
361
- end
362
- end
363
-
364
- # The parameters object from params: (name => property spec).
365
- def self.params_schema(name, params)
366
- raise ArgumentError, "tool #{name}: params must be a Hash" unless params.is_a?(Hash)
367
-
368
- required = []
369
- properties = params.each_with_object({}) do |(param, spec), acc|
370
- param = param.to_s
371
- raise ArgumentError, "tool #{name}: parameter name #{param.inspect} is not a plain word" unless param.match?(PARAM_NAME)
372
- raise ArgumentError, "tool #{name}: parameter #{param} must be a Hash {type:, description:}" unless spec.is_a?(Hash)
373
-
374
- spec = Api.symbolize(spec)
375
- required << param if spec.delete(:required) == true
376
- acc[param.to_sym] = { type: (spec.delete(:type) || "string").to_s, description: spec.delete(:description).to_s }.merge(spec)
377
- end
378
- { type: "object", properties: properties, required: required }
379
- end
380
- private_class_method :params_schema
381
-
382
- # The parameters object from schema: (a JSON Schema object).
383
- def self.schema_parameters(name, schema)
384
- raise ArgumentError, "tool #{name}: schema must be a Hash" unless schema.is_a?(Hash)
385
-
386
- schema = Api.symbolize(schema)
387
- raise ArgumentError, "tool #{name}: schema must be {type: \"object\", properties: {…}}" unless schema.fetch(:type, "object").to_s == "object"
388
-
389
- properties = schema[:properties] || {}
390
- raise ArgumentError, "tool #{name}: schema properties must be a Hash" unless properties.is_a?(Hash)
391
-
392
- { type: "object", properties: properties, required: Array(schema[:required]).map(&:to_s) }
393
- .merge(schema.except(:type, :properties, :required))
394
- end
395
- private_class_method :schema_parameters
374
+ # A checked tool declaration (ToolSpec.build), for bundles: the mcp
375
+ # bundle's token estimate calls it, installed ones included.
376
+ # @return [ToolSpec]
377
+ def self.tool_spec(...) = ToolSpec.build(...)
396
378
  end
397
379
  end
398
380
  end
@@ -29,9 +29,13 @@ module Samagotchi
29
29
  # (reason, label), each true when it acted on a running turn.
30
30
  # +frontend+ returns the Engine's interface (:repl, :worker,
31
31
  # :non_interactive), which ctx.frontend names for plugins.
32
+ # +toolkit+ returns the session's toolkit name, +declared_tools+ the
33
+ # tool names its next turn declares, +use_toolkit+ takes a name and
34
+ # returns whether it staged it (Engine#use_toolkit).
32
35
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
33
36
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
34
- :steer, :stop_turn, :stop_generation, :frontend, keyword_init: true)
37
+ :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit,
38
+ keyword_init: true)
35
39
 
36
40
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
37
41
  # sent nothing usable. The message says why, for the user.
@@ -109,6 +113,23 @@ module Samagotchi
109
113
  # current_model_only names it
110
114
  def model_key = @host.model_key&.call
111
115
 
116
+ # @return [String, nil] the name of the session's toolkit ("full" for
117
+ # one from before toolkits; nil with no session)
118
+ def toolkit = @host.toolkit&.call
119
+
120
+ # @return [Array<String>] the tools the session's next turn declares
121
+ # (its toolkit resolved: a kit name says less than this)
122
+ def declared_tools = Array(@host.declared_tools&.call)
123
+
124
+ # Put the session on toolkit +name+ (a plugin's chi.toolkit, or
125
+ # chi's): only before the session's first model reply and never while
126
+ # a turn runs, since the declared tools start every request; it then
127
+ # applies at the next turn's start, and the user is told. Otherwise
128
+ # nothing changes and the plugin can say to run /toolkit NAME.
129
+ # @return [Boolean] whether it was staged
130
+ # @raise [ArgumentError] a toolkit no one declared
131
+ def use_toolkit(name) = !!@host.use_toolkit&.call(name.to_s)
132
+
112
133
  # Whether the session is a `chi scratch` one (deleted when it ends).
113
134
  def scratch? = !!@host.scratch&.call
114
135
 
@@ -16,9 +16,12 @@ module Samagotchi
16
16
  # (the Engine's system prompts); +services+ (Plugin::Services) is what
17
17
  # Engine#shutdown stops; +stage_tools+ is (bundle, specs, context),
18
18
  # chi.replace_tools' set, which the Engine applies on its turn thread;
19
- # +init+ adds a chi.init task (Engine#add_init_task).
19
+ # +init+ adds a chi.init task (Engine#add_init_task); +toolkits+ is the
20
+ # Engine's list of the plugins' toolkits (chi.toolkit: Toolkit::Kit);
21
+ # +tool_index+ the Engine's Tools::ToolIndex (chi.tool_source);
22
+ # +peer_sources+ the Engine's PeerSources (chi.peer_source).
20
23
  Registries = Struct.new(:commands, :tools, :hooks, :context_for, :tools_changed, :services, :stage_tools, :init,
21
- keyword_init: true)
24
+ :toolkits, :tool_index, :peer_sources, keyword_init: true)
22
25
 
23
26
  # Loads installed bundles' plugins (manifest plugin: {file:, sha256:})
24
27
  # into an Engine's registries (docs/plugins.md).