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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/docs/architecture.md +14 -8
  4. data/docs/cli.md +3 -2
  5. data/docs/configuration.md +23 -13
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/anthropic.md +1 -1
  8. data/docs/internals/engine.md +38 -13
  9. data/docs/internals/llm-context-live.md +1 -1
  10. data/docs/internals/prompt-caching.md +6 -0
  11. data/docs/plugins.md +18 -7
  12. data/docs/sessions.md +3 -1
  13. data/docs/testing.md +10 -1
  14. data/lib/samagotchi/bootstrap/config_writer.rb +2 -3
  15. data/lib/samagotchi/bridge.rb +5 -2
  16. data/lib/samagotchi/broadcast/scope_card.rb +2 -2
  17. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -7
  18. data/lib/samagotchi/bundles/claude-peer/plugin.rb +2 -2
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -6
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +2 -2
  21. data/lib/samagotchi/bundles/coordinator/manifest.yml +8 -6
  22. data/lib/samagotchi/bundles/coordinator/plugin.rb +10 -0
  23. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +2 -1
  24. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/config.rb +7 -25
  27. data/lib/samagotchi/context_note.rb +2 -6
  28. data/lib/samagotchi/effective_model.rb +1 -1
  29. data/lib/samagotchi/engine.rb +91 -213
  30. data/lib/samagotchi/engine_peers.rb +2 -0
  31. data/lib/samagotchi/event_routes.rb +83 -0
  32. data/lib/samagotchi/hook_routes.rb +16 -37
  33. data/lib/samagotchi/host_config.rb +1 -1
  34. data/lib/samagotchi/host_keys.rb +0 -2
  35. data/lib/samagotchi/paths.rb +13 -0
  36. data/lib/samagotchi/plugin/api.rb +11 -5
  37. data/lib/samagotchi/plugin/peer.rb +5 -8
  38. data/lib/samagotchi/prompt_files.rb +2 -4
  39. data/lib/samagotchi/session.rb +2 -1
  40. data/lib/samagotchi/session_manager.rb +6 -16
  41. data/lib/samagotchi/session_model_settings.rb +227 -0
  42. data/lib/samagotchi/session_setup.rb +2 -1
  43. data/lib/samagotchi/system_prompt.rb +18 -13
  44. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -14
  45. data/lib/samagotchi/terminal_ui/formatting.rb +4 -0
  46. data/lib/samagotchi/tool_activity.rb +1 -0
  47. data/lib/samagotchi/tool_declarations.rb +4 -0
  48. data/lib/samagotchi/toolkit.rb +55 -24
  49. data/lib/samagotchi/toolkit_binding.rb +7 -5
  50. data/lib/samagotchi/toolkit_command.rb +28 -5
  51. data/lib/samagotchi/tools/builtins.rb +2 -1
  52. data/lib/samagotchi/tools/delegate.rb +32 -6
  53. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  54. data/lib/samagotchi/tools/peers.rb +5 -2
  55. data/lib/samagotchi/tools/register_reminder.rb +1 -0
  56. data/lib/samagotchi/tools/registry.rb +1 -1
  57. data/lib/samagotchi/tools/tool_index.rb +15 -4
  58. data/lib/samagotchi/turn_note.rb +4 -1
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +13 -10
  61. data/lib/samagotchi/web/public/app.js +20 -18
  62. data/lib/samagotchi/web/public/bubbles.js +1 -1
  63. data/lib/samagotchi/web/public/cards_place.js +6 -10
  64. data/lib/samagotchi/web/public/chains.js +1 -1
  65. data/lib/samagotchi/web/public/chip_popover.js +3 -1
  66. data/lib/samagotchi/web/public/composer.js +13 -6
  67. data/lib/samagotchi/web/public/context_chips.js +25 -12
  68. data/lib/samagotchi/web/public/data.js +5 -1
  69. data/lib/samagotchi/web/public/format.js +5 -0
  70. data/lib/samagotchi/web/public/sessions_list.js +2 -3
  71. data/lib/samagotchi/web/public/stage_frame.js +1 -1
  72. data/lib/samagotchi/web/public/stage_view.js +8 -2
  73. data/lib/samagotchi/web/public/stream_handlers.js +6 -3
  74. data/lib/samagotchi/web/public/timing.js +1 -1
  75. data/lib/samagotchi/web/public/toolkit_chip.js +3 -1
  76. data/lib/samagotchi/web/session_hub.rb +8 -0
  77. metadata +3 -1
@@ -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) and,
82
- # when either list has any, the kit name the line gives. Two equal
83
- # keys build the same prompt (/toolkit's "no change", its cost).
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, removed: removed,
95
- fallback: resolved.fallback }
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
- Kit = Data.define(:name, :base, :tools, :add, :remove, :source) do
107
- def initialize(name:, base: nil, tools: nil, add: [], remove: [], source: "core")
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
- Resolved = Data.define(:name, :all_core, :core, :added, :removed, :fallback) do
137
- def initialize(name:, all_core: false, core: [], added: [], removed: [], fallback: nil)
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 nothing removed: what no toolkit declares.
143
- def everything? = all_core && removed.empty?
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
- base.changed(add: kit.add, remove: kit.remove).with(name: name)
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 (a
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 tool".
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 changed. A name that doesn't resolve (a
110
- # typo, an uninstalled bundle) declares everything for now and says so
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), "The tools are the front of every request: the whole prompt is read " \
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, and the ones left out:
62
- # reachable with find_tools / call_tool, removed (only /toolkit add
63
- # brings one back), or, with call_tool not declared, not loaded.
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], peers: kctx.peers)
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. With
23
- # session:, a follow-up to a child that exists. Only the child's final
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, state_dir: sd, peers: peers)
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 "../context_note"
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} #{ContextNote.home_relative(s[:cwd])} #{s[:preview].inspect}"
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). Engine hands KernelLoop one that follows its current
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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "../reminder_store"
4
5
 
5
6
  module Samagotchi
@@ -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.each_value.any? { |entry| soft?(entry, [], toolkit) }
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
- return "Error: the MCP tools changed while the call waited for its server; call #{tool.full_name} again"
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
- source_of(tool.group).run(tool, inner)
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}/") }
@@ -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. Answer the user's last message now, briefly."))
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.71.0"
4
+ VERSION = "0.72.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
- messages = last_assistant_for_display([tail["answer"]].compact)
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 worker from before the
953
- # route answers 404 not_found: its GET snapshot, in the same shape (the
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(`Can't restart: ${String(e.message).replace(/ \(\d+\)$/, "")}`, null, { sticky: true });
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: matchMedia("(prefers-reduced-motion: reduce)").matches });
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
- // The worker is gone: don't wait for its stream to notice.
1869
- streamControl?.close();
1870
- current.status = "stopped";
1871
- workerGone(id);
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
  }