samagotchi 0.65.0 → 0.66.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/cli.md +11 -1
  6. data/docs/configuration.md +51 -7
  7. data/docs/context.md +4 -1
  8. data/docs/internals/engine.md +4 -1
  9. data/docs/internals/prompt-caching.md +10 -2
  10. data/docs/memory.md +1 -1
  11. data/docs/plugins.md +163 -2
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +10 -0
  14. data/docs/sub-agent.md +3 -1
  15. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
  18. data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
  19. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
  20. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  21. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
  22. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  23. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  24. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  25. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  27. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  28. data/lib/samagotchi/cache_confirm.rb +9 -6
  29. data/lib/samagotchi/commands/registry.rb +25 -8
  30. data/lib/samagotchi/config.rb +8 -1
  31. data/lib/samagotchi/engine.rb +112 -4
  32. data/lib/samagotchi/kernel_loop.rb +9 -1
  33. data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
  34. data/lib/samagotchi/plugin/api.rb +80 -7
  35. data/lib/samagotchi/plugin/context.rb +22 -1
  36. data/lib/samagotchi/plugin/loader.rb +3 -2
  37. data/lib/samagotchi/send_command.rb +23 -7
  38. data/lib/samagotchi/session.rb +18 -1
  39. data/lib/samagotchi/session_commands.rb +16 -1
  40. data/lib/samagotchi/session_manager.rb +4 -1
  41. data/lib/samagotchi/session_setup.rb +14 -7
  42. data/lib/samagotchi/system_prompt.rb +16 -1
  43. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  44. data/lib/samagotchi/terminal_ui.rb +18 -2
  45. data/lib/samagotchi/tool_runner.rb +13 -1
  46. data/lib/samagotchi/toolkit.rb +246 -0
  47. data/lib/samagotchi/toolkit_command.rb +117 -0
  48. data/lib/samagotchi/tools/registry.rb +43 -6
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +12 -0
  51. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  52. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  53. data/lib/samagotchi/web/public/app.js +21 -7
  54. data/lib/samagotchi/web/public/commands.js +4 -2
  55. data/lib/samagotchi/web/public/index.html +9 -0
  56. data/lib/samagotchi/web/public/info_bar.js +5 -3
  57. data/lib/samagotchi/web/public/session_state.js +3 -0
  58. data/lib/samagotchi/web/public/stream_handlers.js +13 -0
  59. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  60. data/lib/samagotchi/worker.rb +2 -0
  61. metadata +10 -1
@@ -31,6 +31,7 @@ require_relative "llm/backend"
31
31
  require_relative "llm/openai_chat"
32
32
  require_relative "llm/turn_settings"
33
33
  require_relative "session"
34
+ require_relative "toolkit"
34
35
  require_relative "answer_tail"
35
36
  require_relative "archive_store"
36
37
  require_relative "session_observer"
@@ -184,6 +185,9 @@ module Samagotchi
184
185
  # plugins' tool sets from chi.replace_tools until the turn thread
185
186
  # applies them (#apply_staged_tools!). Built before the plugins load.
186
187
  @plugin_tasks = build_plugin_tasks
188
+ # The toolkits plugins declare (chi.toolkit), beside the built-in and
189
+ # config ones (#toolkit_kits).
190
+ @plugin_kits = []
187
191
  @lifecycle_mutex = Mutex.new
188
192
  @shut_down = false
189
193
  load_plugins if plugins
@@ -1295,6 +1299,7 @@ module Samagotchi
1295
1299
  llm_context: llm_context_summary,
1296
1300
  thinking: thinking_explained&.summary,
1297
1301
  reasoning_history: reasoning_history_explained&.summary,
1302
+ toolkit: toolkit_summary,
1298
1303
  recap_enabled: !@recap.nil?,
1299
1304
  recap_min_user_turns: @recap&.min_user_turns,
1300
1305
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1563,11 +1568,12 @@ module Samagotchi
1563
1568
  # (Web::Capabilities#editor), for a UI or a viewer that can't. A UI that
1564
1569
  # does leaves the line out; the others, the log among them, show it. An
1565
1570
  # unknown value is dropped, so the line shows everywhere.
