samagotchi 0.67.0 → 0.69.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/docs/architecture.md +7 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +28 -6
  6. data/docs/guardrails.md +23 -1
  7. data/docs/hooks.md +4 -0
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +73 -17
  10. data/docs/internals/prompt-caching.md +9 -2
  11. data/docs/plugins.md +42 -19
  12. data/docs/releasing.md +14 -6
  13. data/docs/sessions.md +3 -3
  14. data/docs/testing.md +8 -2
  15. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  18. data/lib/samagotchi/config.rb +0 -19
  19. data/lib/samagotchi/effective_model.rb +283 -0
  20. data/lib/samagotchi/engine.rb +216 -360
  21. data/lib/samagotchi/guardrails/targets.rb +9 -2
  22. data/lib/samagotchi/host_config.rb +1 -29
  23. data/lib/samagotchi/host_model.rb +0 -7
  24. data/lib/samagotchi/kernel_loop.rb +24 -7
  25. data/lib/samagotchi/level_setting_command.rb +99 -0
  26. data/lib/samagotchi/llm/errors.rb +47 -1
  27. data/lib/samagotchi/llm/http.rb +20 -2
  28. data/lib/samagotchi/llm_context_stale.rb +6 -0
  29. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  30. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  31. data/lib/samagotchi/model_price.rb +2 -4
  32. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  33. data/lib/samagotchi/session.rb +22 -8
  34. data/lib/samagotchi/session_metrics.rb +4 -1
  35. data/lib/samagotchi/session_setting.rb +86 -0
  36. data/lib/samagotchi/system_prompt.rb +19 -3
  37. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  38. data/lib/samagotchi/thinking_command.rb +24 -76
  39. data/lib/samagotchi/thinking_notices.rb +169 -0
  40. data/lib/samagotchi/tool_activity.rb +3 -0
  41. data/lib/samagotchi/tool_row_fields.rb +4 -2
  42. data/lib/samagotchi/tool_runner.rb +24 -3
  43. data/lib/samagotchi/toolkit.rb +27 -14
  44. data/lib/samagotchi/toolkit_binding.rb +8 -2
  45. data/lib/samagotchi/toolkit_command.rb +20 -4
  46. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  47. data/lib/samagotchi/tools/registry.rb +95 -8
  48. data/lib/samagotchi/tools/tool_index.rb +223 -72
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +69 -93
  51. data/lib/samagotchi/web/message_parts.rb +5 -2
  52. data/lib/samagotchi/web/public/activity.js +3 -1
  53. data/lib/samagotchi/web/public/app.js +196 -526
  54. data/lib/samagotchi/web/public/bubbles.js +291 -0
  55. data/lib/samagotchi/web/public/cards_place.js +184 -0
  56. data/lib/samagotchi/web/public/chains.js +19 -2
  57. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  58. data/lib/samagotchi/web/public/command_list.js +12 -10
  59. data/lib/samagotchi/web/public/families_view.js +15 -0
  60. data/lib/samagotchi/web/public/index.html +21 -11
  61. data/lib/samagotchi/web/public/info_bar.js +3 -3
  62. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  63. data/lib/samagotchi/web/public/init_row.js +21 -10
  64. data/lib/samagotchi/web/public/level_chip.js +29 -80
  65. data/lib/samagotchi/web/public/lists_view.js +64 -9
  66. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  67. data/lib/samagotchi/web/public/model_picker.js +47 -28
  68. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  69. data/lib/samagotchi/web/public/refs.js +13 -0
  70. data/lib/samagotchi/web/public/session_state.js +5 -0
  71. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  72. data/lib/samagotchi/web/public/stage_model.js +33 -0
  73. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  74. data/lib/samagotchi/web/public/stage_view.js +56 -335
  75. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  77. data/lib/samagotchi/web/public/timing.js +3 -1
  78. data/lib/samagotchi/web/public/toast_place.js +16 -0
  79. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  80. data/lib/samagotchi/web/public/turn_html.js +4 -2
  81. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  82. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  83. data/lib/samagotchi/worker.rb +11 -16
  84. metadata +15 -1
@@ -7,7 +7,8 @@ require_relative "toolkit"
7
7
  module Samagotchi
8
8
  # /toolkit, the session's toolkit (Session#toolkit): with no argument,
9
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
10
+ # toolkit and the tools it leaves out (the ones find_tools reaches, the
11
+ # ones it removes); `/toolkit NAME` puts the session
11
12
  # on another toolkit (its own adds and removes go), `/toolkit add TOOL`
12
13
  # and `/toolkit remove TOOL` change one tool. A change is saved at once
13
14
  # and applies at the next turn's start (Engine#apply_toolkit!). The
