samagotchi 0.64.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +20 -3
  6. data/docs/cli.md +36 -2
  7. data/docs/configuration.md +177 -8
  8. data/docs/context.md +4 -1
  9. data/docs/internals/engine.md +19 -4
  10. data/docs/internals/prompt-caching.md +10 -2
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +163 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +13 -2
  15. data/docs/sub-agent.md +3 -1
  16. data/lib/samagotchi/bridge.rb +11 -1
  17. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
  21. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
  22. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  23. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
  24. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  25. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  28. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  31. data/lib/samagotchi/cache_confirm.rb +9 -6
  32. data/lib/samagotchi/client.rb +4 -2
  33. data/lib/samagotchi/commands/registry.rb +25 -8
  34. data/lib/samagotchi/config.rb +13 -5
  35. data/lib/samagotchi/engine.rb +151 -11
  36. data/lib/samagotchi/generation_phase.rb +17 -0
  37. data/lib/samagotchi/host_config.rb +27 -6
  38. data/lib/samagotchi/host_registry.rb +34 -11
  39. data/lib/samagotchi/idle_client.rb +62 -25
  40. data/lib/samagotchi/idle_recap.rb +5 -2
  41. data/lib/samagotchi/idle_target.rb +30 -7
  42. data/lib/samagotchi/kernel_loop.rb +9 -1
  43. data/lib/samagotchi/llm/adapters.rb +41 -0
  44. data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
  45. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  46. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  47. data/lib/samagotchi/llm/api_key.rb +26 -9
  48. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  49. data/lib/samagotchi/llm/dialect.rb +57 -0
  50. data/lib/samagotchi/llm/errors.rb +81 -12
  51. data/lib/samagotchi/llm/model_listing.rb +111 -0
  52. data/lib/samagotchi/llm/model_result.rb +9 -2
  53. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  54. data/lib/samagotchi/model_profile.rb +1 -1
  55. data/lib/samagotchi/plugin/api.rb +80 -7
  56. data/lib/samagotchi/plugin/context.rb +22 -1
  57. data/lib/samagotchi/plugin/loader.rb +3 -2
  58. data/lib/samagotchi/reasoning_history.rb +14 -4
  59. data/lib/samagotchi/recap_setup.rb +2 -1
  60. data/lib/samagotchi/self_report.rb +18 -28
  61. data/lib/samagotchi/send_command.rb +23 -7
  62. data/lib/samagotchi/session.rb +21 -3
  63. data/lib/samagotchi/session_commands.rb +16 -1
  64. data/lib/samagotchi/session_manager.rb +4 -1
  65. data/lib/samagotchi/session_metrics.rb +6 -11
  66. data/lib/samagotchi/session_setup.rb +14 -7
  67. data/lib/samagotchi/steer_cut.rb +4 -0
  68. data/lib/samagotchi/system_prompt.rb +16 -1
  69. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  70. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
  71. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  72. data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
  73. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  74. data/lib/samagotchi/terminal_ui.rb +18 -2
  75. data/lib/samagotchi/tool_runner.rb +13 -1
  76. data/lib/samagotchi/toolkit.rb +246 -0
  77. data/lib/samagotchi/toolkit_command.rb +117 -0
  78. data/lib/samagotchi/tools/registry.rb +43 -6
  79. data/lib/samagotchi/turn_note.rb +22 -5
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +14 -1
  82. data/lib/samagotchi/web/public/activity.js +3 -0
  83. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  84. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  85. data/lib/samagotchi/web/public/app.js +111 -20
  86. data/lib/samagotchi/web/public/commands.js +4 -2
  87. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  88. data/lib/samagotchi/web/public/index.html +38 -0
  89. data/lib/samagotchi/web/public/info_bar.js +5 -3
  90. data/lib/samagotchi/web/public/live_turn.js +8 -0
  91. data/lib/samagotchi/web/public/session_state.js +3 -0
  92. data/lib/samagotchi/web/public/stage_model.js +27 -7
  93. data/lib/samagotchi/web/public/stage_view.js +32 -10
  94. data/lib/samagotchi/web/public/stream_handlers.js +21 -2
  95. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  96. data/lib/samagotchi/web/public/turn_events.js +22 -8
  97. data/lib/samagotchi/web/public/turn_html.js +20 -4
  98. data/lib/samagotchi/web/public/turn_model.js +16 -9
  99. data/lib/samagotchi/web/public/turn_view.js +6 -4
  100. data/lib/samagotchi/worker.rb +2 -0
  101. metadata +17 -1
@@ -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
@@ -35,8 +35,15 @@ module Samagotchi
35
35
 
36
36
  def initialize
37
37
  @entries = {}
38
+ @toolkit = nil
38
39
  end