1566
- def hook_notify(text, level, hook, fallback_for: nil)
1571
+ # +extra+: more fields for the UIs (a toolkit change's summary).
1572
+ def hook_notify(text, level, hook, fallback_for: nil, extra: {})
1567
1573
  level = (level || :info).to_sym
1568
1574
  level = :info unless %i[info warn].include?(level)
1569
1575
  notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level,
1570
- fallback_for: notice_fallback(fallback_for, hook) }.compact
1576
+ fallback_for: notice_fallback(fallback_for, hook) }.compact.merge(extra)
1571
1577
  return @extension_load.hold(notice) && nil if @extension_load.loading?
1572
1578
 
1573
1579
  in_turn, sink = @turn_state.in_turn_sink
@@ -2019,8 +2025,11 @@ module Samagotchi
2019
2025
  # at its first request).
2020
2026
  start_init_tasks!
2021
2027
  await_init_tasks(turn.controller, turn.on_event)
2022
- # Plugins' tool sets that changed since the last turn.
2028
+ # Plugins' tool sets that changed since the last turn, then the
2029
+ # session's toolkit (a /toolkit change since the last turn): what the
2030
+ # prompt built next declares, unchanged until the next turn's start.
2023
2031
  apply_staged_tools!
2032
+ apply_toolkit!(session, turn.on_event)
2024
2033
  # A Stop during the wait above: the turn ends here,
2025
2034
  # before the hooks run or a reminder is used up.
2026
2035
  return false if turn.controller.cancelled?
@@ -2666,7 +2675,8 @@ module Samagotchi
2666
2675
  init: lambda { |bundle, label, plugin_label, provides_tools:, quiet:, timeout:, failed: nil, &block|
2667
2676
  add_init_task(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
2668
2677
  quiet: quiet, timeout: timeout, failed: failed, &block)
2669
- }
2678
+ },
2679
+ toolkits: @plugin_kits
2670
2680
  )
2671
2681
  # What plugins show while they load (an MCP server that didn't
2672
2682
  # start) waits for the first turn, beside the load warnings: no UI
@@ -2689,6 +2699,101 @@ module Samagotchi
2689
2699
  end
2690
2700
  public :apply_staged_tools!
2691
2701
 
