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
@@ -3,6 +3,7 @@
3
3
  require_relative "llm_context_override"
4
4
  require_relative "reasoning_history"
5
5
  require_relative "thinking"
6
+ require_relative "toolkit"
6
7
 
7
8
  module Samagotchi
8
9
  # A session's own settings that come before its model's, carried as one
@@ -12,27 +13,33 @@ module Samagotchi
12
13
  # +thinking+ a level Symbol (Thinking.session_level) or nil,
13
14
  # +reasoning_history+ a level Symbol (ReasoningHistory.session_level) or nil,
14
15
  # +appended_prompt_files+ the absolute paths chi --append-system-prompt
15
- # named (their text goes into the system prompt; [] with none).
16
+ # named (their text goes into the system prompt; [] with none),
17
+ # +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
18
+ # session config's toolkit.default then).
16
19
  #
17
20
  # It doesn't save itself: the session keeps each setting in its own field
18
21
  # and file key, and resolves it on its own. A delegate child starts
19
22
  # without one; a continue and a fork copy their session's (.of).
20
- SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files) do
21
- def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [])
23
+ SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit) do
24
+ def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil)
22
25
  super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
23
- appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze)
26
+ appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit)
24
27
  end
25
28
 
26
- def empty? = llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty?
29
+ def empty?
30
+ llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil?
31
+ end
27
32
 
28
- # A session's own setup, for a session that starts from it.
33
+ # A session's own setup, for a session that starts from it. Its
34
+ # toolkit always: one from before toolkits is full (Toolkit.of), not
35
+ # whatever config's default is now.
29
36
  # @param session [Session, nil]
30
37
  # @return [SessionSetup]
31
38
  def self.of(session)
32
39
  return new if session.nil?
33
40
 
34
41
  new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
35
- appended_prompt_files: session.appended_prompt_files)
42
+ appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit))
36
43
  end
37
44
  end
38
45
  end
@@ -317,7 +317,7 @@ module Samagotchi
317
317
  "Project memories:\n#{indexes[:project]}",
318
318
  "System memories:\n#{indexes[:system]}"
319
319
  ].join("\n\n")
320
- stable = [thinking_token + base, rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
320
+ stable = [thinking_token + base, not_loaded_line(layers), rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
321
321
  project_specific_description, appended_section, memory_sections]
322
322
  stable_text = stable.compact.join("\n")
323
323
  volatile = [current_model, project_location, current_session].compact
@@ -328,6 +328,21 @@ module Samagotchi
328
328
  with_tail_declarations(prompt, chat: chat, layers: layers)
329
329
  end
330
330
 
331
+ # The tools the session's toolkit leaves out, named once right after the
332
+ # base prompt: sessions on another toolkit already differ at the tools,
333
+ # and nothing later (a memory write) moves it. nil when none are (full):
334
+ # the prompt is what it was before toolkits.
335
+ def not_loaded_line(layers)
336
+ registry = @tools_lookup.call
337
+ return nil unless registry.respond_to?(:left_out_names)
338
+
339
+ names = registry.left_out_names(layers: layers)
340
+ return nil if names.empty?
341
+
342
+ "Not loaded in this session (toolkit #{registry.toolkit&.name}): #{names.join(", ")}. " \
343
+ "Only the user can load one (/toolkit add <name>)."
344
+ end
345
+
331
346
  def with_tail_declarations(prompt, chat:, layers:)
332
347
  chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(layers)
333
348
  end
@@ -46,17 +46,23 @@ module Samagotchi
46
46
  # @param thinking [String, nil] --thinking as typed: a new session
47
47
  # starts with it as its own level; a resumed or attached one's
48
48
  # worker gets a /thinking, after those
49
+ # @param toolkit [Toolkit, nil] --toolkit/--tool/--no-tool: a new
50
+ # session's toolkit (config's toolkit.default without); an existing
51
+ # session keeps its own
52
+ # @param toolkit_flags [Array<String>] which of those were given, for
53
+ # the line an existing session's run prints
49
54
  # @param state_dir [String, nil] where the sessions are (the default
50
55
  # state dir by default): the worker's, for the attached loop too
51
56
  # @return [Symbol] :detached, :closed when the worker went away,
52
57
  # :failed when the --model switch didn't go through, or (input from a
53
58
  # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
54
59
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
55
- memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], state_dir: nil)
60
+ memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
61
+ toolkit_flags: [], state_dir: nil)
56
62
  state_dir ||= Session.default_state_dir