@@ -57,7 +58,9 @@ module Samagotchi
57
58
  [[listing(after), "From the next turn's start (a running turn keeps its tools)."].join("\n"), true, nil]
58
59
  end
59
60
 
60
- # The toolkit, its declared tools with tokens, and the ones left out.
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.
61
64
  def listing(explained)
62
65
  toolkit = explained.toolkit
63
66
  lines = ["toolkit: #{toolkit.label}#{" (applies at the next turn's start)" if explained.pending}"]
@@ -68,7 +71,13 @@ module Samagotchi
68
71
  lines << " declared (#{declared.size}, ~#{format_tokens(declared.sum { |entry| tokens(entry) })} tokens): " \
69
72
  "#{declared.map { |entry| "#{entry.name} ~#{format_tokens(tokens(entry))}" }.join(", ")}"
70
73
  left_out = explained.left_out
71
- lines << " not loaded: #{left_out.empty? ? "none" : left_out.join(", ")}"
74
+ if explained.reachable.empty?
75
+ lines << " not loaded: #{left_out.empty? ? "none" : left_out.join(", ")}"
76
+ else
77
+ route = explained.removed.include?("find_tools") ? "call_tool" : "find_tools"
78
+ lines << " reachable (#{route}): #{explained.reachable.join(", ")}"
79
+ lines << " removed: #{explained.removed.empty? ? "none" : explained.removed.join(", ")}"
80
+ end
72
81
  lines << " toolkits: #{@engine.toolkit_kits.keys.join(", ")}; #{USAGE.delete_prefix("usage: ")} changes it"
73
82
  lines.join("\n")
74
83
  end
@@ -101,8 +110,15 @@ module Samagotchi
101
110
  added = names(after) - names(before)
102
111
  removed = names(before) - names(after)
103
112
  parts = []
113
+ # Removed (hard: call_tool can't reach it), declared before or not;
114
+ # left out by the kit (soft); reachable now and not before.
115
+ hard = after.removed - before.removed
116
+ left = removed - hard
117
+ soft = after.reachable - before.reachable - removed
104
118
  parts << "declares #{added.join(", ")}" unless added.empty?
105
- parts << "leaves out #{removed.join(", ")}" unless removed.empty?
119
+ parts << "leaves out #{left.join(", ")}" unless left.empty?
120
+ parts << "removes #{hard.join(", ")} (call_tool can't reach #{hard.size == 1 ? "it" : "them"})" unless hard.empty?
121
+ parts << "lets call_tool reach #{soft.join(", ")}" unless soft.empty?
106
122
  parts << "declares the same tools under its own name" if parts.empty?
107
123
  "Switching to #{after.toolkit.label} #{parts.join("; ")}."
108
124
  end
@@ -63,6 +63,14 @@ module Samagotchi
63
63
  # called_as: with the model's own spelling.
64
64
  ALIASES = { "bash" => "execute" }.freeze
65
65
 
66
+ # The tool index's call tool, whose call of a built-in runs as that
67
+ # built-in (ToolIndex#unwrap; ToolIndex::CALL_NAME is this), and the
68
+ # group it names them by. The one place the name is spelled.
69
+ VIA = "call_tool"
70
+ VIA_GROUP = "chi/"
71
+ # A saved run's "[name]" lead (ToolResponse::LEAD).
72
+ SAVED_LEAD = /\A\[([^\]\s]+)\]/
73
+
66
74
  # One built-in's mapping, resolved from its schema and OVERRIDES.
67
75
  Row = Data.define(:name, :content_keys, :path_key, :fields, :aliases, :verbatim, :fallback, :fallback_key,
68
76
  :options) do
@@ -97,7 +105,11 @@ module Samagotchi
97
105
  unknown = ([path_key].compact + Array(o[:verbatim]) + Array(o[:also]) + o.fetch(:aliases, {}).keys) - properties
98
106
  unknown << content_keys.join("/") unless content_keys.empty? || content_keys.intersect?(properties)
99
107
  raise ArgumentError, "#{name}: overrides name no schema property: #{unknown.join(", ")}" unless unknown.empty?
100
- raise ArgumentError, "#{name}: a property would replace the call's name" if fields.include?("name")
108
+ # A property can't set the call's name, nor the fields only chi sets
109
+ # (an alias's called_as, an unwrapped call's via).
110
+ if (reserved = fields & %w[name called_as via]).any?
111
+ raise ArgumentError, "#{name}: a property would replace the call's #{reserved.first}"
112
+ end
101
113
 
102
114
  Row.new(name: name, content_keys: content_keys, path_key: path_key, fields: fields,
103
115
  aliases: o.fetch(:aliases, {}), verbatim: Array(o[:verbatim]), fallback: o[:fallback],
@@ -135,6 +147,38 @@ module Samagotchi
135
147
  call
136
148
  end
137
149
 
150
+ # A saved call as it ran (a reload, the stale-read pairing): a call_tool
151
+ # of a built-in ("chi/delegate", or a bare "delegate") is that
152
+ # built-in's call with via: "call_tool" when +output+ (its saved
153
+ # result) shows it ran as that built-in: its "[name]" lead, or, with
154
+ # no lead, an error that names it (refused by its name before the
155
+ # gate); any other call as it is (a call_tool that ran, refused or
156
+ # not, leads with "[call_tool]").
157
+ # @param call [Hash] as #build gives it
158
+ # @param output [String, nil]
159
+ # @return [Hash]
160
+ def unwrap_saved(call, output)
161
+ return call unless call[:name] == VIA && call[:args].is_a?(Hash)
162
+
163
+ name = call[:args]["tool"].to_s.strip.delete_prefix(VIA_GROUP)
164
+ return call unless row(name) && ran_as?(output.to_s, name)
165
+
166
+ build(name, saved_args(call[:args]["args"])).merge(via: VIA)
167
+ end
168
+
169
+ def ran_as?(output, name)
170
+ lead = output[SAVED_LEAD, 1]
171
+ lead ? lead == name : output.start_with?("Error: #{name} ")
172
+ end
173
+
174
+ # call_tool's inner args as saved: an object, or its JSON text.
175
+ def saved_args(args)
176
+ args = JSON.parse(args) if args.is_a?(String) && !args.strip.empty?
177
+ args.is_a?(Hash) ? args : {}
178
+ rescue JSON::ParserError
179
+ {}
180
+ end
181
+
138
182
  # An execute-shaped call the model spelled +name+: built as its +target+,
139
183
  # tagged with the model's spelling for the activity event and the log.
140
184
  def aliased(name, target, args, raw)
@@ -26,18 +26,33 @@ module Samagotchi
26
26
  # @!attribute layer [Symbol, nil] the LLM context layer the tool
27
27
  # belongs to (LLMContextStrategy): offered only in a turn running
28
28
  # under it (forget_outputs, :forget); nil: always
29
- Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :refs, :source, :layer, keyword_init: true) do
29
+ # @!attribute unwrap [#call, nil] (call, layers) → the call it stands
30
+ # for, run in its place before the guardrail gate (call_tool on one
31
+ # of chi's own tools: Tools::ToolIndex#unwrap), or nil to run it as
32
+ # it is; nil on every other tool
33
+ Entry = Struct.new(:name, :schema, :handler, :label, :preview, :targets, :refs, :source, :layer, :unwrap,
34
+ keyword_init: true) do
30
35
  def core? = source == "core"
31
36
 
32
37
  # Offered in a turn under +layers+ (the turn's LLM context layers).
33
38
  def offered?(layers) = layer.nil? || Array(layers).include?(layer)
34
39
  end
35
40
 
41
+ # What Tools::ToolIndex#declare registered: the entries of +source+
42
+ # are the index's search and call tools, +call_name+ the call tool's
43
+ # name, and +sourced+ whether a source (an MCP server's bundle)
44
+ # registered when the plugins loaded.
45
+ Index = Data.define(:source, :call_name, :sourced)
46
+
36
47
  def initialize
37
48
  @entries = {}
38
49
  @toolkit = nil
50
+ @index = nil
39
51
  end
40
52
 
53
+ # The tool index's declaration (Index), nil without one.
54
+ attr_accessor :index
55
+
41
56
  # The session's resolved toolkit (Toolkit::Resolved), which the lists
42
57
  # below and #offered filter by; nil declares every tool (full). The
43
58
  # Engine sets it at a turn's start, before the prompt is built, and
@@ -45,11 +60,12 @@ module Samagotchi
45
60
  attr_accessor :toolkit
46
61
 
47
62
  # @return [Entry]
48
- def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil)
63
+ def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil,
64
+ unwrap: nil)
49
65
  raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