2702
+ # The kits a session's toolkit resolves against: the built-ins, config's
2703
+ # toolkit.kits, the plugins' (Toolkit.kits). Config's are read once per
2704
+ # process, like the plugins': an edit to toolkit.kits never changes a
2705
+ # live session's tools mid-session (a new worker reads it).
2706
+ # @param errors [Array<String>] gets a line per config kit left out
2707
+ # @return [Hash{String => Toolkit::Kit}]
2708
+ def toolkit_kits(errors = [])
2709
+ @config_kits = Toolkit.config_kits unless defined?(@config_kits)
2710
+ Toolkit.kits(config: @config_kits, plugins: @plugin_kits, errors: errors)
2711
+ end
2712
+
2713
+ # The session's toolkit resolved (Toolkit::Resolved): what its next
2714
+ # turn declares. A plain full toolkit is nil, the Registry's "every tool".
2715
+ def resolved_toolkit(session, errors = [])
2716
+ resolved = Toolkit.of(session&.toolkit).resolve(toolkit_kits(errors))
2717
+ resolved.everything? ? nil : resolved
2718
+ end
2719
+
2720
+ # The tools this session registered (Tools::Registry), declared or not.
2721
+ attr_reader :tools
2722
+
2723
+ # +toolkit+ (the session's by default) as its next turn would declare
2724
+ # it (/toolkit, the UIs' chip).
2725
+ # @return [Toolkit::Explained]
2726
+ def toolkit_explained(toolkit = nil)
2727
+ toolkit ||= Toolkit.of(@session&.toolkit)
2728
+ resolved = toolkit.resolve(toolkit_kits)
2729
+ filter = resolved.everything? ? nil : resolved
2730
+ Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: @tools.entries(toolkit: filter),
2731
+ left_out: @tools.left_out_names(toolkit: filter), pending: filter != @tools.toolkit)
2732
+ end
2733
+
2734
+ # The session's toolkit for the UIs (the snapshot, command_ran): nil on
2735
+ # full, or with no session.
2736
+ def toolkit_summary
2737
+ @session && toolkit_explained.summary
2738
+ end
2739
+ public :tools, :toolkit_kits, :resolved_toolkit, :toolkit_explained, :toolkit_summary
2740
+
2741
+ # A plugin's ctx.use_toolkit: put the session on toolkit +name+, saved,
2742
+ # when it has no model reply yet and no turn runs (D3: no confirm from
2743
+ # plugins), and tell the user as /toolkit would; it applies at the next
2744
+ # turn's start.
2745
+ # @return [Boolean] whether it was staged
2746
+ # @raise [ArgumentError] an unknown toolkit
2747
+ def use_toolkit(name)
2748
+ raise ArgumentError, "no toolkit named #{name}" unless toolkit_kits.key?(name)
2749
+ return false if @session.nil? || turn_running? || history?
2750
+
2751
+ toolkit = Toolkit.new(name: name)
2752
+ return true if @session.toolkit == toolkit
2753
+
2754
+ @session.toolkit = toolkit
2755
+ @session.save(state_dir: session_state_dir)
2756
+ hook_notify("toolkit: #{name} from the next turn's start", :info, "toolkit", extra: { toolkit: toolkit_summary })
2757
+ true
2758
+ end
2759
+ public :use_toolkit
2760
+
2761
+ # Resolve the session's toolkit into the Registry, on the turn thread at
2762
+ # a turn's start (before its prompt is built), and build the prompts
2763
+ # again when what it declares changed. A name that doesn't resolve (a
2764
+ # typo, an uninstalled bundle) declares everything for now and says so
2765
+ # once; the saved toolkit stays as it is.
2766
+ # @return [Boolean] whether the declared tools changed
2767
+ def apply_toolkit!(session, on_event = nil)
2768
+ errors = []
2769
+ resolved = Toolkit.of(session&.toolkit).resolve(toolkit_kits(errors))
2770
+ warn_toolkit(session, resolved, errors, on_event)
2771
+ resolved = nil if resolved.everything?
2772
+ return false if resolved == @tools.toolkit
2773
+
2774
+ @tools.toolkit = resolved
2775
+ @prompt_builder&.reset!
2776
+ true
2777
+ end
2778
+ private :apply_toolkit!
2779
+
2780
+ # The toolkit's load warnings, each once per process: a name that
2781
+ # didn't resolve, config kits left out.
2782
+ def warn_toolkit(session, resolved, errors, on_event)
2783
+ lines = errors.dup
2784
+ if resolved.fallback
2785
+ lines << "#{Toolkit.of(session&.toolkit).label}: #{resolved.fallback}; this session declares every tool (full) " \
2786
+ "until it resolves"
2787
+ end
2788
+ @toolkit_warned ||= []
2789
+ (lines - @toolkit_warned).each do |line|
2790
+ @toolkit_warned << line
2791
+ Log.warn(:config, "toolkit_unresolved", message: line)
2792
+ emit_event(on_event, { type: :guardrail_warning, message: line, label: "toolkit" })
2793
+ end
2794
+ end
2795
+ private :warn_toolkit
2796
+
2692
2797
  # The tools changed (a plugin's chi.tools_changed!): the system prompts,
2693
2798
  # which declare them, are built again on the next turn.
2694
2799
  def tools_changed!
@@ -2715,6 +2820,9 @@ module Samagotchi
2715
2820
  steer: ->(text, label) { steer(text, source: hook_source(label)) },
2716
2821
  stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
2717
2822
  stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
2823
+ toolkit: -> { @session && Toolkit.of(@session.toolkit).name },
2824
+ declared_tools: -> { toolkit_explained.declared.map(&:name) },
2825
+ use_toolkit: ->(name) { use_toolkit(name) },
2718
2826
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
2719
2827
  ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
2720
2828
  },
@@ -1067,9 +1067,17 @@ module Samagotchi
1067
1067
  # The error an unknown tool name gets back. A shell-like name with
1068
1068
  # `execute` offered this turn points at `execute`; every other unknown
1069
1069
  # name lists what is offered. Neither echoes the wrong name.
