samagotchi 0.65.0 → 0.67.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +50 -28
  6. data/docs/cli.md +14 -2
  7. data/docs/configuration.md +101 -16
  8. data/docs/context.md +39 -2
  9. data/docs/desktop.md +90 -22
  10. data/docs/guardrails.md +8 -5
  11. data/docs/hooks.md +2 -2
  12. data/docs/internals/anthropic.md +156 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bundles.md +2 -0
  15. data/docs/internals/engine.md +75 -30
  16. data/docs/internals/gemma4-contract.md +1 -1
  17. data/docs/internals/llm-context-bench.md +5 -4
  18. data/docs/internals/prompt-caching.md +36 -13
  19. data/docs/memory.md +1 -1
  20. data/docs/plugins.md +289 -25
  21. data/docs/releasing.md +1 -1
  22. data/docs/sessions.md +16 -6
  23. data/docs/sub-agent.md +6 -1
  24. data/docs/testing.md +17 -3
  25. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  26. data/lib/samagotchi/bootstrap_command.rb +13 -3
  27. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  28. data/lib/samagotchi/bridge.rb +6 -3
  29. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
  31. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  33. data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
  34. data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
  35. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
  36. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  37. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
  38. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  39. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  40. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  44. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  45. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  46. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  47. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  48. data/lib/samagotchi/cache_confirm.rb +9 -6
  49. data/lib/samagotchi/commands/registry.rb +25 -8
  50. data/lib/samagotchi/config.rb +23 -8
  51. data/lib/samagotchi/context_absorber.rb +66 -13
  52. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  53. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  54. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  55. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  56. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  57. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  58. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  59. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  60. data/lib/samagotchi/desktop/macos.rb +84 -13
  61. data/lib/samagotchi/desktop_command.rb +14 -3
  62. data/lib/samagotchi/engine.rb +120 -107
  63. data/lib/samagotchi/engine_peers.rb +3 -0
  64. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  65. data/lib/samagotchi/guardrails/approval.rb +1 -1
  66. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  67. data/lib/samagotchi/guardrails/rules.rb +1 -1
  68. data/lib/samagotchi/guardrails/targets.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +151 -0
  70. data/lib/samagotchi/hooks/registry.rb +2 -2
  71. data/lib/samagotchi/host_keys.rb +147 -0
  72. data/lib/samagotchi/kernel_loop.rb +9 -1
  73. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  74. data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
  75. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  76. data/lib/samagotchi/llm/api_key.rb +23 -7
  77. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  78. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  79. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  80. data/lib/samagotchi/llm_context_override.rb +1 -0
  81. data/lib/samagotchi/peer_sources.rb +58 -0
  82. data/lib/samagotchi/plugin/api.rb +138 -156
  83. data/lib/samagotchi/plugin/context.rb +22 -1
  84. data/lib/samagotchi/plugin/loader.rb +5 -2
  85. data/lib/samagotchi/plugin/peer.rb +394 -0
  86. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  87. data/lib/samagotchi/plugin_tasks.rb +2 -2
  88. data/lib/samagotchi/row_refs.rb +1 -1
  89. data/lib/samagotchi/self_report.rb +5 -3
  90. data/lib/samagotchi/send_command.rb +41 -15
  91. data/lib/samagotchi/session.rb +18 -1
  92. data/lib/samagotchi/session_commands.rb +16 -1
  93. data/lib/samagotchi/session_continue.rb +58 -15
  94. data/lib/samagotchi/session_inbox.rb +0 -13
  95. data/lib/samagotchi/session_manager.rb +4 -1
  96. data/lib/samagotchi/session_setup.rb +14 -7
  97. data/lib/samagotchi/system_prompt.rb +16 -1
  98. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  99. data/lib/samagotchi/terminal_ui.rb +18 -2
  100. data/lib/samagotchi/tool_call_parser.rb +1 -1
  101. data/lib/samagotchi/tool_runner.rb +13 -1
  102. data/lib/samagotchi/toolkit.rb +246 -0
  103. data/lib/samagotchi/toolkit_binding.rb +138 -0
  104. data/lib/samagotchi/toolkit_command.rb +117 -0
  105. data/lib/samagotchi/tools/context_read.rb +14 -0
  106. data/lib/samagotchi/tools/execute.rb +12 -12
  107. data/lib/samagotchi/tools/registry.rb +43 -6
  108. data/lib/samagotchi/tools/task_create.rb +6 -6
  109. data/lib/samagotchi/tools/task_get.rb +13 -13
  110. data/lib/samagotchi/tools/task_list.rb +6 -6
  111. data/lib/samagotchi/tools/task_record.rb +52 -0
  112. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  113. data/lib/samagotchi/tools/task_stop.rb +6 -6
  114. data/lib/samagotchi/tools/task_wait.rb +12 -13
  115. data/lib/samagotchi/tools/tool_index.rb +454 -0
  116. data/lib/samagotchi/update_command.rb +9 -6
  117. data/lib/samagotchi/version.rb +1 -1
  118. data/lib/samagotchi/web/app.rb +28 -9
  119. data/lib/samagotchi/web/capabilities.rb +1 -1
  120. data/lib/samagotchi/web/public/activity.js +2 -2
  121. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  122. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  123. data/lib/samagotchi/web/public/app.js +52 -43
  124. data/lib/samagotchi/web/public/chains.js +1 -1
  125. data/lib/samagotchi/web/public/commands.js +6 -4
  126. data/lib/samagotchi/web/public/composer_size.js +2 -2
  127. data/lib/samagotchi/web/public/copy.js +1 -1
  128. data/lib/samagotchi/web/public/ctx.js +1 -1
  129. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  130. data/lib/samagotchi/web/public/families_view.js +1 -1
  131. data/lib/samagotchi/web/public/format.js +11 -7
  132. data/lib/samagotchi/web/public/images.js +25 -2
  133. data/lib/samagotchi/web/public/index.html +9 -0
  134. data/lib/samagotchi/web/public/info_bar.js +5 -3
  135. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  136. data/lib/samagotchi/web/public/notify.js +6 -4
  137. data/lib/samagotchi/web/public/question_card.js +1 -1
  138. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  139. data/lib/samagotchi/web/public/session_state.js +17 -0
  140. data/lib/samagotchi/web/public/session_title.js +2 -3
  141. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  142. data/lib/samagotchi/web/public/stage_model.js +2 -2
  143. data/lib/samagotchi/web/public/stream_handlers.js +13 -0
  144. data/lib/samagotchi/web/public/strip.js +3 -3
  145. data/lib/samagotchi/web/public/task_stop.js +3 -2
  146. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  147. data/lib/samagotchi/web/public/turn_events.js +1 -1
  148. data/lib/samagotchi/web/public/turn_html.js +2 -2
  149. data/lib/samagotchi/web/public/turn_model.js +2 -2
  150. data/lib/samagotchi/web/public/turn_view.js +1 -1
  151. data/lib/samagotchi/web/server.rb +0 -4
  152. data/lib/samagotchi/worker.rb +13 -8
  153. data/lib/samagotchi/worker_inbound.rb +36 -4
  154. data/lib/samagotchi/worker_wakes.rb +67 -7
  155. metadata +23 -1