57
63
  client = connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
58
64
  memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking,
59
- appended_prompt_files: appended_prompt_files)
65
+ appended_prompt_files: appended_prompt_files, toolkit: toolkit, toolkit_flags: toolkit_flags)
60
66
  commands = []
61
67
  commands << "/model #{model}" if model && (attach || resume)
62
68
  commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
@@ -123,10 +129,12 @@ module Samagotchi
123
129
  # @return [BridgeClient] a client for the live Bridge of the session
124
130
  # @raise [Error]
125
131
  def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
126
- memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], err: $stderr)
132
+ memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
133
+ toolkit_flags: [], err: $stderr)
127
134
  sd = state_dir || Session.default_state_dir
128
135
  if attach || resume
129
- warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files)
136
+ warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
137
+ toolkit_flags: toolkit_flags)
130
138
  end
131
139
  if attach
132
140
  live = connect_existing(attach, sd)
@@ -143,7 +151,8 @@ module Samagotchi
143
151
  memories: memories, muted_memories: muted_memories,
144
152
  setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
145
153
  thinking: Thinking.session_level(thinking),
146
- appended_prompt_files: appended_prompt_files))
154
+ appended_prompt_files: appended_prompt_files,
155
+ toolkit: toolkit))
147
156
  end
148
157
  client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
149
158
  client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
@@ -159,14 +168,15 @@ module Samagotchi
159
168
  end
160
169
 
161
170
  # An existing session's prompt is built from its own session fields:
162
- # --memory/--mute/--append-system-prompt given with --attach or
163
- # --resume are ignored, with a line saying so (printed before the live
164
- # region opens).
165
- def warn_memory_flags_ignored(session_id, memories, muted_memories, err, appended_prompt_files: [])
171
+ # --memory/--mute/--append-system-prompt and the toolkit flags given
172
+ # with --attach or --resume are ignored, with a line saying so (printed
173
+ # before the live region opens).
174
+ def warn_memory_flags_ignored(session_id, memories, muted_memories, err, appended_prompt_files: [], toolkit_flags: [])
166
175
  flags = []
167
176
  flags << "--memory" unless Array(memories).empty?
168
177
  flags << "--mute" unless Array(muted_memories).empty?
169
178
  flags << "--append-system-prompt" unless Array(appended_prompt_files).empty?
179
+ flags.concat(toolkit_flags)
170
180
  return if flags.empty?
171
181
 
172
182
  verb = flags.size == 1 ? "applies" : "apply"
@@ -97,10 +97,15 @@ module Samagotchi
97
97
  # @param appended_prompt_files [Array<String>] --append-system-prompt's
98
98
  # files (absolute): a new session saves them; a resumed one's own come
99
99
  # first, and the merge goes only to the Engine (as --memory's does)
100
+ # @param toolkit [Toolkit, nil] --toolkit/--tool/--no-tool: a new
101
+ # session's toolkit (config's toolkit.default without); a resumed one
102
+ # keeps its own, with a line naming +toolkit_flags+ (the flags given)
100
103
  def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
101
104
  spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
102
- appended_prompt_files: [])
105
+ appended_prompt_files: [], toolkit: nil, toolkit_flags: [])
103
106
  @scratch = scratch
107
+ @toolkit = toolkit
108
+ @toolkit_flags = toolkit_flags
104
109
  @thinking_word = thinking
105
110
  @prompt = prompt
106
111
  @llm_context_words = llm_context
@@ -269,7 +274,7 @@ module Samagotchi
269
274
  preloaded_memory_names: @requested_memories,
270
275
  muted_memory_names: @muted_memory_names,
271
276
  scratch: @scratch,
272
- setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files)
277
+ setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured)
273
278
  ))
274
279
  # --llm-context*: the session's own values, over a resumed one's.
275
280
  # Saved at once on a resumed one: leaving before a turn keeps them.
@@ -279,6 +284,7 @@ module Samagotchi
279
284
  session.thinking = Thinking.session_level(@thinking_word) if @thinking_word
280
285
  session.save if @resume_session
281
286
  end