1070
+ #
1071
+ # A tool the session's toolkit leaves out is a real one, so its error
1072
+ # does name it (deliberately, against the rule above): the model learns
1073
+ # it exists and that only the user can load it.
1070
1074
  def unknown_tool_error(name, layers)
1071
1075
  names = @tools.names(layers: layers)
1072
- if SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
1076
+ if @tools.respond_to?(:left_out) && @tools.left_out(name, layers: layers)
1077
+ toolkit = @tools.toolkit&.name
1078
+ "Error: #{name} isn't loaded in this session#{" (toolkit #{toolkit})" if toolkit} and can't be loaded from " \
1079
+ "here; only the user can (/toolkit add #{name}). Available: #{names.join(", ")}"
1080
+ elsif SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
1073
1081
  "Error: no such tool. Shell commands run with `execute` (same arguments)."
1074
1082
  else
1075
1083
  "Error: no such tool. Available: #{names.join(", ")}"
@@ -102,9 +102,11 @@ module Samagotchi
102
102
  end
103
103
 
104
104
  # The usage so far as an OpenAI-shaped `usage` object, nil when the
105
- # server sent no counts.
105
+ # server sent no counts, or only zeros (Fireworks' message_start: its
106
+ # counts come in the last message_delta; a 0 read as an empty context
107
+ # in the web's ctx meter while the answer streamed).
106
108
  def usage_payload
107
- return nil if @usage.empty?
109
+ return nil if @usage.values.none?(&:positive?)
108
110
 
109
111
  read = @usage["cache_read_input_tokens"].to_i
110
112
  write = @usage["cache_creation_input_tokens"].to_i
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "../toolkit"
4
5
  require_relative "../log"
5
6
  require_relative "../hooks/registry"
6
7
  require_relative "../tools/args"
8
+ require_relative "../commands/registry"
7
9
  require_relative "service"
8
10
  require_relative "tool_result"
9
11
 
@@ -16,6 +18,8 @@ module Samagotchi
16
18
  COMMAND_NAME = %r{\A/[a-z][a-z0-9_-]{0,31}\z}
17
19
  TOOL_NAME = /\A[a-z][a-z0-9_]{0,47}\z/
18
20
  PARAM_NAME = /\A[a-z_][a-z0-9_]*\z/i
21
+ ANNOTATION_LABEL_MAX = 40
22
+ ANNOTATIONS_MAX = 5
19
23
 
20
24
  # @param bundle [String]
21
25
  # @param label [String] "<file> (bundle <name>)": what hooks and
@@ -29,9 +33,11 @@ module Samagotchi
29
33
  @context = context
30
34
  @hooks = []
31
35
  @commands = []
36
+ @annotations = []
32
37
  @tools = []
33
38
  @services = []
34
39
  @inits = []
40
+ @toolkits = []
35
41
  @committed = false
36
42
  end
37
43
 
@@ -63,6 +69,35 @@ module Samagotchi
63
69
  nil
64
70
  end
65
71
 
72
+ # A pill in the web's selection bar: it prepares +command+ (a command
73
+ # line) with +template+ filled from the selected text on the lines
74
+ # under it ({quote}: the labelled quote block Annotate makes, {text}:
75
+ # the selection alone, {source}: answer, thinking, step, tool or
76
+ # user), and fills the composer with it, or sends it at once with
77
+ # +send+ (docs/plugins.md). Web only.
78
+ # @param label [String] the pill's text, 1–40 characters
79
+ # @param command [String] "/name" or "/name words": a command this
80
+ # plugin registers (checked once #register returns, so the order
81
+ # doesn't matter; another's is a load error)
82
+ # @param template [String] what goes under the command line
83
+ # @param send [Boolean] send at once instead of filling the composer
84
+ def annotation(label, command:, template: "{quote}", send: false)
85
+ label = label.to_s.strip
86
+ unless label.length.between?(1, ANNOTATION_LABEL_MAX)
87
+ raise ArgumentError, "annotation label #{label.inspect} must be 1 to #{ANNOTATION_LABEL_MAX} characters"
88
+ end
89
+
90
+ line = command.to_s.strip
91
+ name = line.split(/\s/, 2).first.to_s
92
+ raise ArgumentError, "annotation #{label}: command #{line.inspect} must start with a /name" unless name.match?(COMMAND_NAME)
93
+ raise ArgumentError, "annotation #{label}: template must be a non-blank String" unless template.is_a?(String) && !template.strip.empty?
94
+ raise ArgumentError, "at most #{ANNOTATIONS_MAX} annotations per plugin" if @annotations.size >= ANNOTATIONS_MAX
95
+
96
+ @annotations << { name: name, annotation: Commands::Registry::Annotation.new(label: label, line: line, template: template,
97
+ sends: send ? true : false) }
98
+ nil
99
+ end
100
+
66
101
  # A tool the model can call: the block gets the call's arguments (a
67
102
  # frozen Hash, string keys, typed by the schema: see Tools::Args) and
68
103
  # the Context, and returns the result text ("Error: …" marks a
@@ -215,12 +250,40 @@ module Samagotchi
215
250
  nil
216
251
  end
217
252
 
218
- # Register what was staged. Called by Loader after #register.
253
+ TOOLKIT_NAME = /\A[a-z][a-z0-9_-]{0,31}\z/
254
+
255
+ # A named toolkit sessions can be put on (/toolkit NAME, chi --toolkit,
256
+ # toolkit.default, ctx.use_toolkit): +base+'s tools (a toolkit chi or
257
+ # a bundle has) with +add+ declared and +remove+ left out. A name chi
258
+ # (full, lean, child) or another bundle has is a load error; a base
259
+ # that doesn't resolve leaves the sessions on it with every tool
260
+ # (Toolkit#resolve).
261
+ def toolkit(name, base:, add: [], remove: [])
262
+ name = name.to_s
263
+ raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
264
+ raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
265
+ if Toolkit::BUILTIN_KITS.key?(name)
266
+ raise ArgumentError, "toolkit #{name} is chi's own"
267
+ elsif (taken = @registries.toolkits.find { |kit| kit.name == name })
268
+ raise ArgumentError, "toolkit #{name} is already declared (#{taken.source})"
269
+ end
270
+ raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
271
+
272
+ @toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, source: @bundle)
273
+ nil
274
+ end
275
+
276
+ # Register what was staged. Called by Loader after #register. What
277
+ # can only be checked now (an annotation's command) is checked before
278
+ # anything is registered: a raise here leaves the registries as they were.
219
279
  def commit!