@@ -49,8 +49,11 @@ module Samagotchi
49
49
  def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
50
50
  params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
51
51
  # The gate runs first, so tool_call_started shows the call that runs.
52
+ # A tool the session's toolkit leaves out skips it: no before_tool_call
53
+ # hook, no approval card for a call that can't run (KernelLoop#dispatch
54
+ # answers it with the not-loaded error).
52
55
  original = call.dup
53
- verdict = evaluate(call, iteration, params)
56
+ verdict = left_out?(call) ? Guardrails::Verdict.new(call: call) : evaluate(call, iteration, params)
54
57
  call = verdict.call
55
58
  params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
56
59
  label = plugin_label(call[:name])
@@ -265,6 +268,15 @@ module Samagotchi
265
268
  # The kernel's tools, for the activity line of a tool that isn't built in.
266
269
  def tools = @kernel.tools
267
270
 
271
+ # A real tool the session's toolkit leaves out of this turn.
272
+ def left_out?(call)
273
+ registry = tools
274
+ return false unless registry.respond_to?(:left_out) && !call[:name].nil?
275
+
276
+ layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
277
+ !registry.left_out(call[:name], layers: layers).nil?
278
+ end
279
+
268
280
  # A tool the registry has from a plugin (not core, not unknown).
269
281
  def plugin_tool?(name)