287
+ warn_toolkit_flags_ignored(session) if @resume_session
282
288
  claim_session!(session.id) unless @owner_lock
283
289
  # A turn a worker died in (TurnCheckpoint): ended as canceled, with its
284
290
  # note, before this REPL's first turn appends after it.
@@ -714,6 +720,16 @@ module Samagotchi
714
720
  end
715
721
  end
716
722
 
723
+ # The toolkit flags are a new session's: a resumed one keeps its own
724
+ # toolkit (as attached mode says it, AttachLauncher).
725
+ def warn_toolkit_flags_ignored(session)
726
+ return if @toolkit_flags.empty?
727
+
728
+ names = @toolkit_flags.size > 1 ? "#{@toolkit_flags[0..-2].join(", ")} and #{@toolkit_flags.last}" : @toolkit_flags.first
729
+ warn "(#{names} #{@toolkit_flags.size == 1 ? "applies" : "apply"} to a new session; #{session.id}'s prompt is already built)"
730
+ end
731
+ private :warn_toolkit_flags_ignored
732
+
717
733
  private
718
734
 
719
735
  # Interactive REPL loop. Session seed + messages are built by #run and
@@ -49,8 +49,11 @@ module Samagotchi
49
49
  def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
50
50
  params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
51
51
  # The gate runs first, so tool_call_started shows the call that runs.
52
+ # A tool the session's toolkit leaves out skips it: no before_tool_call
53
+ # hook, no approval card for a call that can't run (KernelLoop#dispatch
54
+ # answers it with the not-loaded error).
52
55
  original = call.dup
53
- verdict = evaluate(call, iteration, params)
56
+ verdict = left_out?(call) ? Guardrails::Verdict.new(call: call) : evaluate(call, iteration, params)
54
57
  call = verdict.call
55
58
  params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
56
59
  label = plugin_label(call[:name])
@@ -265,6 +268,15 @@ module Samagotchi
265
268
  # The kernel's tools, for the activity line of a tool that isn't built in.
266
269
  def tools = @kernel.tools
267
270
 
271
+ # A real tool the session's toolkit leaves out of this turn.
272
+ def left_out?(call)
273
+ registry = tools
274
+ return false unless registry.respond_to?(:left_out) && !call[:name].nil?
275
+
276
+ layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
277
+ !registry.left_out(call[:name], layers: layers).nil?
278
+ end
279
+
268
280
  # A tool the registry has from a plugin (not core, not unknown).
269
281
  def plugin_tool?(name)