50
66
 
51
67
  @entries[name] = Entry.new(name: name, schema: schema, handler: handler, label: label, preview: preview,
52
- targets: targets, refs: refs, source: source, layer: layer)
68
+ targets: targets, refs: refs, source: source, layer: layer, unwrap: unwrap)
53
69
  end
54
70
 
55
71
  # Remove a tool (a plugin's tool set changed after load).
@@ -70,18 +86,48 @@ module Samagotchi
70
86
  end
71
87
 
72
88
  # The entry +name+ names when the session's toolkit leaves it out (a
73
- # real tool the model can't call here), nil otherwise.
89
+ # real tool the model can't call here, by its name), nil otherwise.
74
90
  # @return [Entry, nil]
75
91
  def left_out(name, layers: [])
76
92
  entry = self[name]
77
- entry if entry&.offered?(layers) && !declared?(entry)
93
+ entry if entry && left_out?(entry, layers, @toolkit)
78
94
  end
79
95
 
80
96
  # The tools +toolkit+ (the session's by default) leaves out of a turn
81
- # under +layers+.
97
+ # under +layers+. The tool index's own aren't, unless it removes them:
98
+ # they are declared when there is something to reach.
82
99
  # @return [Array<String>] in declaration order
83
100
  def left_out_names(layers: [], toolkit: @toolkit)