39
40
 
41
+ # The session's resolved toolkit (Toolkit::Resolved), which the lists
42
+ # below and #offered filter by; nil declares every tool (full). The
43
+ # Engine sets it at a turn's start, before the prompt is built, and
44
+ # only there: a request's declared tools never change mid-turn.
45
+ attr_accessor :toolkit
46
+
40
47
  # @return [Entry]
41
48
  def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil)
42
49
  raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
@@ -59,19 +66,40 @@ module Samagotchi
59
66
  # @return [Entry, nil]
60
67
  def offered(name, layers: [])
61
68
  entry = self[name]
62
- entry if entry&.offered?(layers)
69
+ entry if entry&.offered?(layers) && declared?(entry)
70
+ end
71
+
72
+ # The entry +name+ names when the session's toolkit leaves it out (a
73
+ # real tool the model can't call here), nil otherwise.
74
+ # @return [Entry, nil]
75
+ def left_out(name, layers: [])
76
+ entry = self[name]
77
+ entry if entry&.offered?(layers) && !declared?(entry)
78
+ end
79
+
80
+ # The tools +toolkit+ (the session's by default) leaves out of a turn
81
+ # under +layers+.
82
+ # @return [Array<String>] in declaration order
83
+ def left_out_names(layers: [], toolkit: @toolkit)
84
+ ordered(@entries.values.select { |entry| entry.offered?(layers) && !declared?(entry, toolkit) }).map(&:name)
63
85
  end
64
86
 
87
+ # Every tool +source+ registered, declared or not (a plugin's tool set
88
+ # changing: Plugin::Api.apply_tools).
89
+ # @return [Array<Entry>]
90
+ def by_source(source) = @entries.values.select { |entry| entry.source == source }
91
+
65
92
  # The tools a turn offers is per turn: +layers+, the turn's LLM
66
93
  # context layers (LLMContextStrategy), adds the tools gated by them.
67
94
  # Without layers (none, the default) the list is what it always was.
68
95
  # @return [Array<String>] in declaration order
69
- def names(layers: []) = entries(layers: layers).map(&:name)
96
+ def names(layers: [], toolkit: @toolkit) = entries(layers: layers, toolkit: toolkit).map(&:name)
70
97
 
71
- # @return [Array<Entry>] in declaration order
72
- def entries(layers: [])
73
- core, added = @entries.values.select { |entry| entry.offered?(layers) }.partition(&:core?)
74
- core + added.sort_by { |entry| [entry.source.to_s, entry.name] }
98
+ # @return [Array<Entry>] in declaration order: the ones the turn's
99
+ # layers offer and the session's toolkit declares
100
+ # (+toolkit+: another one's, for what a change would declare)
101
+ def entries(layers: [], toolkit: @toolkit)
102
+ ordered(@entries.values.select { |entry| entry.offered?(layers) && declared?(entry, toolkit) })
75
103
  end
76
104
 
77
105
  # @return [Array<Hash>] the schemas, in declaration order
@@ -81,6 +109,15 @@ module Samagotchi
81
109
  @entries.freeze
82
110
  super
83
111
  end
112
+
113
+ private
114
+
115
+ def declared?(entry, toolkit = @toolkit) = toolkit.nil? || toolkit.declares?(entry)
116
+
117
+ def ordered(entries)
118
+ core, added = entries.partition(&:core?)
119
+ core + added.sort_by { |entry| [entry.source.to_s, entry.name] }
120
+ end
84
121
  end
85
122
  end
86
123
  end
@@ -136,13 +136,27 @@ module Samagotchi
136
136
  # text cut to its last STEP_CHARS. Never
137
137
  # sent: the payload builders send a system message's content, and the
138
138
  # copies for hooks, plugins and the recap drop it (AnswerDisplay.strip).
139
- def empty(retries: 0, steps: [])
139
+ # +refusal+: the provider refused the answer (ModelResult#refusal:
140
+ # {category:} or {}), kept on the marker and said in the note.
141
+ def empty(retries: 0, steps: [], refusal: nil)
140
142
  marker = { retries: retries.to_i }
141
143
  marker[:steps] = steps.map { |step| capped_step(step) } unless steps.empty?
142
- message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
144
+ marker[:refusal] = refusal if refusal
145
+ what = if refusal
146
+ "the previous turn's answer was refused by the provider#{refusal_category(refusal)}"
147
+ else
148
+ "the previous turn ended with no visible answer (thinking only, or nothing)"
149
+ end
150
+ message("#{what}. The user's last message is still unanswered.")
143
151
  .merge(EMPTY_ANSWER => marker)
144
152
  end
145
153
 
