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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/cli.md +11 -1
- data/docs/configuration.md +51 -7
- data/docs/context.md +4 -1
- data/docs/internals/engine.md +4 -1
- data/docs/internals/prompt-caching.md +10 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +163 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +10 -0
- data/docs/sub-agent.md +3 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +8 -1
- data/lib/samagotchi/engine.rb +112 -4
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
- data/lib/samagotchi/plugin/api.rb +80 -7
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +3 -2
- data/lib/samagotchi/send_command.rb +23 -7
- data/lib/samagotchi/session.rb +18 -1
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui.rb +18 -2
- data/lib/samagotchi/tool_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/registry.rb +43 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -0
- data/lib/samagotchi/web/public/annotate_actions.js +109 -0
- data/lib/samagotchi/web/public/annotate_bar.js +84 -2
- data/lib/samagotchi/web/public/app.js +21 -7
- data/lib/samagotchi/web/public/commands.js +4 -2
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stream_handlers.js +13 -0
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/worker.rb +2 -0
- metadata +10 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
@
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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.
|