270
282
  entry = tools && !name.nil? ? tools[name] : nil
@@ -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,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "log"
4
+ require_relative "toolkit"
5
+
6
+ module Samagotchi
7
+ # An Engine's session toolkit: the kits it resolves against, what the
8
+ # session's next turn declares (#explained, the UIs' summary), a plugin's
9
+ # ctx.use_toolkit (#use) and the turn-start apply into the tool Registry
10
+ # (#apply!), with its load warnings said once per process.
11
+ #
12
+ # What it needs from the Engine comes through lookups, called at use.
13
+ class ToolkitBinding
14
+ # @param tools [#call] → Tools::Registry
15
+ # @param session [#call] → Session, nil
16
+ # @param state_dir [#call] → the sessions' state dir (#use saves there)
17
+ # @param can_stage [#call] → whether a plugin may change the toolkit now
18
+ # (no turn runs, no model reply yet)
19
+ # @param reset_prompt [#call] → build the system prompts again
20
+ # @param emit [#call] (sink, event) → emits to a turn's sink + observers
21
+ # @param notify [#call] (text, level, source, extra:) → a notice
22
+ def initialize(tools:, session:, state_dir:, can_stage:, reset_prompt:, emit:, notify:)
23
+ @tools = tools
24
+ @session = session
25
+ @state_dir = state_dir
26
+ @can_stage = can_stage
27
+ @reset_prompt = reset_prompt
28
+ @emit = emit
29
+ @notify = notify
30
+ # The toolkits plugins declare (chi.toolkit), beside the built-in and
31
+ # config ones (#kits); Plugin::Registries appends to it.
32
+ @plugin_kits = []
33
+ @warned = []
34
+ end
35
+
36
+ # @return [Array<Toolkit::Kit>] the plugins' kits (Plugin::Registries' toolkits:)
37
+ attr_reader :plugin_kits
38
+
39
+ # The kits a session's toolkit resolves against: the built-ins, config's
40
+ # toolkit.kits, the plugins' (Toolkit.kits). Config's are read once per
41
+ # process, like the plugins': an edit to toolkit.kits never changes a
42
+ # live session's tools mid-session (a new worker reads it).
43
+ # @param errors [Array<String>] gets a line per config kit left out
44
+ # @return [Hash{String => Toolkit::Kit}]
45
+ def kits(errors = [])
46
+ @config_kits = Toolkit.config_kits unless defined?(@config_kits)
47
+ Toolkit.kits(config: @config_kits, plugins: @plugin_kits, errors: errors)
48
+ end
49
+
50
+ # The session's toolkit resolved (Toolkit::Resolved): what its next
51
+ # turn declares. A plain full toolkit is nil, the Registry's "every tool".
52
+ def resolved(session, errors = [])
53
+ resolved = Toolkit.of(session&.toolkit).resolve(kits(errors))
54
+ resolved.everything? ? nil : resolved
55
+ end
56
+
57
+ # +toolkit+ (the session's by default) as its next turn would declare
58
+ # it (/toolkit, the UIs' chip).
59
+ # @return [Toolkit::Explained]
60
+ def explained(toolkit = nil)
61
+ toolkit ||= Toolkit.of(@session.call&.toolkit)
62
+ resolved = toolkit.resolve(kits)
63
+ filter = resolved.everything? ? nil : resolved
64
+ tools = @tools.call
65
+ Toolkit::Explained.new(toolkit: toolkit, resolved: resolved, declared: tools.entries(toolkit: filter),
66
+ left_out: tools.left_out_names(toolkit: filter), pending: filter != tools.toolkit)
67
+ end
68
+
69
+ # The session's toolkit for the UIs (the snapshot, command_ran): nil on
70
+ # full, or with no session.
71
+ def summary
72
+ @session.call && explained.summary
73
+ end
74
+
75
+ # The session's toolkit name, nil with no session (ctx.toolkit).
76
+ def name
77
+ session = @session.call
78
+ session && Toolkit.of(session.toolkit).name
79
+ end
80
+
81
+ # A plugin's ctx.use_toolkit: put the session on toolkit +name+, saved,
82
+ # when it has no model reply yet and no turn runs (D3: no confirm from
83
+ # plugins), and tell the user as /toolkit would; it applies at the next
84
+ # turn's start.
85
+ # @return [Boolean] whether it was staged
86
+ # @raise [ArgumentError] an unknown toolkit
87
+ def use(name)
88
+ raise ArgumentError, "no toolkit named #{name}" unless kits.key?(name)
89
+
90
+ session = @session.call
91
+ return false if session.nil? || !@can_stage.call
92
+
93
+ toolkit = Toolkit.new(name: name)
94
+ return true if session.toolkit == toolkit
95
+
96
+ session.toolkit = toolkit
97
+ session.save(state_dir: @state_dir.call)
98
+ @notify.call("toolkit: #{name} from the next turn's start", :info, "toolkit", extra: { toolkit: summary })
99
+ true
100
+ end
101
+
102
+ # Resolve the session's toolkit into the Registry, on the turn thread at
103
+ # a turn's start (before its prompt is built), and build the prompts
104
+ # again when what it declares changed. A name that doesn't resolve (a
105
+ # typo, an uninstalled bundle) declares everything for now and says so
106
+ # once; the saved toolkit stays as it is.
107
+ # @return [Boolean] whether the declared tools changed
108
+ def apply!(session, on_event = nil)
109
+ errors = []
110
+ resolved = Toolkit.of(session&.toolkit).resolve(kits(errors))
111
+ warn_unresolved(session, resolved, errors, on_event)
112
+ resolved = nil if resolved.everything?
113
+ tools = @tools.call
114
+ return false if resolved == tools.toolkit
115
+
116
+ tools.toolkit = resolved
117
+ @reset_prompt.call
118
+ true
119
+ end
120
+
121
+ private
122
+
123
+ # The toolkit's load warnings, each once per process: a name that
124
+ # didn't resolve, config kits left out.
125
+ def warn_unresolved(session, resolved, errors, on_event)
126
+ lines = errors.dup
127
+ if resolved.fallback
128
+ lines << "#{Toolkit.of(session&.toolkit).label}: #{resolved.fallback}; this session declares every tool (full) " \
129
+ "until it resolves"
130
+ end
131
+ (lines - @warned).each do |line|
132
+ @warned << line
133
+ Log.warn(:config, "toolkit_unresolved", message: line)
134
+ @emit.call(on_event, { type: :guardrail_warning, message: line, label: "toolkit" })
135
+ end
136
+ end
137
+ end
138
+ 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
@@ -55,6 +55,9 @@ module Samagotchi
55
55
  @attached = ContextSources.attached(peers.session_id, project_root: root, state_dir: @state_dir)