154
+ # " (cyber)" for a refusal that names its category (either key type), else "".
155
+ def refusal_category(refusal)
156
+ category = (refusal[:category] || refusal["category"] if refusal.is_a?(Hash)).to_s.strip
157
+ category.empty? ? "" : " (#{one_line(category)})"
158
+ end
159
+
146
160
  def capped_step(step)
147
161
  step.slice(:role, :content, :thinking).to_h do |key, value|
148
162
  [key, key == :role || !value.is_a?(String) ? value : tail_of(value)]
@@ -167,9 +181,12 @@ module Samagotchi
167
181
  end
168
182
 
169
183
  # The notice every UI shows for a turn that ended with no answer:
170
- # "no answer: the model returned nothing (after 1 retry)" (turn_events.js
171
- # emptyAnswerLine words it the same).
172
- def empty_answer_line(retries)
184
+ # "no answer: the model returned nothing (after 1 retry)", or for a
185
+ # +refusal+ (the marker's) "no answer: refused by the provider (cyber)"
186
+ # (turn_events.js emptyAnswerLine words it the same).
187
+ def empty_answer_line(retries, refusal: nil)
188
+ return "no answer: refused by the provider#{refusal_category(refusal)}" if refusal
189
+
173
190
  count = retries.to_i
174
191
  after = if count.positive?
175
192
  " (after #{count} #{count == 1 ? "retry" : "retries"})"
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.64.0"
4
+ VERSION = "0.66.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -566,6 +566,14 @@ module Samagotchi
566
566
  LLMContextStrategy.for_session(session, registry: host_registry)
567
567
  end
568
568
 
569
+ # A session's toolkit as its file names it ({name:, label:}), nil on
570
+ # full: without a worker nothing resolves it (a bundle's kits load in
571
+ # the worker), so the tools it leaves out aren't known here.
572
+ def toolkit_for(session)
573
+ toolkit = session.toolkit
574
+ toolkit && !toolkit.full? ? { name: toolkit.name, label: toolkit.label } : nil
575
+ end
576
+
569
577
  # A session's thinking level with where it came from and its own
570
578
  # (SettingExplained#summary), as its worker would resolve it; nil
571
579
  # when it can't be. Config and the registry only: no network.
@@ -848,6 +856,10 @@ module Samagotchi
848
856
  # native host), else the server's reading (an older worker, none).
849
857
  history = snapshot&.key?("reasoning_history") ? snapshot["reasoning_history"] : reasoning_history_for(session)
850
858
  session_json = session_json.merge(reasoning_history: history)
859
+ # Its toolkit when it isn't full (the tools chip): the worker's, which
860
+ # resolved it, else the name saved in the session file.
861
+ toolkit = snapshot&.key?("toolkit") ? snapshot["toolkit"] : toolkit_for(session)
862
+ session_json = session_json.merge(toolkit: toolkit)
851
863
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
852
864
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
853
865
  json_response(200, {
@@ -1944,7 +1956,8 @@ module Samagotchi
1944
1956
  did = step.is_a?(Hash) ? MessageParts.for_message(step, [], cwd: cwd) : nil
1945
1957
  { role: "assistant", content: "", parts: did } if did
1946
1958
  end
1947
- items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i }
1959
+ refusal = marker[:refusal] || marker["refusal"]
1960
+ items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i, refusal: refusal }.compact
1948
1961
  end
1949
1962
 
1950
1963
  # The last turn failed and its prompt went back to the user: the
@@ -103,8 +103,11 @@ export function addStarted(model, event = {}, now = null) {
103
103
  return { row, created: true };
104
104
  }
105
105
 
106
+ // A replayed running call a snapshot timed (its elapsed_ms so far) started
107
+ // that long before +now+; any other replayed start is no clock.
106
108
  function markStart(row, event, now) {
107
109
  if (now !== null && !event.replayed) row.startedAt = now;
110
+ else if (now !== null && event.elapsed_ms != null) row.startedAt = now - Number(event.elapsed_ms);
108
111
  else delete row.startedAt;
109
112
  }
110
113
 
