samagotchi 0.71.0 → 0.72.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 +45 -1
- data/docs/architecture.md +14 -8
- data/docs/cli.md +3 -2
- data/docs/configuration.md +23 -13
- data/docs/hooks.md +1 -1
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/engine.md +38 -13
- data/docs/internals/llm-context-live.md +1 -1
- data/docs/internals/prompt-caching.md +6 -0
- data/docs/plugins.md +18 -7
- data/docs/sessions.md +3 -1
- data/docs/testing.md +10 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -3
- data/lib/samagotchi/bridge.rb +5 -2
- data/lib/samagotchi/broadcast/scope_card.rb +2 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -7
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +2 -2
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +2 -2
- data/lib/samagotchi/bundles/coordinator/manifest.yml +8 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +10 -0
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +7 -25
- data/lib/samagotchi/context_note.rb +2 -6
- data/lib/samagotchi/effective_model.rb +1 -1
- data/lib/samagotchi/engine.rb +91 -213
- data/lib/samagotchi/engine_peers.rb +2 -0
- data/lib/samagotchi/event_routes.rb +83 -0
- data/lib/samagotchi/hook_routes.rb +16 -37
- data/lib/samagotchi/host_config.rb +1 -1
- data/lib/samagotchi/host_keys.rb +0 -2
- data/lib/samagotchi/paths.rb +13 -0
- data/lib/samagotchi/plugin/api.rb +11 -5
- data/lib/samagotchi/plugin/peer.rb +5 -8
- data/lib/samagotchi/prompt_files.rb +2 -4
- data/lib/samagotchi/session.rb +2 -1
- data/lib/samagotchi/session_manager.rb +6 -16
- data/lib/samagotchi/session_model_settings.rb +227 -0
- data/lib/samagotchi/session_setup.rb +2 -1
- data/lib/samagotchi/system_prompt.rb +18 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -14
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
- data/lib/samagotchi/tool_activity.rb +1 -0
- data/lib/samagotchi/tool_declarations.rb +4 -0
- data/lib/samagotchi/toolkit.rb +55 -24
- data/lib/samagotchi/toolkit_binding.rb +7 -5
- data/lib/samagotchi/toolkit_command.rb +28 -5
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +32 -6
- data/lib/samagotchi/tools/list_sessions.rb +2 -2
- data/lib/samagotchi/tools/peers.rb +5 -2
- data/lib/samagotchi/tools/register_reminder.rb +1 -0
- data/lib/samagotchi/tools/registry.rb +1 -1
- data/lib/samagotchi/tools/tool_index.rb +15 -4
- data/lib/samagotchi/turn_note.rb +4 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +13 -10
- data/lib/samagotchi/web/public/app.js +20 -18
- data/lib/samagotchi/web/public/bubbles.js +1 -1
- data/lib/samagotchi/web/public/cards_place.js +6 -10
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/chip_popover.js +3 -1
- data/lib/samagotchi/web/public/composer.js +13 -6
- data/lib/samagotchi/web/public/context_chips.js +25 -12
- data/lib/samagotchi/web/public/data.js +5 -1
- data/lib/samagotchi/web/public/format.js +5 -0
- data/lib/samagotchi/web/public/sessions_list.js +2 -3
- data/lib/samagotchi/web/public/stage_frame.js +1 -1
- data/lib/samagotchi/web/public/stage_view.js +8 -2
- data/lib/samagotchi/web/public/stream_handlers.js +6 -3
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/toolkit_chip.js +3 -1
- data/lib/samagotchi/web/session_hub.rb +8 -0
- metadata +3 -1
data/lib/samagotchi/toolkit.rb
CHANGED
|
@@ -78,21 +78,25 @@ module Samagotchi
|
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
# What the prompt carries of it: the declared names, the reachable
|
|
81
|
-
# and removed ones (the not-loaded line, find_tools' chi line)
|
|
82
|
-
#
|
|
83
|
-
# keys build the same prompt (/toolkit's "no
|
|
81
|
+
# and removed ones (the not-loaded line, find_tools' chi line), when
|
|
82
|
+
# either list has any the kit name the line gives, and the kit's
|
|
83
|
+
# prompt text. Two equal keys build the same prompt (/toolkit's "no
|
|
84
|
+
# change", its cost).
|
|
84
85
|
def prompt_key
|
|
85
|
-
[declared.map(&:name), reachable, removed, reachable.empty? && removed.empty? ? nil : resolved.name
|
|
86
|
+
[declared.map(&:name), reachable, removed, reachable.empty? && removed.empty? ? nil : resolved.name,
|
|
87
|
+
resolved.prompt]
|
|
86
88
|
end
|
|
87
89
|
|
|
88
90
|
# What the UIs show (the snapshot, command_ran): nil when it declares
|
|
89
91
|
# every tool on full (full, full +delegate), which has no chip.
|
|
90
|
-
# +left_out+ is +reachable+ and +removed+ together
|
|
92
|
+
# +left_out+ is +reachable+ and +removed+ together; +prompt+ (the
|
|
93
|
+
# kit's text) only when the kit has one.
|
|
91
94
|
def summary
|
|
92
95
|
return nil if toolkit.name == Toolkit::FULL && left_out.empty? && resolved.fallback.nil?
|
|
93
96
|
|
|
94
|
-
{ name: toolkit.name, label: toolkit.label, left_out: left_out, reachable: reachable,
|
|
95
|
-
|
|
97
|
+
summary = { name: toolkit.name, label: toolkit.label, left_out: left_out, reachable: reachable,
|
|
98
|
+
removed: removed, fallback: resolved.fallback }
|
|
99
|
+
resolved.prompt ? summary.merge(prompt: resolved.prompt) : summary
|
|
96
100
|
end
|
|
97
101
|
end
|
|
98
102
|
|
|
@@ -102,14 +106,30 @@ module Samagotchi
|
|
|
102
106
|
# A named list of declared tools: +tools+ (:all, every built-in; or the
|
|
103
107
|
# built-in names) when it has no +base+, else its +base+'s with +add+
|
|
104
108
|
# and +remove+. +source+: "core", "config", or the bundle that declared
|
|
105
|
-
# it (chi.toolkit).
|
|
106
|
-
|
|
107
|
-
|
|
109
|
+
# it (chi.toolkit). +prompt+: text the sessions on it get in their
|
|
110
|
+
# system prompt, verbatim (stripped); nil inherits the base's, "" clears
|
|
111
|
+
# it.
|
|
112
|
+
Kit = Data.define(:name, :base, :tools, :add, :remove, :source, :prompt) do
|
|
113
|
+
def initialize(name:, base: nil, tools: nil, add: [], remove: [], source: "core", prompt: nil)
|
|
108
114
|
super(name: name.to_s, base: base&.to_s, tools: tools == :all ? :all : Toolkit.tool_names(tools),
|
|
109
|
-
add: Toolkit.tool_names(add), remove: Toolkit.tool_names(remove), source: source.to_s
|
|
115
|
+
add: Toolkit.tool_names(add), remove: Toolkit.tool_names(remove), source: source.to_s,
|
|
116
|
+
prompt: prompt.is_a?(String) ? prompt.strip.freeze : nil)
|
|
110
117
|
end
|
|
111
118
|
end
|
|
112
119
|
|
|
120
|
+
# The longest kit prompt, in characters: it is a cached prefix, not a
|
|
121
|
+
# document (long text belongs in append_system_prompt:).
|
|
122
|
+
PROMPT_MAX = 2000
|
|
123
|
+
|
|
124
|
+
# Why +value+ can't be a kit's prompt (text, at most PROMPT_MAX
|
|
125
|
+
# characters once stripped), nil when it can (nil too: no prompt).
|
|
126
|
+
def self.prompt_error(value)
|
|
127
|
+
return nil if value.nil?
|
|
128
|
+
return "prompt must be text" unless value.is_a?(String)
|
|
129
|
+
|
|
130
|
+
"prompt is over #{PROMPT_MAX} characters" if value.strip.length > PROMPT_MAX
|
|
131
|
+
end
|
|
132
|
+
|
|
113
133
|
# The built-ins `lean` declares: the file and shell tools, memories,
|
|
114
134
|
# the task tools (execute's hand-over, turn notes and task_wait's tail
|
|
115
135
|
# name the trio), questions, web_fetch, and context_read (core notes
|
|
@@ -133,14 +153,17 @@ module Samagotchi
|
|
|
133
153
|
# an LLM context layer gates (forget_outputs), but for +removed+;
|
|
134
154
|
# +added+ declares any registered tool. +fallback+: why the session's
|
|
135
155
|
# own toolkit couldn't be resolved (this is FULL then), nil when it was.
|
|
136
|
-
|
|
137
|
-
|
|
156
|
+
# +prompt+: the kit's prompt text (its own or its base's), nil without.
|
|
157
|
+
Resolved = Data.define(:name, :all_core, :core, :added, :removed, :fallback, :prompt) do
|
|
158
|
+
def initialize(name:, all_core: false, core: [], added: [], removed: [], fallback: nil, prompt: nil)
|
|
138
159
|
super(name: name.to_s, all_core: all_core, core: core.dup.freeze, added: added.dup.freeze,
|
|
139
|
-
removed: removed.dup.freeze, fallback: fallback)
|
|
160
|
+
removed: removed.dup.freeze, fallback: fallback, prompt: prompt)
|
|
140
161
|
end
|
|
141
162
|
|
|
142
|
-
# Every built-in and
|
|
143
|
-
|
|
163
|
+
# Every built-in, nothing removed and no prompt text: what no toolkit
|
|
164
|
+
# declares (the Registry's nil). A kit on full's tools with a prompt
|
|
165
|
+
# is kept, so its text reaches the system prompt.
|
|
166
|
+
def everything? = all_core && removed.empty? && prompt.nil?
|
|
144
167
|
|
|
145
168
|
# @param entry [Tools::Registry::Entry]
|
|
146
169
|
def declares?(entry)
|
|
@@ -216,7 +239,8 @@ module Samagotchi
|
|
|
216
239
|
# @param after [Object] what it would carry after the change
|
|
217
240
|
def self.cache_cost(before, after) = before == after ? nil : :full
|
|
218
241
|
|
|
219
|
-
# @return [Resolved] +name+'s kit through its bases
|
|
242
|
+
# @return [Resolved] +name+'s kit through its bases; its prompt is the
|
|
243
|
+
# kit's own, else its base's ("" clears it: nil)
|
|
220
244
|
# @raise [Unresolved] unknown, or a cycle
|
|
221
245
|
def self.resolve_kit(name, kits, seen = [])
|
|
222
246
|
raise Unresolved, "no toolkit named #{name}" unless (kit = kits[name])
|
|
@@ -227,17 +251,20 @@ module Samagotchi
|
|
|
227
251
|
else
|
|
228
252
|
Resolved.new(name: name, all_core: kit.tools == :all, core: kit.tools == :all ? [] : kit.tools)
|
|
229
253
|
end
|
|
230
|
-
|
|
254
|
+
prompt = kit.prompt.nil? ? base.prompt : kit.prompt
|
|
255
|
+
base.changed(add: kit.add, remove: kit.remove).with(name: name, prompt: prompt.to_s.empty? ? nil : prompt)
|
|
231
256
|
end
|
|
232
257
|
|
|
233
258
|
# The keys a config kit entry takes.
|
|
234
|
-
KIT_KEYS = %w[base add remove].freeze
|
|
259
|
+
KIT_KEYS = %w[base add remove prompt].freeze
|
|
235
260
|
|
|
236
261
|
# The kits known: the built-ins, then config's toolkit.kits, then the
|
|
237
262
|
# plugins' (chi.toolkit). A config kit with a built-in's name is left
|
|
238
263
|
# out (one it can't redefine) with a line in +errors+, and so is one
|
|
239
|
-
# that isn't a {base:, add:, remove:} map. One with another key
|
|
240
|
-
# typo, a tools: list) still loads, with a line naming the key
|
|
264
|
+
# that isn't a {base:, add:, remove:, prompt:} map. One with another key
|
|
265
|
+
# (a typo, a tools: list) still loads, with a line naming the key, and
|
|
266
|
+
# so does one whose prompt isn't text or is over PROMPT_MAX characters:
|
|
267
|
+
# without the prompt, with a line saying why.
|
|
241
268
|
# @param config [Hash, nil] toolkit.kits
|
|
242
269
|
# @param plugins [Array<Kit>]
|
|
243
270
|
# @param errors [Array<String>] gets a line per kit left out or unknown key
|
|
@@ -249,14 +276,18 @@ module Samagotchi
|
|
|
249
276
|
if kits.key?(name)
|
|
250
277
|
errors << "toolkit.kits.#{name}: #{name} is a built-in toolkit"
|
|
251
278
|
elsif !spec.is_a?(Hash)
|
|
252
|
-
errors << "toolkit.kits.#{name}: expected {base:, add:, remove:}"
|
|
279
|
+
errors << "toolkit.kits.#{name}: expected {base:, add:, remove:, prompt:}"
|
|
253
280
|
else
|
|
254
281
|
spec = spec.transform_keys(&:to_s)
|
|
255
282
|
(spec.keys - KIT_KEYS).each do |key|
|
|
256
|
-
errors << "toolkit.kits.#{name}: unknown key #{key} (a kit takes base:, add:, remove:)"
|
|
283
|
+
errors << "toolkit.kits.#{name}: unknown key #{key} (a kit takes base:, add:, remove:, prompt:)"
|
|
284
|
+
end
|
|
285
|
+
if (problem = prompt_error(spec["prompt"]))
|
|
286
|
+
errors << "toolkit.kits.#{name}: #{problem}; the kit loads without it"
|
|
287
|
+
spec.delete("prompt")
|
|
257
288
|
end
|
|
258
289
|
kits[name] = Kit.new(name: name, base: spec["base"] || FULL, add: spec["add"], remove: spec["remove"],
|
|
259
|
-
source: "config")
|
|
290
|
+
prompt: spec["prompt"], source: "config")
|
|
260
291
|
end
|
|
261
292
|
end
|
|
262
293
|
plugins.each { |kit| kits[kit.name] ||= kit }
|
|
@@ -51,7 +51,9 @@ module Samagotchi
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# The session's toolkit resolved (Toolkit::Resolved): what its next
|
|
54
|
-
# turn declares. A plain full toolkit is nil, the Registry's "every
|
|
54
|
+
# turn declares. A plain full toolkit is nil, the Registry's "every
|
|
55
|
+
# tool"; a kit on full's tools with a prompt text is not (its text
|
|
56
|
+
# reaches the prompt).
|
|
55
57
|
def resolved(session, errors = [])
|
|
56
58
|
resolved = Toolkit.of(session&.toolkit).resolve(kits(errors))
|
|
57
59
|
resolved.everything? ? nil : resolved
|
|
@@ -106,10 +108,10 @@ module Samagotchi
|
|
|
106
108
|
|
|
107
109
|
# Resolve the session's toolkit into the Registry, on the turn thread at
|
|
108
110
|
# a turn's start (before its prompt is built), and build the prompts
|
|
109
|
-
# again when what it declares
|
|
110
|
-
# typo, an uninstalled bundle) declares
|
|
111
|
-
# once; the saved toolkit stays as it is.
|
|
112
|
-
# @return [Boolean] whether the declared tools changed
|
|
111
|
+
# again when what it declares (or its kit's prompt text) changed. A
|
|
112
|
+
# name that doesn't resolve (a typo, an uninstalled bundle) declares
|
|
113
|
+
# everything for now and says so once; the saved toolkit stays as it is.
|
|
114
|
+
# @return [Boolean] whether the declared tools (or the kit's prompt text) changed
|
|
113
115
|
def apply!(session, on_event = nil)
|
|
114
116
|
errors = []
|
|
115
117
|
resolved = Toolkit.of(session&.toolkit).resolve(kits(errors))
|
|
@@ -49,8 +49,7 @@ module Samagotchi
|
|
|
49
49
|
cost = Toolkit.cache_cost(before.prompt_key, after.prompt_key)
|
|
50
50
|
if confirm && !yes && CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: !cost.nil?)
|
|
51
51
|
step = CacheConfirm.confirm(NAME, words)
|
|
52
|
-
return [[change_line(before, after),
|
|
53
|
-
"again from them on.", step.text].join("\n"), false, step]
|
|
52
|
+
return [[change_line(before, after), reread_line(before, after), step.text].join("\n"), false, step]
|
|
54
53
|
end
|
|
55
54
|
|
|
56
55
|
session.toolkit = after.toolkit
|
|
@@ -58,9 +57,10 @@ module Samagotchi
|
|
|
58
57
|
[[listing(after), "From the next turn's start (a running turn keeps its tools)."].join("\n"), true, nil]
|
|
59
58
|
end
|
|
60
59
|
|
|
61
|
-
# The toolkit, its declared tools with tokens,
|
|
62
|
-
#
|
|
63
|
-
# brings one back), or, with
|
|
60
|
+
# The toolkit, its declared tools with tokens, its kit's prompt text
|
|
61
|
+
# (when it has one), and the ones left out: reachable with find_tools /
|
|
62
|
+
# call_tool, removed (only /toolkit add brings one back), or, with
|
|
63
|
+
# call_tool not declared, not loaded.
|
|
64
64
|
def listing(explained)
|
|
65
65
|
toolkit = explained.toolkit
|
|
66
66
|
lines = ["toolkit: #{toolkit.label}#{" (applies at the next turn's start)" if explained.pending}"]
|
|
@@ -70,6 +70,7 @@ module Samagotchi
|
|
|
70
70
|
declared = explained.declared
|
|
71
71
|
lines << " declared (#{declared.size}, ~#{format_tokens(declared.sum { |entry| tokens(entry) })} tokens): " \
|
|
72
72
|
"#{declared.map { |entry| "#{entry.name} ~#{format_tokens(tokens(entry))}" }.join(", ")}"
|
|
73
|
+
lines << prompt_line(explained.resolved.prompt) if explained.resolved.prompt
|
|
73
74
|
left_out = explained.left_out
|
|
74
75
|
if explained.reachable.empty?
|
|
75
76
|
lines << " not loaded: #{left_out.empty? ? "none" : left_out.join(", ")}"
|
|
@@ -119,12 +120,34 @@ module Samagotchi
|
|
|
119
120
|
parts << "leaves out #{left.join(", ")}" unless left.empty?
|
|
120
121
|
parts << "removes #{hard.join(", ")} (call_tool can't reach #{hard.size == 1 ? "it" : "them"})" unless hard.empty?
|
|
121
122
|
parts << "lets call_tool reach #{soft.join(", ")}" unless soft.empty?
|
|
123
|
+
# The kit's prompt text (Toolkit::Kit#prompt) is in the prompt too.
|
|
124
|
+
text = after.resolved.prompt
|
|
125
|
+
parts << (text ? "sends its kit's prompt text" : "drops the kit's prompt text") if text != before.resolved.prompt
|
|
122
126
|
parts << "declares the same tools under its own name" if parts.empty?
|
|
123
127
|
"Switching to #{after.toolkit.label} #{parts.join("; ")}."
|
|
124
128
|
end
|
|
125
129
|
|
|
130
|
+
# What the confirm says is read again: from the tools on (the front of
|
|
131
|
+
# every request), or from the kit's prompt text on when only that
|
|
132
|
+
# changes (Explained#prompt_key's last part: a kit on full's tools).
|
|
133
|
+
def reread_line(before, after)
|
|
134
|
+
if before.prompt_key[0...-1] == after.prompt_key[0...-1]
|
|
135
|
+
"The kit's prompt text is in the system prompt: the prompt is read again from it on."
|
|
136
|
+
else
|
|
137
|
+
"The tools are the front of every request: the whole prompt is read again from them on."
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
126
141
|
def names(explained) = explained.declared.map(&:name)
|
|
127
142
|
|
|
143
|
+
# The kit's prompt text (Toolkit::Kit#prompt): its first line, how many
|
|
144
|
+
# more and its estimated tokens.
|
|
145
|
+
def prompt_line(text)
|
|
146
|
+
first, *rest = text.lines.map(&:chomp)
|
|
147
|
+
more = rest.empty? ? "" : "+#{rest.size} line#{"s" if rest.size > 1}, "
|
|
148
|
+
" prompt: #{first} (#{more}~#{format_tokens((text.length / CHARS_PER_TOKEN).ceil)} tokens)"
|
|
149
|
+
end
|
|
150
|
+
|
|
128
151
|
# A tool's schema in tokens, estimated from its JSON.
|
|
129
152
|
def tokens(entry) = (JSON.generate(entry.schema).length / CHARS_PER_TOKEN).round
|
|
130
153
|
|
|
@@ -103,7 +103,8 @@ module Samagotchi
|
|
|
103
103
|
end,
|
|
104
104
|
Delegate::NAME => lambda do |call, kctx|
|
|
105
105
|
Delegate.call(call[:content], model: call[:model], session: call[:session], cwd: call[:cwd],
|
|
106
|
-
wait: call[:wait], timeout: call[:timeout],
|
|
106
|
+
toolkit: call[:toolkit], wait: call[:wait], timeout: call[:timeout],
|
|
107
|
+
peers: kctx.peers)
|
|
107
108
|
end,
|
|
108
109
|
DelegateResult::NAME => lambda do |call, kctx|
|
|
109
110
|
DelegateResult.call(call[:content], session: call[:session], timeout: call[:timeout], peers: kctx.peers)
|
|
@@ -19,8 +19,9 @@ module Samagotchi
|
|
|
19
19
|
# worker, started in this session's folder (or cwd:, a worktree or
|
|
20
20
|
# subfolder of the same repository) with the task as its first
|
|
21
21
|
# user message and the `delegated` system memory preloaded. It shows in
|
|
22
|
-
# every list as a child of this one and the user can attach to it
|
|
23
|
-
#
|
|
22
|
+
# every list as a child of this one and the user can attach to it, on
|
|
23
|
+
# toolkit: (a kit's name) or config's toolkit.child. With session:, a
|
|
24
|
+
# follow-up to a child that exists. Only the child's final
|
|
24
25
|
# reply comes back (DelegateWait), never its trace.
|
|
25
26
|
class Delegate
|
|
26
27
|
NAME = "delegate"
|
|
@@ -41,10 +42,12 @@ module Samagotchi
|
|
|
41
42
|
# @param model [String, nil] the child's model (an alias works); the parent's by default
|
|
42
43
|
# @param session [String, nil] a child's id or prefix: send the task there instead
|
|
43
44
|
# @param cwd [String, nil] the new child's folder (child_cwd checks it); the parent's by default
|
|
45
|
+
# @param toolkit [String, nil] the new child's toolkit, a kit's name (checked against the parent's
|
|
46
|
+
# kits, Peers#toolkit_names); config's toolkit.child by default
|
|
44
47
|
# @param wait [Boolean, String, nil] wait for the reply (default true)
|
|
45
48
|
# @param timeout [Integer, String, nil] seconds to wait (default DelegateWait::TIMEOUT_DEFAULT)
|
|
46
49
|
# @param peers [Peers, nil]
|
|
47
|
-
def self.call(content, model: nil, session: nil, cwd: nil, wait: nil, timeout: nil, peers: nil)
|
|
50
|
+
def self.call(content, model: nil, session: nil, cwd: nil, toolkit: nil, wait: nil, timeout: nil, peers: nil)
|
|
48
51
|
return "Error: this session's id is not known here" unless peers&.session_id
|
|
49
52
|
|
|
50
53
|
task = content.to_s.strip
|
|
@@ -56,10 +59,14 @@ module Samagotchi
|
|
|
56
59
|
timeout = parse_timeout(timeout)
|
|
57
60
|
|
|
58
61
|
cwd = cwd.to_s.strip
|
|
62
|
+
toolkit = toolkit.to_s.strip
|
|
59
63
|
child_id, warning = if session.to_s.strip.empty?
|
|
60
|
-
start_child(task, parent: parent, model: model, cwd: cwd,
|
|
64
|
+
start_child(task, parent: parent, model: model, cwd: cwd, toolkit: toolkit, state_dir: sd,
|
|
65
|
+
peers: peers)
|
|
61
66
|
elsif !cwd.empty?
|
|
62
67
|
"Error: cwd starts a new child; a follow-up with session keeps the child's folder"
|
|
68
|
+
elsif !toolkit.empty?
|
|
69
|
+
"Error: toolkit starts a new child; a follow-up keeps the child's toolkit"
|
|
63
70
|
else
|
|
64
71
|
follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
|
|
65
72
|
end
|
|
@@ -106,8 +113,9 @@ module Samagotchi
|
|
|
106
113
|
end
|
|
107
114
|
private_class_method :full_hint
|
|
108
115
|
|
|
116
|
+
# @param toolkit [String] a kit's name, "" for config's toolkit.child
|
|
109
117
|
# @return [String] the new child's id, or an Error: line
|
|
110
|
-
def self.start_child(task, parent:, model:, cwd:, state_dir:, peers:)
|
|
118
|
+
def self.start_child(task, parent:, model:, cwd:, toolkit:, state_dir:, peers:)
|
|
111
119
|
if parent.parent_id
|
|
112
120
|
return "Error: this session is a delegate of #{parent.parent_id}; delegated sessions don't delegate further"
|
|
113
121
|
end
|
|
@@ -115,6 +123,9 @@ module Samagotchi
|
|
|
115
123
|
folder = child_cwd(cwd, parent)
|
|
116
124
|
return folder if folder.start_with?("Error:")
|
|
117
125
|
|
|
126
|
+
unknown = unknown_toolkit(toolkit, peers)
|
|
127
|
+
return unknown if unknown
|
|
128
|
+
|
|
118
129
|
running = running_children(parent.id, state_dir: state_dir)
|
|
119
130
|
max = max_children
|
|
120
131
|
if running.size >= max
|
|
@@ -123,14 +134,29 @@ module Samagotchi
|
|
|
123
134
|
"(the most is #{max}, #{MAX_CHILDREN_KEY}): #{ids}. #{full_hint(peers)}"
|
|
124
135
|
end
|
|
125
136
|
|
|
137
|
+
setup = toolkit.empty? ? SessionSetup.new : SessionSetup.new(toolkit: Toolkit.new(name: toolkit))
|
|
126
138
|
child = SessionManager.spawn_session(prompt: task, working_directory: folder,
|
|
127
139
|
model_name: child_model(model, parent), memories: CHILD_MEMORIES,
|
|
128
|
-
parent_id: parent.id, delegate: true, state_dir: state_dir)
|
|
140
|
+
parent_id: parent.id, delegate: true, setup: setup, state_dir: state_dir)
|
|
129
141
|
DelegateWait.mark_started(parent.id, child, state_dir: state_dir)
|
|
130
142
|
[child.id, child.model_warning]
|
|
131
143
|
end
|
|
132
144
|
private_class_method :start_child
|
|
133
145
|
|
|
146
|
+
# The Error: line for a toolkit name the parent doesn't know
|
|
147
|
+
# (Peers#toolkit_names: its built-in, config and plugin kits), nil
|
|
148
|
+
# when it knows it, none was given, or the peers can't tell (a bare
|
|
149
|
+
# kernel: the child's worker resolves it, full when it can't).
|
|
150
|
+
def self.unknown_toolkit(toolkit, peers)
|
|
151
|
+
return nil if toolkit.empty? || !peers.respond_to?(:toolkit_names)
|
|
152
|
+
|
|
153
|
+
names = peers.toolkit_names
|
|
154
|
+
return nil if names.nil? || names.include?(toolkit)
|
|
155
|
+
|
|
156
|
+
"Error: no toolkit named #{toolkit} (toolkits: #{names.join(", ")})"
|
|
157
|
+
end
|
|
158
|
+
private_class_method :unknown_toolkit
|
|
159
|
+
|
|
134
160
|
# The new child's folder: the parent's, or +cwd+ (relative to the
|
|
135
161
|
# parent's) when it is a folder of the parent's repository, in any of
|
|
136
162
|
# its worktrees (one project_root, from the common git dir). Outside
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../
|
|
3
|
+
require_relative "../paths"
|
|
4
4
|
require_relative "../project_scope"
|
|
5
5
|
require_relative "../session"
|
|
6
6
|
require_relative "peers"
|
|
@@ -46,7 +46,7 @@ module Samagotchi
|
|
|
46
46
|
elsif own&.parent_id && s[:id] == own.parent_id then " parent"
|
|
47
47
|
else ""
|
|
48
48
|
end
|
|
49
|
-
"#{s[:short_id]} #{state} #{s[:busy] ? "running" : "idle"}#{relation} #{
|
|
49
|
+
"#{s[:short_id]} #{state} #{s[:busy] ? "running" : "idle"}#{relation} #{Paths.tilde(s[:cwd])} #{s[:preview].inspect}"
|
|
50
50
|
end
|
|
51
51
|
scope = project ? " in this project (#{File.basename(project)}; cwd \"/\" for every project)" : ""
|
|
52
52
|
<<~TEXT.chomp
|
|
@@ -11,11 +11,14 @@ module Samagotchi
|
|
|
11
11
|
# resolved ref, live after /model), and the session's own thinking level
|
|
12
12
|
# (thinking: nil without one) and reasoning_history level
|
|
13
13
|
# (reasoning_history: nil without one), and why the turn was canceled
|
|
14
|
-
# (cancel_reason: :signal, :manual, …; nil while it runs)
|
|
14
|
+
# (cancel_reason: :signal, :manual, …; nil while it runs), and the
|
|
15
|
+
# toolkit names the session knows (toolkit_names: the built-in, config
|
|
16
|
+
# and plugin kits a delegate may name; nil: not known, no check).
|
|
17
|
+
# Engine hands KernelLoop one that follows its current
|
|
15
18
|
# session (Engine::PeerView); this plain one is for specs and a kernel
|
|
16
19
|
# without an Engine.
|
|
17
20
|
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, :reasoning_history,
|
|
18
|
-
:cancel_reason, keyword_init: true) do
|
|
21
|
+
:cancel_reason, :toolkit_names, keyword_init: true) do
|
|
19
22
|
# @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
|
|
20
23
|
def cancelled?
|
|
21
24
|
cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
|
|
@@ -186,7 +186,7 @@ module Samagotchi
|
|
|
186
186
|
# removed or not: a source registered at load, or a built-in the
|
|
187
187
|
# toolkit leaves out without removing it.
|
|
188
188
|
def index_reaches?(toolkit)
|
|
189
|
-
@index.sourced || @entries.
|
|
189
|
+
@index.sourced || @entries.values.any? { |entry| soft?(entry, [], toolkit) }
|
|
190
190
|
end
|
|
191
191
|
|
|
192
192
|
# Not declared by +toolkit+ under +layers+. An index tool counts only
|
|
@@ -194,8 +194,6 @@ module Samagotchi
|
|
|
194
194
|
nil
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
-
def sources = @sources.dup
|
|
198
|
-
|
|
199
197
|
# Every group, now: the index's own (#registry_groups), then the
|
|
200
198
|
# sources'. A source group with a reserved name, or one an earlier
|
|
201
199
|
# group has, is left out of search, lookup and the description (said
|
|
@@ -301,8 +299,14 @@ module Samagotchi
|
|
|
301
299
|
def call(name, args)
|
|
302
300
|
tool = lookup(name)
|
|
303
301
|
return tool if tool.is_a?(String)
|
|
302
|
+
|
|
304
303
|
unless claimed(name.strip)&.first == tool.acts_as
|
|
305
|
-
|
|
304
|
+
# A bare acts_as of a source with no prefix: guardrails can't
|
|
305
|
+
# name it before its tool is listed (#claimed), so only
|
|
306
|
+
# <group>/<tool> is taken.
|
|
307
|
+
return "Error: call #{name.strip} as #{tool.full_name}" if name.strip == tool.acts_as
|
|
308
|
+
|
|
309
|
+
return "Error: the tool list changed while the call waited for its server; call #{tool.full_name} again"
|
|
306
310
|
end
|
|
307
311
|
return "Error: #{tool.full_name} can't be called: #{tool.refusal}" if tool.refusal
|
|
308
312
|
|
|
@@ -310,7 +314,11 @@ module Samagotchi
|
|
|
310
314
|
return "Error: args must be an object.#{ToolIndex.schema_note(tool)}" unless inner
|
|
311
315
|
return "Error: #{tool.full_name} can't be called through #{CALL_NAME} here" if tool.entry
|
|
312
316
|
|
|
313
|
-
|
|
317
|
+
# A publish may drop the group after the lookup.
|
|
318
|
+
source = source_of(tool.group)
|
|
319
|
+
return "Error: the tool list changed and #{tool.group} is gone; search with #{FIND_NAME}" unless source
|
|
320
|
+
|
|
321
|
+
source.run(tool, inner)
|
|
314
322
|
end
|
|
315
323
|
|
|
316
324
|
# The built-in a call_tool +call+ names, as the call that runs in its
|
|
@@ -534,6 +542,9 @@ module Samagotchi
|
|
|
534
542
|
return same.first if same.size == 1
|
|
535
543
|
return "Error: #{name} names #{same.map(&:full_name).join(" and ")}; call one as <group>/<tool>" if same.size > 1
|
|
536
544
|
|
|
545
|
+
# A built-in the session declares is no starting server's tool.
|
|
546
|
+
return "Error: #{name} is declared; call it directly" if @registry&.offered(name)&.core?
|
|
547
|
+
|
|
537
548
|
# A name with a group's <group>/ waits for that group only; any
|
|
538
549
|
# other name (an acts_as) for every one starting.
|
|
539
550
|
named = all.select { |group| name.start_with?("#{group.name}/") }
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -198,8 +198,11 @@ module Samagotchi
|
|
|
198
198
|
|
|
199
199
|
# The hidden nudge before a retry of an empty answer (EmptyAnswerRetry):
|
|
200
200
|
# the model never sees its empty generation, so it is told what happened.
|
|
201
|
+
# It asks to go on with the task, like cut_retry: "answer now" made a
|
|
202
|
+
# mid-task model stop and report before its last steps.
|
|
201
203
|
def empty_retry
|
|
202
|
-
retry_nudge(message("your last reply had no visible answer.
|
|
204
|
+
retry_nudge(message("your last reply had no visible answer. Continue the task with your next tool call, " \
|
|
205
|
+
"or answer if it is done."))
|
|
203
206
|
end
|
|
204
207
|
|
|
205
208
|
# The hidden nudge before a retry of a generation a plugin cut
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -46,6 +46,7 @@ require_relative "editor"
|
|
|
46
46
|
require_relative "message_parts"
|
|
47
47
|
require_relative "session_summary"
|
|
48
48
|
require_relative "../log"
|
|
49
|
+
require_relative "../paths"
|
|
49
50
|
require_relative "../redacted_inspect"
|
|
50
51
|
|
|
51
52
|
module Samagotchi
|
|
@@ -909,7 +910,14 @@ module Samagotchi
|
|
|
909
910
|
state = nil unless state.is_a?(Hash)
|
|
910
911
|
if state
|
|
911
912
|
status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
|
|
912
|
-
|
|
913
|
+
# No session file read here: the folder the answer's file refs must
|
|
914
|
+
# stay under is the worker's (its tail's working_directory), else the
|
|
915
|
+
# hub's session list entry's (a worker from before the field, or read
|
|
916
|
+
# through its snapshot); nil then without a hub or before it saw the
|
|
917
|
+
# session: refs aren't checked.
|
|
918
|
+
cwd = tail["working_directory"]
|
|
919
|
+
cwd = @hub&.working_directory(id) unless cwd.is_a?(String) && !cwd.empty?
|
|
920
|
+
messages = last_assistant_for_display([tail["answer"]].compact, cwd: cwd)
|
|
913
921
|
else
|
|
914
922
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
915
923
|
status = displayed_status(session)
|
|
@@ -949,9 +957,9 @@ module Samagotchi
|
|
|
949
957
|
end
|
|
950
958
|
|
|
951
959
|
# The worker's light read (Bridge GET tail): {"session_state_snapshot",
|
|
952
|
-
# "answer", "cards", "event_seq", "event_id"}. A
|
|
953
|
-
# route answers 404 not_found: its GET snapshot,
|
|
954
|
-
# answer by AnswerTail). Anything else (no worker, a refused connect, a
|
|
960
|
+
# "working_directory", "answer", "cards", "event_seq", "event_id"}. A
|
|
961
|
+
# worker from before the route answers 404 not_found: its GET snapshot,
|
|
962
|
+
# in the same shape (the answer by AnswerTail; no working_directory). Anything else (no worker, a refused connect, a
|
|
955
963
|
# 500, a timeout) is nil: the caller reads the disk, with no second
|
|
956
964
|
# try at a worker that failed once.
|
|
957
965
|
# @return [Hash, nil]
|
|
@@ -1998,12 +2006,7 @@ module Samagotchi
|
|
|
1998
2006
|
home_label(File.expand_path(default_state_dir))
|
|
1999
2007
|
end
|
|
2000
2008
|
|
|
2001
|
-
def home_label(dir)
|
|
2002
|
-
home = Dir.home
|
|
2003
|
-
return "~" if dir == home
|
|
2004
|
-
|
|
2005
|
-
dir.start_with?("#{home}/") ? "~#{dir.delete_prefix(home)}" : dir
|
|
2006
|
-
end
|
|
2009
|
+
def home_label(dir) = Paths.tilde(dir)
|
|
2007
2010
|
|
|
2008
2011
|
# <body> data for the page: where sessions are stored, where an
|
|
2009
2012
|
# all-view new chat starts (the server's cwd), and, when ?dir names a
|
|
@@ -38,12 +38,13 @@ import { MODEL_KEY } from "./model_pick.js";
|
|
|
38
38
|
import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
39
39
|
import { createModelPicker } from "./model_picker.js";
|
|
40
40
|
import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
|
|
41
|
-
import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, withLiveStatus, withoutWorker } from "./format.js";
|
|
41
|
+
import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
|
|
42
42
|
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
43
43
|
import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
|
|
44
44
|
import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
|
|
45
45
|
import { createCardsPlace } from "./cards_place.js";
|
|
46
46
|
import { createStageView, FOLD_MS } from "./stage_view.js";
|
|
47
|
+
import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
|
|
47
48
|
import { CONTINUE_OPENER, chainNeighbours, isOpener, continueRefusal, continuedText, freshLink, openChildrenTemplate } from "./chains.js";
|
|
48
49
|
import { createTaskStopper } from "./task_stop.js";
|
|
49
50
|
import { appendQuote } from "./annotations.js";
|
|
@@ -313,8 +314,6 @@ const {
|
|
|
313
314
|
// The plugin cards and a snapshot's notices in the history (cards_place.js).
|
|
314
315
|
const cards = createCardsPlace({
|
|
315
316
|
historyEl,
|
|
316
|
-
appendToHistory: (el) => appendToHistory(el),
|
|
317
|
-
isNearBottom: () => isNearBottom(),
|
|
318
317
|
turnDom,
|
|
319
318
|
findInTurnRoots: (selector) => findInTurnRoots(selector),
|
|
320
319
|
selected: () => selected,
|
|
@@ -1063,7 +1062,7 @@ async function restartWorker() {
|
|
|
1063
1062
|
const result = await restartSession(id);
|
|
1064
1063
|
showToast(result.version ? `Restarted on chi ${result.version}` : "Restarting: the new worker is starting");
|
|
1065
1064
|
} catch (e) {
|
|
1066
|
-
showToast(
|
|
1065
|
+
showToast(cantText("restart", e), null, { sticky: true });
|
|
1067
1066
|
} finally {
|
|
1068
1067
|
restartingIds.delete(id);
|
|
1069
1068
|
updateInfoBar();
|
|
@@ -1482,6 +1481,8 @@ const composerCommands = createComposerCommands({
|
|
|
1482
1481
|
followWokenWorker: () => followWokenWorker(),
|
|
1483
1482
|
refreshHistory: () => refreshHistory(),
|
|
1484
1483
|
failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
|
|
1484
|
+
// As handleSendTurn says a turn's failure after a switch.
|
|
1485
|
+
failedElsewhere: (line, text) => alert(`${line}: ${text}`),
|
|
1485
1486
|
ownedByTui: () => {
|
|
1486
1487
|
current.owner = "tui";
|
|
1487
1488
|
updateComposerMode();
|
|
@@ -1742,7 +1743,6 @@ const centerEl = $("#center");
|
|
|
1742
1743
|
// A leaving stage (the hand-off's slide) no longer counts: the reserve eases
|
|
1743
1744
|
// down over the slide (index.html, #center.reserve-easing).
|
|
1744
1745
|
const stageEl = view === "stage" ? turnDom.root : null;
|
|
1745
|
-
const STAGE_GAP = 10;
|
|
1746
1746
|
function dockReserve() {
|
|
1747
1747
|
const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
|
|
1748
1748
|
return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
|
|
@@ -1760,7 +1760,7 @@ function updateReserve({ fromStage = false } = {}) {
|
|
|
1760
1760
|
const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
|
|
1761
1761
|
if (changed) centerEl.style.setProperty("--dock-h", reserve);
|
|
1762
1762
|
if (changed && follow) {
|
|
1763
|
-
if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce:
|
|
1763
|
+
if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: reducedMotion() });
|
|
1764
1764
|
else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1765
1765
|
}
|
|
1766
1766
|
placeToast();
|
|
@@ -1864,12 +1864,11 @@ $("#infoStopBtn").addEventListener("click", async () => {
|
|
|
1864
1864
|
stoppingId = id;
|
|
1865
1865
|
try {
|
|
1866
1866
|
await stopSession(id);
|
|
1867
|
-
if (id === selected)
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
}
|
|
1867
|
+
if (id === selected) stoppedWorker(id);
|
|
1868
|
+
} catch (e) {
|
|
1869
|
+
// A refusal (a terminal holds it) or a worker that won't stop: said, as
|
|
1870
|
+
// a failed restart is.
|
|
1871
|
+
showToast(cantText(`stop ${id.slice(0, 8)}`, e), null, { sticky: true });
|
|
1873
1872
|
} finally {
|
|
1874
1873
|
stoppingId = null;
|
|
1875
1874
|
}
|
|
@@ -1963,6 +1962,14 @@ function markArchived(ids, archived) {
|
|
|
1963
1962
|
// and a batch share. The ids in the answer flip in the list at once. When
|
|
1964
1963
|
// the open session's worker was stopped (it, or a delegate of the archived
|
|
1965
1964
|
// one), its stream ending is a stop, not a lost worker.
|
|
1965
|
+
// The open session's worker was stopped from here (the stop button, an
|
|
1966
|
+
// archive): don't wait for its stream to notice.
|
|
1967
|
+
function stoppedWorker(id) {
|
|
1968
|
+
streamControl?.close();
|
|
1969
|
+
current.status = "stopped";
|
|
1970
|
+
workerGone(id);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1966
1973
|
// @return {ok: true, result} | {ok: false, error}
|
|
1967
1974
|
async function archiveOne(id, archive) {
|
|
1968
1975
|
// A live worker in the tree is stopped by the archive: the open
|
|
@@ -1972,12 +1979,7 @@ async function archiveOne(id, archive) {
|
|
|
1972
1979
|
try {
|
|
1973
1980
|
const result = archive ? await archiveSession(id) : await unarchiveSession(id);
|
|
1974
1981
|
markArchived(archive ? result.archived : result.unarchived, archive);
|
|
1975
|
-
if (mine && mine === selected && (result.stopped || []).includes(mine))
|
|
1976
|
-
// Its worker is gone: don't wait for its stream to notice.
|
|
1977
|
-
streamControl?.close();
|
|
1978
|
-
current.status = "stopped";
|
|
1979
|
-
workerGone(mine);
|
|
1980
|
-
}
|
|
1982
|
+
if (mine && mine === selected && (result.stopped || []).includes(mine)) stoppedWorker(mine);
|
|
1981
1983
|
return { ok: true, result };
|
|
1982
1984
|
} catch (error) {
|
|
1983
1985
|
return { ok: false, error };
|
|
@@ -285,7 +285,7 @@ export function createBubbles({
|
|
|
285
285
|
}
|
|
286
286
|
|
|
287
287
|
return {
|
|
288
|
-
removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
|
|
288
|
+
removeHintIfEmpty, placeFollowing, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
|
|
289
289
|
markRecapStale, showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
|
|
290
290
|
};
|
|
291
291
|
}
|