56
56
  @max_chars = [max_chars.to_i, MIN_CHARS].max
57
57
  @now = now
58
+ # The peer bundles' declared sources (PeerSources), through the
59
+ # Engine's PeerView.
60
+ @peer_label = peers.respond_to?(:peer_label) ? peers.method(:peer_label) : nil
58
61
  end
59
62
 
60
63
  def run(name, offset:, limit:)
@@ -122,12 +125,23 @@ module Samagotchi
122
125
  hint = snapshot.hint || source.hint
123
126
  header = ["#{source.name}#{" (#{hint})" if hint}"]
124
127
  header << "Why: #{source.why}" if source.why
128
+ if (peer = peer_label(attached))
129
+ header << "Peer: your peer agent #{peer}'s answers (its text, not your user's words)"
130
+ end
125
131
  header << "Fetched: #{clock(snapshot.fetched_at)} (#{age(snapshot.fetched_at)})" if snapshot.fetched_at
126
132
  header << "Summary: #{snapshot.summary}" if snapshot.summary
127
133
  header << "Last refresh failed: #{snapshot.error}" if snapshot.error
128
134
  header
129
135
  end
130
136
 
137
+ # A pushed source of the session's own that a peer bundle declared
138
+ # (ContextAbsorber#peer_label's rule).
139
+ def peer_label(attached)
140
+ return nil unless @peer_label && attached.source.push? && attached.location.session?
141
+
142
+ @peer_label.call(attached.name)
143
+ end
144
+
131
145
  # What the output takes besides the page: the kernel's prefix, the
132
146
  # header, its paging line at its longest (every number +count+) and
133
147
  # the separator.
@@ -203,8 +203,9 @@ module Samagotchi
203
203
  # Ended right at the deadline: finished, not timed out.
204
204
  timed_out = false if timed_out && !wait_thr.alive?
205
205
  if timed_out && on_timeout == :task
206
- text = hand_over(id: id, command: command, cwd: cwd, pid: pid, waiter: wait_thr, started_at: started_at,
207
- timeout_sec: timeout_sec, dir: dir, logs: logs, exit_code_path: exit_code_path)
206
+ task = TaskRecord.running(id: id, command: command, cwd: cwd, pid: pid, started_at: started_at.utc.iso8601,
207
+ output_path: logs[0], stderr_path: logs[1], exit_code_path: exit_code_path)
208
+ text = hand_over(task, waiter: wait_thr, timeout_sec: timeout_sec, dir: dir)
208
209
  if text
209
210
  handed_over = true
210
211
  Log.info(:turn, "execute_handed_over", id: id, after_sec: timeout_sec)
@@ -337,25 +338,24 @@ module Samagotchi
337
338
  private_class_method :read_log
338
339
 
339
340
  # A command still running at the limit becomes a task (TaskRuntime.
340
- # adopt) with the logs where they are; a back-pointer in its dir names
341
- # the record for the sweep. nil when it can't be adopted (the caller
341
+ # adopt +task+, a TaskRecord.running) with the logs where they are
342
+ # (+dir+); a back-pointer in that dir names the record for the sweep. nil when it can't be adopted (the caller
342
343
  # then kills it as in :kill mode) or when it ended meanwhile (the
343
344
  # caller then reads it as finished).
344
345
  # @return [String, nil] the HANDED_OVER result
345
- def self.hand_over(id:, command:, cwd:, pid:, waiter:, started_at:, timeout_sec:, dir:, logs:, exit_code_path:)
346
+ def self.hand_over(task, waiter:, timeout_sec:, dir:)
346
347
  return nil unless waiter.alive?
347
348
 
348
- record, error = TaskRuntime.adopt(id: id, command: command, cwd: cwd, pid: pid, started_at: started_at,
349
- output_path: logs[0], stderr_path: logs[1], exit_code_path: exit_code_path)
349
+ record, error = TaskRuntime.adopt(task)
350
350
  return nil if error
351
351
 
352
- File.write(File.join(dir, BACK_POINTER), File.expand_path(TaskRuntime.metadata_path(record["id"])))
353
- write_missing_exit_code(waiter, exit_code_path)
352
+ File.write(File.join(dir, BACK_POINTER), File.expand_path(TaskRuntime.metadata_path(record.id)))
353
+ write_missing_exit_code(waiter, record.exit_code_path)
354
354
  # Ended between the deadline and the adopt: the record says how.
355
- TaskRuntime.get_record(id) unless waiter.alive?
356
- lines = [handed_over_line(timeout_sec), "task_id: #{id}",
355
+ TaskRuntime.get_record(record.id) unless waiter.alive?
356
+ lines = [handed_over_line(timeout_sec), "task_id: #{record.id}",
357
357
  "Wait for it with task_wait (it may take minutes); task_stop stops it."]
358
- { "stdout" => logs[0], "stderr" => logs[1] }.each do |label, path|
358
+ { "stdout" => record.output_path, "stderr" => record.stderr_path }.each do |label, path|
359
359
  tail = TaskRuntime.output_tail_lines(path, HAND_OVER_TAIL_LINES)
360
360
  lines << "#{label} (so far, tail):\n#{tail.chomp}" unless tail.empty?
361
361
  end