270
282
  entry = tools && !name.nil? ? tools[name] : nil
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module Samagotchi
6
+ # A session's toolkit: which of the registered tools its prompts declare
7
+ # (docs/sessions.md, "Toolkits"). Saved in the session file as given
8
+ # ({"name" => "lean", "add" => [], "remove" => []}) when the session is
9
+ # created, kept by continues and forks, and never resolved again from
10
+ # config: a respawned worker declares what the session was created with.
11
+ # A session without one (a file from before toolkits) is on FULL.
12
+ #
13
+ # +name+ names a Kit (the built-ins, config's toolkit.kits, a plugin's
14
+ # chi.toolkit); +add+ and +remove+ are the session's own changes on top
15
+ # (--tool / --no-tool, /toolkit add|remove). #resolve against the kits
16
+ # known gives the Resolved the Registry filters by.
17
+ Toolkit = Data.define(:name, :add, :remove) do
18
+ def initialize(name:, add: [], remove: [])
19
+ super(name: name.to_s.strip, add: Toolkit.tool_names(add), remove: Toolkit.tool_names(remove))
20
+ end
21
+
22
+ # @return [Hash] the session file's "toolkit"
23
+ def to_file = { "name" => name, "add" => add, "remove" => remove }
24
+
25
+ # The plain form the snapshot and the web carry.
26
+ def to_h = { name: name, add: add, remove: remove }
27
+
28
+ # This toolkit with +tool+ declared (/toolkit add): a tool it removed
29
+ # comes out of +remove+ (back to its kit's), any other goes into +add+.
30
+ def adding(tool)
31
+ tool = tool.to_s
32
+ remove.include?(tool) ? with(remove: remove - [tool]) : with(add: (add + [tool]).uniq)
33
+ end
34
+
35
+ # This toolkit with +tool+ left out (/toolkit remove), the other way.
36
+ def removing(tool)
37
+ tool = tool.to_s
38
+ add.include?(tool) ? with(add: add - [tool]) : with(remove: (remove + [tool]).uniq)
39
+ end
40
+
41
+ # How the UIs name it: "lean", "lean +web_fetch -memory_write".
42
+ def label = [name, *add.map { |tool| "+#{tool}" }, *remove.map { |tool| "-#{tool}" }].join(" ")
43
+
44
+ def full? = name == Toolkit::FULL && add.empty? && remove.empty?
45
+
46
+ # The kit +name+ names, from +kits+ (Toolkit.kits), with the session's
47
+ # own +add+ and +remove+ on top. An unknown name, a base that is, or a
48
+ # cycle falls back to FULL with the reason in Resolved#fallback; the
49
+ # saved toolkit is untouched, so a reinstalled bundle gets it back.
50
+ # @param kits [Hash{String => Kit}]
51
+ # @return [Resolved]
52
+ def resolve(kits)
53
+ Toolkit.resolve_kit(name, kits).changed(add: add, remove: remove)
54
+ rescue Toolkit::Unresolved => e
55
+ Toolkit.resolve_kit(Toolkit::FULL, kits).changed(add: add, remove: remove).with(fallback: e.message)
56
+ end
57
+ end
58
+
59
+ class Toolkit
60
+ FULL = "full"
61
+ LEAN = "lean"
62
+ CHILD = "child"
63
+
64
+ # A name that can't be resolved (unknown, a cycle).
65
+ class Unresolved < StandardError; end
66
+
67
+ # A toolkit as a session's next turn would declare it (Engine
68
+ # #toolkit_explained): +toolkit+ as saved, +resolved+, the Registry
69
+ # +declared+ entries and the names it leaves out (+left_out+), and
70
+ # whether that isn't what the Registry declares now (+pending+: it
71
+ # applies at the next turn's start).
72
+ Explained = Data.define(:toolkit, :resolved, :declared, :left_out, :pending) do
73
+ # What the prompt carries of it: the declared names and, when it
74
+ # leaves tools out, the kit name the not-loaded line gives. Two equal
75
+ # keys build the same prompt (/toolkit's "no change", its cost).
76
+ def prompt_key = [declared.map(&:name), left_out.empty? ? nil : resolved.name]
77
+
78
+ # What the UIs show (the snapshot, command_ran): nil when it declares
79
+ # every tool on full (full, full +delegate), which has no chip.
80
+ def summary
81
+ return nil if toolkit.name == Toolkit::FULL && left_out.empty? && resolved.fallback.nil?
82
+
83
+ { name: toolkit.name, label: toolkit.label, left_out: left_out, fallback: resolved.fallback }
84
+ end
85
+ end
86
+
87
+ # Tool names as a list of unique, stripped strings.
88
+ def self.tool_names(names) = Array(names).map { |tool| tool.to_s.strip }.reject(&:empty?).uniq
89
+
90
+ # A named list of declared tools: +tools+ (:all, every built-in; or the
91
+ # built-in names) when it has no +base+, else its +base+'s with +add+
92
+ # and +remove+. +source+: "core", "config", or the bundle that declared
93
+ # it (chi.toolkit).
94
+ Kit = Data.define(:name, :base, :tools, :add, :remove, :source) do
95
+ def initialize(name:, base: nil, tools: nil, add: [], remove: [], source: "core")
96
+ super(name: name.to_s, base: base&.to_s, tools: tools == :all ? :all : Toolkit.tool_names(tools),
97
+ add: Toolkit.tool_names(add), remove: Toolkit.tool_names(remove), source: source.to_s)
98
+ end
99
+ end
100
+
101
+ # The built-ins `lean` declares: the file and shell tools, memories,
102
+ # the task tools (execute's hand-over, turn notes and task_wait's tail
103
+ # name the trio), questions, web_fetch, and context_read (core notes
104
+ # tell the model to call it).
105
+ LEAN_TOOLS = %w[
106
+ execute read write edit memory_read memory_write task_create task_wait task_get task_list task_stop
107
+ ask_user_question web_fetch context_read
108
+ ].freeze
109
+
110
+ BUILTIN_KITS = [
111
+ Kit.new(name: FULL, tools: :all),
112
+ Kit.new(name: LEAN, tools: LEAN_TOOLS),
113
+ # A delegate child: it reports to its parent (send_note; the
114
+ # delegated-by line names it) and leaves memory writes to it.
115
+ Kit.new(name: CHILD, base: LEAN, add: %w[send_note], remove: %w[memory_write])
116
+ ].to_h { |kit| [kit.name, kit] }.freeze
117
+
118
+ # What a toolkit declares, the Registry's filter: every built-in
119
+ # (+all_core+) or the ones in +core+, every plugin tool and every tool
120
+ # an LLM context layer gates (forget_outputs), but for +removed+;
121
+ # +added+ declares any registered tool. +fallback+: why the session's
122
+ # own toolkit couldn't be resolved (this is FULL then), nil when it was.
123
+ Resolved = Data.define(:name, :all_core, :core, :added, :removed, :fallback) do
124
+ def initialize(name:, all_core: false, core: [], added: [], removed: [], fallback: nil)
125
+ super(name: name.to_s, all_core: all_core, core: core.dup.freeze, added: added.dup.freeze,
126
+ removed: removed.dup.freeze, fallback: fallback)
127
+ end
128
+
129
+ # Every built-in and nothing removed: what no toolkit declares.
130
+ def everything? = all_core && removed.empty?
131
+
132
+ # @param entry [Tools::Registry::Entry]
133
+ def declares?(entry)
134
+ return false if removed.include?(entry.name)
135
+ return true if added.include?(entry.name) || !entry.core? || !entry.layer.nil?
136
+
137
+ all_core || core.include?(entry.name)
138
+ end
139
+
140
+ # This with +add+ declared and +remove+ left out, in that order of
141
+ # the kit's own words: a later change wins.
142
+ def changed(add: [], remove: [])
143
+ added = self.added - remove
144
+ removed = self.removed - add
145
+ with(added: (added + add).uniq, removed: (removed + remove).uniq)
146
+ end
147
+ end
148
+
149
+ # The session file's "toolkit": a Toolkit, nil for none (a file from
150
+ # before toolkits, or one that isn't a toolkit), which reads as FULL.
151
+ def self.from_file(value)
152
+ case value
153
+ when Hash
154
+ name = value["name"] || value[:name]
155
+ return nil if name.to_s.strip.empty?
156
+
157
+ new(name: name, add: value["add"] || value[:add], remove: value["remove"] || value[:remove])
158
+ when String, Symbol
159
+ value.to_s.strip.empty? ? nil : new(name: value)
160
+ end
161
+ end
162
+
163
+ # The toolkit a new session is stored on: config's toolkit.default, or
164
+ # toolkit.child for a delegate child; FULL when unset. As named: the
165
+ # worker resolves it (plugins' kits load there).
166
+ def self.configured(delegate: false)
167
+ name = Config.get(delegate ? "toolkit.child" : "toolkit.default").to_s.strip
168
+ new(name: name.empty? ? FULL : name)
169
+ end
170
+
171
+ # config.yml's toolkit.kits (a map of name => {base:, add:, remove:}),
172
+ # nil when it has none.
173
+ def self.config_kits
174
+ section = ConfigFile.section("toolkit")
175
+ section.is_a?(Hash) ? section["kits"] : nil
176
+ rescue StandardError
177
+ nil
178
+ end
179
+
180
+ # A new session's toolkit from chi --toolkit NAME / --tool / --no-tool
181
+ # (each repeatable, a comma list too): NAME (config's toolkit.default
182
+ # without one) with those tools added and left out; nil when none was
183
+ # given. A tool in both is left out.
184
+ def self.from_flags(name: nil, add: [], remove: [])
185
+ add = split_names(add)
186
+ remove = split_names(remove)
187
+ return nil if name.to_s.strip.empty? && add.empty? && remove.empty?
188
+
189
+ base = name.to_s.strip.empty? ? configured.name : name
190
+ new(name: base, add: add - remove, remove: remove)
191
+ end
192
+
193
+ def self.split_names(values) = tool_names(Array(values).flat_map { |value| value.to_s.split(",") })
194
+ private_class_method :split_names
195
+
196
+ # The toolkit a session is on: its own, or FULL without one.
197
+ def self.of(toolkit) = toolkit || new(name: FULL)
198
+
199
+ # What a toolkit change costs the prompt cache (CacheConfirm): the
200
+ # declared tools are the front of every request, so any change to them
201
+ # (or to the not-loaded line) is :full; nil when they stay the same.
202
+ # @param before [Object] what the prompt carries now (Explained#prompt_key)
203
+ # @param after [Object] what it would carry after the change
204
+ def self.cache_cost(before, after) = before == after ? nil : :full
205
+
206
+ # @return [Resolved] +name+'s kit through its bases
207
+ # @raise [Unresolved] unknown, or a cycle
208
+ def self.resolve_kit(name, kits, seen = [])
209
+ raise Unresolved, "no toolkit named #{name}" unless (kit = kits[name])
210
+ raise Unresolved, "toolkit #{name}'s bases make a cycle (#{(seen + [name]).join(" → ")})" if seen.include?(name)
211
+
212
+ base = if kit.base
213
+ resolve_kit(kit.base, kits, seen + [name])
214
+ else
215
+ Resolved.new(name: name, all_core: kit.tools == :all, core: kit.tools == :all ? [] : kit.tools)
216
+ end
217
+ base.changed(add: kit.add, remove: kit.remove).with(name: name)
218
+ end
219
+
220
+ # The kits known: the built-ins, then config's toolkit.kits, then the
221
+ # plugins' (chi.toolkit). A config kit with a built-in's name is left
222
+ # out (one it can't redefine) with a line in +errors+, and so is one
223
+ # that isn't a {base:, add:, remove:} map.
224
+ # @param config [Hash, nil] toolkit.kits
225
+ # @param plugins [Array<Kit>]
226
+ # @param errors [Array<String>] gets a line per kit left out
227
+ # @return [Hash{String => Kit}]
228
+ def self.kits(config: nil, plugins: [], errors: [])
229
+ kits = BUILTIN_KITS.dup
230
+ (config.is_a?(Hash) ? config : {}).each do |name, spec|
231
+ name = name.to_s
232
+ if kits.key?(name)
233
+ errors << "toolkit.kits.#{name}: #{name} is a built-in toolkit"
234
+ elsif !spec.is_a?(Hash)
235
+ errors << "toolkit.kits.#{name}: expected {base:, add:, remove:}"
236
+ else
237
+ spec = spec.transform_keys(&:to_s)
238
+ kits[name] = Kit.new(name: name, base: spec["base"] || FULL, add: spec["add"], remove: spec["remove"],
239
+ source: "config")
240
+ end
241
+ end
242
+ plugins.each { |kit| kits[kit.name] ||= kit }
243
+ kits
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "cache_confirm"
5
+ require_relative "toolkit"
6
+
7
+ module Samagotchi
8
+ # /toolkit, the session's toolkit (Session#toolkit): with no argument,
9
+ # the tools its next turn declares with their estimated tokens, the
10
+ # toolkit and the tools it leaves out; `/toolkit NAME` puts the session
11
+ # on another toolkit (its own adds and removes go), `/toolkit add TOOL`
12
+ # and `/toolkit remove TOOL` change one tool. A change is saved at once
13
+ # and applies at the next turn's start (Engine#apply_toolkit!). The
14
+ # declared tools are the front of every request, so once the session has
15
+ # a model reply a change asks first (CacheConfirm); --yes applies it.
16
+ class ToolkitCommand
17
+ NAME = "/toolkit"
18
+ USAGE = "usage: #{NAME} [NAME | add TOOL | remove TOOL]".freeze
19
+ # Characters per token for the estimates (context.chars_per_token's
20
+ # default; a listing, not a meter).
21
+ CHARS_PER_TOKEN = 4.0
22
+
23
+ # @param engine [Engine] #session, #toolkit_explained, #toolkit_kits,
24
+ # #tools, #history?
25
+ # @param save [#call] saves the session after a change
26
+ def initialize(engine:, save:)
27
+ @engine = engine
28
+ @save = save
29
+ end
30
+
31
+ # @param args [String] what follows /toolkit (--yes anywhere)
32
+ # @param confirm [Boolean] ask before a change once the session has a
33
+ # model reply (CacheConfirm); false applies it at once
34
+ # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
35
+ # whether the toolkit changed, and the confirm it asks for (nothing
36
+ # changed then)
37
+ # @raise [ArgumentError] an unknown toolkit or tool, or bad words
38
+ def run(args, confirm: true)
39
+ words, yes = CacheConfirm.split(args, downcase: false)
40
+ return [listing(@engine.toolkit_explained), false, nil] if words.empty?
41
+
42
+ session = @engine.session or raise ArgumentError, "no session yet"
43
+ before = @engine.toolkit_explained
44
+ after = @engine.toolkit_explained(changed(before.toolkit, words.split))
45
+ # Nothing the prompt carries changes (full +delegate on full): not saved.
46
+ return [listing(before) + "\n(no change to what the next turn declares)", false, nil] if after.prompt_key == before.prompt_key
47
+
48
+ cost = Toolkit.cache_cost(before.prompt_key, after.prompt_key)
49
+ if confirm && !yes && CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: !cost.nil?)
50
+ step = CacheConfirm.confirm(NAME, words)
51
+ return [[change_line(before, after), "The tools are the front of every request: the whole prompt is read " \
52
+ "again from them on.", step.text].join("\n"), false, step]
53
+ end
54
+
55
+ session.toolkit = after.toolkit
56
+ @save.call(session)
57
+ [[listing(after), "From the next turn's start (a running turn keeps its tools)."].join("\n"), true, nil]
58
+ end
59
+
60
+ # The toolkit, its declared tools with tokens, and the ones left out.
61
+ def listing(explained)
62
+ toolkit = explained.toolkit
63
+ lines = ["toolkit: #{toolkit.label}#{" (applies at the next turn's start)" if explained.pending}"]
64
+ if (fallback = explained.resolved.fallback)
65
+ lines << " #{fallback}: every tool is declared (full) until it resolves"
66
+ end
67
+ declared = explained.declared
68
+ lines << " declared (#{declared.size}, ~#{format_tokens(declared.sum { |entry| tokens(entry) })} tokens): " \
69
+ "#{declared.map { |entry| "#{entry.name} ~#{format_tokens(tokens(entry))}" }.join(", ")}"
70
+ left_out = explained.left_out
71
+ lines << " not loaded: #{left_out.empty? ? "none" : left_out.join(", ")}"
72
+ lines << " toolkits: #{@engine.toolkit_kits.keys.join(", ")}; #{USAGE.delete_prefix("usage: ")} changes it"
73
+ lines.join("\n")
74
+ end
75
+
76
+ private
77
+
78
+ # +toolkit+ after the words: NAME, add TOOL…, remove TOOL….
79
+ def changed(toolkit, words)
80
+ verb, *tools = words
81
+ case verb.downcase
82
+ when "add", "remove"
83
+ raise ArgumentError, "#{NAME} #{verb} needs a tool name\n#{USAGE}" if tools.empty?
84
+
85
+ tools.reduce(toolkit) do |kit, tool|
86
+ raise ArgumentError, "no tool named #{tool} in this session" unless @engine.tools.key?(tool)
87
+
88
+ verb.casecmp?("add") ? kit.adding(tool) : kit.removing(tool)
89
+ end
90
+ else
91
+ raise ArgumentError, "#{NAME} takes one toolkit name\n#{USAGE}" unless tools.empty?
92
+
93
+ kits = @engine.toolkit_kits
94
+ raise ArgumentError, "no toolkit named #{verb} (toolkits: #{kits.keys.join(", ")})" unless kits.key?(verb)
95
+
96
+ Toolkit.new(name: verb)
97
+ end
98
+ end
99
+
100
+ def change_line(before, after)
101
+ added = names(after) - names(before)
102
+ removed = names(before) - names(after)
103
+ parts = []
104
+ parts << "declares #{added.join(", ")}" unless added.empty?
105
+ parts << "leaves out #{removed.join(", ")}" unless removed.empty?
106
+ parts << "declares the same tools under its own name" if parts.empty?
107
+ "Switching to #{after.toolkit.label} #{parts.join("; ")}."
108
+ end
109
+
110
+ def names(explained) = explained.declared.map(&:name)
111
+
112
+ # A tool's schema in tokens, estimated from its JSON.
113
+ def tokens(entry) = (JSON.generate(entry.schema).length / CHARS_PER_TOKEN).round
114
+
115
+ def format_tokens(count) = count >= 1000 ? format("%.1fk", count / 1000.0) : count.to_s
116
+ end
117
+ end