280
+ check_annotations!
220
281
  context = @context
221
282
  @commands.each do |cmd|
222
283
  block = cmd[:block]
223
- @registries.commands.register(cmd[:name], cmd[:description], anytime: cmd[:anytime], source: @bundle) do |args|
284
+ annotations = @annotations.select { |item| item[:name] == cmd[:name] }.map { |item| item[:annotation] }
285
+ @registries.commands.register(cmd[:name], cmd[:description], anytime: cmd[:anytime], source: @bundle,
286
+ annotations: annotations) do |args|
224
287
  block.call(args, context)
225
288
  end
226
289
  end
@@ -243,12 +306,24 @@ module Samagotchi
243
306
  block.call(context)
244
307
  end
245
308
  end
309
+ @registries.toolkits&.concat(@toolkits)
246
310
  @committed = true
247
311
  end
248
312
 
249
313
  # @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 }
314
+ def counts = { commands: @commands.size, annotations: @annotations.size, tools: @tools.size, hooks: @hooks.size,
315
+ services: @services.size, inits: @inits.size, toolkits: @toolkits.size }
316
+
317
+ # Each annotation's command is one #command staged.
318
+ def check_annotations!
319
+ names = @commands.map { |cmd| cmd[:name] }
320
+ @annotations.each do |item|
321
+ next if names.include?(item[:name])
322
+
323
+ raise ArgumentError, "annotation #{item[:annotation].label}: #{item[:name]} is not a command this plugin registers"
324
+ end
325
+ end
326
+ private :check_annotations!
252
327
 
253
328
  # A checked tool declaration (#tool's arguments: Api#tool and
254
329
  # ToolSet#tool pass theirs on, so a new keyword is added here).
@@ -281,9 +356,7 @@ module Samagotchi
281
356
  def self.apply_tools(registry, bundle, specs, context)
282
357
  wanted = specs.to_h { |spec| [spec[:name], spec] }
283
358
  changed = false