@@ -0,0 +1,109 @@
1
+ // Annotation actions: a bundle's pills in the Annotate bar (chi.annotation,
2
+ // docs/plugins.md). Each prepares a command line of its plugin with the
3
+ // selection filled into its template under it:
4
+ //
5
+ // /claude validate
6
+ // From your thinking:
7
+ // > the quoted line
8
+ //
9
+ // and fills the composer with it (the note goes under), or sends it at once
10
+ // (send: true). The pills ride on the session's command listing (the
11
+ // worker's snapshot), so a muted bundle's go with its command. Pure: no DOM.
12
+ // A module of its own: static JS is cached at unversioned URLs, and a new
13
+ // export on an old module would blank the page.
14
+
15
+ import { quoteBlock } from "./annotations.js";
16
+
17
+ // How many plugin pills the bar shows; the rest go behind "⋯".
18
+ export const INLINE_ACTIONS = 3;
19
+
20
+ // The session's commands' annotations, flattened in listing order: {label,
21
+ // line, template, send, command, anytime, source}. anytime: the command
22
+ // runs mid-turn (a send pill needs it while a turn runs).
23
+ export function annotationActions(commands) {
24
+ const out = [];
25
+ for (const c of Array.isArray(commands) ? commands : []) {
26
+ if (!c || typeof c.name !== "string" || !Array.isArray(c.annotations)) continue;
27
+ for (const a of c.annotations) {
28
+ if (!a || typeof a.label !== "string" || !a.label || typeof a.line !== "string" || !a.line) continue;
29
+ out.push({
30
+ label: a.label,
31
+ line: a.line,
32
+ template: typeof a.template === "string" && a.template ? a.template : "{quote}",
33
+ send: a.send === true,
34
+ command: c.name,
35
+ anytime: c.mid_turn === "anytime" || c.anytime === true,
36
+ source: typeof c.source === "string" ? c.source : "",
37
+ });
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+
43
+ // Whether two action lists draw the same pills (the bar redraws only on a
44
+ // change).
45
+ export function sameActions(a, b) {
46
+ return JSON.stringify(a) === JSON.stringify(b);
47
+ }
48
+
49
+ // The selection as templates read it: {quote} the labelled block Annotate
50
+ // makes (annotations.js quoteBlock), {text} the selection alone with the
51
+ // same trimming (CRLF → LF, each line's trailing blanks, blank lines at
52
+ // either end; indentation kept), {source} where it came from (answer,
53
+ // thinking, step, tool, user). null when it holds no text.
54
+ export function selectionFields(raw, source = {}) {
55
+ const quote = quoteBlock(raw, source);
56
+ if (!quote) return null;
57
+ const lines = String(raw ?? "").replace(/\r\n?/g, "\n").split("\n").map((l) => l.replace(/\s+$/, ""));
58
+ while (lines.length && !lines[0]) lines.shift();
59
+ while (lines.length && !lines[lines.length - 1]) lines.pop();
60
+ return { quote, text: lines.join("\n"), source: source.kind || "answer" };
61
+ }
62
+
63
+ // +template+ with {quote}, {text} and {source} filled from +fields+; any
64
+ // other {x} stays as written.
65
+ export function fillTemplate(template, fields) {
66
+ return String(template ?? "").replace(/\{(quote|text|source)\}/g, (_, key) => String(fields?.[key] ?? ""));
67
+ }
68
+
69
+ // The line the pill prepares: its command line, the filled template under it.
70
+ export function actionLine(action, fields) {
71
+ return `${action.line}\n${fillTemplate(action.template, fields)}`;
72
+ }
73
+
74
+ // The composer's text after a fill pill (+line+: actionLine's):
75
+ // - empty: the line;
76
+ // - it already starts with the same command line: the filled part is
77
+ // appended (several selections collect under one command);
78
+ // - another draft: the line on top, the draft under it, after a blank line
79
+ // (nothing leaves chi without a click on Send).
80
+ export function composerWithAction(value, action, line) {
81
+ const draft = String(value ?? "");
82
+ if (!draft.trim()) return line;
83
+ const filled = line.slice(action.line.length + 1);
84
+ if (draft.trimStart().split("\n")[0].trim() === action.line) {
85
+ const before = draft.replace(/\s+$/, "");
86
+ return `${before}\n\n${filled}`;
87
+ }
88
+ return `${line.replace(/\s+$/, "")}\n\n${draft.replace(/^\s*\n/, "")}`;
89
+ }
90
+
91
+ // Whether a pill can be clicked: a fill pill always; a send pill while a
92
+ // turn runs only when its command runs mid-turn (anytime), else it would come
93
+ // back busy.
94
+ export function actionEnabled(action, running) {
95
+ return !action.send || !running || action.anytime;
96
+ }
97
+
98
+ // A pill's tooltip: its label and bundle, that a send pill sends at once,
99
+ // and why it is disabled.
100
+ export function actionTitle(action, running) {
101
+ const base = action.source ? `${action.label} (${action.source})` : action.label;
102
+ if (!actionEnabled(action, running)) return `${base}: ${action.command} waits for the turn to end`;
103
+ return action.send ? `${base}: sends at once` : base;
104
+ }
105
+
106
+ // The pills shown inline and the ones behind "⋯".
107
+ export function splitInline(actions, max = INLINE_ACTIONS) {
108
+ return { inline: actions.slice(0, max), more: actions.slice(max) };
109
+ }