84
- ordered(@entries.values.select { |entry| entry.offered?(layers) && !declared?(entry, toolkit) }).map(&:name)
101
+ ordered(@entries.values.select { |entry| left_out?(entry, layers, toolkit) }).map(&:name)
102
+ end
103
+
104
+ # The built-in +name+ names when the session's toolkit leaves it out
105
+ # but the tool index reaches it (#reachable_names).
106
+ # @return [Entry, nil]
107
+ def indexed(name, layers: [], toolkit: @toolkit)
108
+ entry = self[name]
109
+ entry if entry && call_offered?(toolkit) && soft?(entry, layers, toolkit)
110
+ end
111
+
112
+ # The built-ins +toolkit+ leaves out of a turn under +layers+ that the
113
+ # tool index reaches (call_tool): not in its kit's list, but not
114
+ # removed either (remove:, --no-tool, /toolkit remove), with call_tool
115
+ # itself declared.
116
+ # @return [Array<String>] in declaration order
117
+ def reachable_names(layers: [], toolkit: @toolkit)
118
+ return [] unless call_offered?(toolkit)
119
+
120
+ ordered(@entries.values.select { |entry| soft?(entry, layers, toolkit) }).map(&:name)
121
+ end
122
+
123
+ # The tools +toolkit+ removes from a turn under +layers+ (remove:,
124
+ # --no-tool, /toolkit remove): only the user brings them back.
125
+ # @return [Array<String>] in declaration order
126
+ def removed_names(layers: [], toolkit: @toolkit)
127
+ return [] if toolkit.nil?
128
+
129
+ ordered(@entries.values.select { |entry| entry.offered?(layers) && toolkit.removed.include?(entry.name) })
130
+ .map(&:name)
85
131
  end
86
132
 
87
133
  # Every tool +source+ registered, declared or not (a plugin's tool set
@@ -112,7 +158,48 @@ module Samagotchi
112
158
 
113
159
  private
114
160
 
115
- def declared?(entry, toolkit = @toolkit) = toolkit.nil? || toolkit.declares?(entry)
161
+ # Declared by +toolkit+ (nil: every tool, full). The tool index's
162
+ # tools are declared when they reach something, whatever the toolkit:
163
+ # a source registered at load, or a built-in it leaves out but
164
+ # doesn't remove (#index_needed?); unless it removes them.
165
+ def declared?(entry, toolkit = @toolkit)
166
+ if index_entry?(entry)
167
+ !toolkit&.removed&.include?(entry.name) && index_needed?(toolkit)
168
+ else
169
+ toolkit.nil? || toolkit.declares?(entry)
170
+ end
171
+ end
172
+
173
+ def index_entry?(entry) = !@index.nil? && entry.source == @index.source
174
+
175
+ # From what the plugins registered at load and the toolkit alone, so
176
+ # the declared tools stay the same for a session's life. A built-in
177
+ # left out counts only while call_tool can reach it (not removed).
178
+ def index_needed?(toolkit)
179
+ return true if @index.sourced
180
+ return false if toolkit&.removed&.include?(@index.call_name)
181
+
182
+ @entries.each_value.any? { |entry| soft?(entry, [], toolkit) }
183
+ end
184
+
185
+ def left_out?(entry, layers, toolkit)
186
+ return false unless entry.offered?(layers) && !declared?(entry, toolkit)
187
+
188
+ !index_entry?(entry) || toolkit&.removed&.include?(entry.name) == true
189
+ end
190
+
191
+ # A built-in +toolkit+ leaves out of a turn under +layers+ without
192
+ # removing it.
193
+ def soft?(entry, layers, toolkit)
194
+ !toolkit.nil? && entry.core? && entry.offered?(layers) && !toolkit.removed.include?(entry.name) &&
195
+ !toolkit.declares?(entry)
196
+ end
197
+
198
+ # Whether +toolkit+ declares the index's call tool.
199
+ def call_offered?(toolkit)
200
+ entry = @index && self[@index.call_name]
201
+ !entry.nil? && declared?(entry, toolkit)
202
+ end
116
203
 
117
204
  def ordered(entries)
118
205
  core, added = entries.partition(&:core?)