284
- registry.entries.each do |entry|
285
- next unless entry.source == bundle
286
-
359
+ registry.by_source(bundle).each do |entry|
287
360
  if (spec = wanted[entry.name]) && spec[:schema] == entry.schema && spec[:label] == entry.label
288
361
  entry_fields(spec, context).slice(:handler, :preview, :targets, :refs).each { |key, value| entry[key] = value }
289
362
  next
@@ -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,10 @@ 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).
20
21
  Registries = Struct.new(:commands, :tools, :hooks, :context_for, :tools_changed, :services, :stage_tools, :init,
21
- keyword_init: true)
22
+ :toolkits, keyword_init: true)
22
23
 
23
24
  # Loads installed bundles' plugins (manifest plugin: {file:, sha256:})
24
25
  # into an Engine's registries (docs/plugins.md).
@@ -39,7 +39,7 @@ module Samagotchi
39
39
 
40
40
  USAGE = <<~TEXT
41
41
  Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
42
- chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [-m TEXT] [--image PATH]...
42
+ chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [-m TEXT] [--image PATH]...
43
43
  chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
44
44
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
45
45
  chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
@@ -63,8 +63,8 @@ module Samagotchi
63
63
  the current one
64
64
  --continues ID
65
65
  (--new) start the next link of that session's chain:
66
- in its folder, on its model, LLM context and
67
- thinking level, with a note carrying its recap; it
66
+ in its folder, on its model, LLM context,
67
+ thinking level and toolkit, with a note carrying its recap; it
68
68
  is archived. last:ID names the chain's latest link.
69
69
  Without -m the new session waits idle. Refused for a
70
70
  session continued already (naming the next link) or
@@ -77,6 +77,11 @@ module Samagotchi
77
77
  (--new) its own thinking level (off, low, medium,
78
78
  high; default: none of its own), above a process
79
79
  default such as SAMAGOTCHI_THINKING_LEVEL
