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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/architecture.md +20 -3
- data/docs/cli.md +36 -2
- data/docs/configuration.md +177 -8
- data/docs/context.md +4 -1
- data/docs/internals/engine.md +19 -4
- 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 +13 -2
- data/docs/sub-agent.md +3 -1
- data/lib/samagotchi/bridge.rb +11 -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/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +13 -5
- data/lib/samagotchi/engine.rb +151 -11
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +27 -6
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- 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/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/send_command.rb +23 -7
- data/lib/samagotchi/session.rb +21 -3
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- 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/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -1
- data/lib/samagotchi/web/public/activity.js +3 -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 +111 -20
- data/lib/samagotchi/web/public/commands.js +4 -2
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +38 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +21 -2
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +16 -9
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/worker.rb +2 -0
- 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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)"
|
|
171
|
-
#
|
|
172
|
-
|
|
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"})"
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|