80
+ --toolkit NAME, --tool NAME, --no-tool NAME
81
+ (--new) its toolkit, the tools its prompts declare
82
+ (full, lean, child, a config or bundle one; default
83
+ config's toolkit.default), and tools declared on top
84
+ of it or left out (repeatable, a comma list too)
80
85
  --wait wait for the answer and print it (one session); the
81
86
  other lines go to stderr. Exit 3: it waits for an
82
87
  answer from you: the question, its options and the
@@ -126,6 +131,9 @@ module Samagotchi
126
131
  f.value "--llm-context-apply", key: :llm_apply
127
132
  f.value "--llm-context-budget", key: :llm_budget
128
133
  f.value "--thinking"
134
+ f.value "--toolkit"
135
+ f.value "--tool", key: :tools, repeat: true
136
+ f.value "--no-tool", key: :no_tools, repeat: true
129
137
  f.value "--image", key: :images, repeat: true
130
138
  f.switch "--wait"
131
139
  f.switch "--cut"
@@ -262,7 +270,8 @@ module Samagotchi
262
270
  return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
263
271
  return new_options(options) if options[:new]
264
272
 
265
- %i[dir model continues thinking].each { |key| return usage_error("--#{key} needs --new") if options[key] }
273
+ %i[dir model continues thinking toolkit].each { |key| return usage_error("--#{key} needs --new") if options[key] }
274
+ TOOLKIT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") unless Array(options[key]).empty? }
266
275
  LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
267
276
  return usage_error("give session ids") if options[:ids].empty?
268
277
 
@@ -273,15 +282,19 @@ module Samagotchi
273
282
  LLM_CONTEXT_FLAGS = { llm_strategy: "--llm-context", llm_apply: "--llm-context-apply",
274
283
  llm_budget: "--llm-context-budget" }.freeze
275
284
  LLM_CONTEXT_FIELDS = { llm_strategy: :strategy, llm_apply: :apply, llm_budget: :budget_tokens }.freeze
285
+ # --tool / --no-tool's option keys.
286
+ TOOLKIT_FLAGS = { tools: "--tool", no_tools: "--no-tool" }.freeze
276
287
 
277
288
  def new_options(options)
278
289
  return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
279
290
 
280
291
  if options[:continues]
281
292
  taken = [("--dir" if options[:dir]), ("--model" if options[:model])] +
282
- LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])]
293
+ LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
294
+ [("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? }
283
295
  unless taken.compact.empty?
284
- return usage_error("--continues takes the previous session's folder, model, LLM context and thinking level; " \
296
+ return usage_error("--continues takes the previous session's folder, model, LLM context, thinking level and " \
297
+ "toolkit; " \
285
298
  "leave out #{taken.compact.join(", ")}")
286
299
  end
287
300
  end
@@ -301,6 +314,8 @@ module Samagotchi
301
314
 
302
315
  options[:thinking] = Thinking.session_level(word)
303
316
  end
317
+ toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
318
+ options[:toolkit_value] = toolkit if toolkit
304
319
 
305
320
  if options[:dir]
306
321
  options[:dir] = File.expand_path(options[:dir])
@@ -348,7 +363,8 @@ module Samagotchi
348
363
  SessionManager.spawn_session(**start, working_directory: options[:dir],
349
364
  model_name: options[:model],
350
365
  setup: SessionSetup.new(llm_context: options[:llm_context],
351
- thinking: options[:thinking]),
366
+ thinking: options[:thinking],
367
+ toolkit: options[:toolkit_value]),
352
368
  state_dir: @state_dir)
353
369
  end
354
370
  rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
@@ -184,6 +184,19 @@ module Samagotchi
184
184
  # couldn't read; nil when there is neither.
185
185
  def reasoning_history_file = @reasoning_history&.to_s || @reasoning_history_unread
186
186
 
187
+ # The session's toolkit (Toolkit: the tools its prompts declare), as
188
+ # given when it was created; nil for a session from before toolkits,
189
+ # which is on Toolkit::FULL (Toolkit.of). Saved and never resolved
190
+ # again from config, so a respawned worker declares the same tools; a
191
+ # continue and a fork copy it (SessionSetup.of), a delegate child gets
192
+ # toolkit.child (SessionManager.spawn_session).
193
+ attr_reader :toolkit
194
+
195
+ # @param value [Toolkit, Hash, String, nil] the session file's form too
196
+ def toolkit=(value)
197
+ @toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
198
+ end
199
+
187
200
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
188
201
  # left), e.g. "loop-guard"; nil when it ended otherwise.
189
202
  def stopped_by
@@ -213,7 +226,7 @@ module Samagotchi
213
226
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
214
227
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
215
228
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
216
- interrupted_turn: nil)
229
+ toolkit: nil, interrupted_turn: nil)
217
230
  @id = id
218
231
  @metadata_version = metadata_version
219
232
  @mode = mode
@@ -242,6 +255,7 @@ module Samagotchi
242
255
  self.thinking = thinking
243
256
  self.reasoning_history = reasoning_history
244
257
  self.appended_prompt_files = appended_prompt_files
258
+ self.toolkit = toolkit
245
259
  self.interrupted_turn = interrupted_turn
246
260
  @turn_checkpoint = nil
247
261
  @archived = false
@@ -336,6 +350,7 @@ module Samagotchi
336
350
  thinking: setup.thinking,
337
351
  reasoning_history: setup.reasoning_history,
338
352
  appended_prompt_files: setup.appended_prompt_files,
353
+ toolkit: setup.toolkit,
339
354
  continues: continues
340
355
  )
341
356
  end
@@ -400,6 +415,7 @@ module Samagotchi
400
415
  "thinking" => nil,
401
416
  "reasoning_history" => nil,
402
417
  "appended_prompt_files" => [],
418
+ "toolkit" => nil,
403
419
  "interrupted_turn" => nil
404
420
  }.freeze
405
421
 
@@ -527,6 +543,7 @@ module Samagotchi
527
543
  when "llm_context" then llm_context_file
528
544
  when "thinking" then thinking_file
529
545
  when "reasoning_history" then reasoning_history_file
546
+ when "toolkit" then @toolkit&.to_file
530
547
  when "prompt_notes" then @prompt_notes.map(&:to_file)
531
548
  when "last_turn" then @last_turn&.to_file
532
549
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
@@ -12,6 +12,7 @@ require_relative "tools/peers"
12
12
  require_relative "commands/registry"
13
13
  require_relative "llm_context_command"
14
14
  require_relative "thinking_command"
15
+ require_relative "toolkit_command"
15
16
  require_relative "reasoning_history_command"
16
17
 
17
18
  module Samagotchi
@@ -32,6 +33,7 @@ module Samagotchi
32
33
  HELP_COMMAND = "/help"
33
34
  LLM_CONTEXT_COMMAND = LLMContextCommand::NAME
34
35
  THINKING_COMMAND = ThinkingCommand::NAME
36
+ TOOLKIT_COMMAND = ToolkitCommand::NAME
35
37
  REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
36
38
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
37
39
  # shows this many per host; /models <text> lists every match.
@@ -48,7 +50,7 @@ module Samagotchi
48
50
 
49
51
  # The commands that ask before a change that restarts the prompt cache
50
52
  # (CacheConfirm): #run passes them its confirm flag.
51
- CONFIRMING = %i[thinking reasoning_history].freeze
53
+ CONFIRMING = %i[thinking reasoning_history toolkit].freeze
52
54
 
53
55
  # @!attribute status [Symbol] :ok, or :error when the line was refused
54
56
  # @!attribute output [String, nil] what to tell the user
@@ -103,6 +105,9 @@ module Samagotchi
103
105
  registry.register(REASONING_HISTORY_COMMAND, "show or set which of its reasoning this session's model gets back (drop|turn|all|default)",
104
106
  id: :reasoning_history, mid_turn: show_mid_turn(REASONING_HISTORY_COMMAND),
105
107
  match: ->(text) { text.match?(%r{\A/reasoning-history(?:\s+.*)?\z}) }) { |text| reasoning_history(text) }
108
+ registry.register(TOOLKIT_COMMAND, "show or change the tools this session declares (NAME, add TOOL, remove TOOL)",
109
+ id: :toolkit, mid_turn: show_mid_turn(TOOLKIT_COMMAND),
110
+ match: ->(text) { text.match?(%r{\A/toolkit(?:\s+.*)?\z}) }) { |text| toolkit(text) }
106
111
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
107
112
  registry.register("/stats", "show the session's stats", local: true)
108
113
  registry.register("/recap", "show the session's recap", local: true)
@@ -467,6 +472,16 @@ module Samagotchi
467
472
  reply(e.message, status: :error)
468
473
  end
469
474
 
475
+ # /toolkit (ToolkitCommand); :toolkit in changed when it set the
476
+ # session's toolkit.
477
+ def toolkit(text, confirm: true)
478
+ output, changed, ask = ToolkitCommand.new(engine: @engine, save: ->(session) { save_setup(session) })
479
+ .run(text.delete_prefix(TOOLKIT_COMMAND), confirm: confirm)
480
+ reply(output, changed: changed ? [:toolkit] : [], confirm: ask)
481
+ rescue ArgumentError => e
482
+ reply(e.message, status: :error)
483
+ end
484
+
470
485
  # A session setting changed (/llm-context, /thinking, /reasoning-history):
471
486
  # saved with its model.
472
487
  def save_setup(session)
@@ -140,7 +140,9 @@ module Samagotchi
140
140
  # @param title [String, nil] what the lists show before the first turn
141
141
  # (the prompt's preview by default, else the seed's first user message)
142
142
  # @param setup [SessionSetup] the session's own settings (chi
143
- # --llm-context; a fork and a continue copy their session's)
143
+ # --llm-context; a fork and a continue copy their session's); without
144
+ # a toolkit the session is stored on config's toolkit.default, a
145
+ # delegate child always on toolkit.child (Toolkit.configured)
144
146
  # @param continues [String, nil] the session this one continues (a
145
147
  # session field; SessionContinue)
146
148
  # @param note [String, nil] a context note the session starts with,
@@ -177,6 +179,7 @@ module Samagotchi
177
179
  setup: setup,
178
180
  continues: continues
179
181
  )
182
+ session.toolkit = Toolkit.configured(delegate: delegate) if delegate || session.toolkit.nil?
180
183
  session.model_warning = model_warning
181
184
  # With no prompt there is no first turn to run (an attaching UI sends
182
185
  # the prompts), so the session starts idle.