samagotchi 0.68.0 → 0.70.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 +76 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +31 -8
  6. data/docs/context.md +15 -6
  7. data/docs/guardrails.md +23 -1
  8. data/docs/hooks.md +5 -1
  9. data/docs/internals/background-tasks.md +1 -1
  10. data/docs/internals/bundles.md +1 -1
  11. data/docs/internals/engine.md +25 -13
  12. data/docs/internals/prompt-caching.md +9 -2
  13. data/docs/internals/tool-guardrails.md +61 -1
  14. data/docs/plugins.md +66 -38
  15. data/docs/releasing.md +14 -6
  16. data/docs/sessions.md +5 -5
  17. data/docs/sub-agent.md +2 -1
  18. data/docs/testing.md +8 -1
  19. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +10 -8
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +4 -4
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +6 -6
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +3 -3
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +6 -5
  26. data/lib/samagotchi/bundles/codex-peer/manifest.yml +10 -8
  27. data/lib/samagotchi/bundles/codex-peer/plugin.rb +4 -4
  28. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +6 -6
  29. data/lib/samagotchi/bundles/codex-peer/scripts/pair +3 -3
  30. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +6 -5
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  33. data/lib/samagotchi/config.rb +6 -21
  34. data/lib/samagotchi/context_absorber.rb +35 -21
  35. data/lib/samagotchi/engine.rb +7 -2
  36. data/lib/samagotchi/guardrails/targets.rb +9 -2
  37. data/lib/samagotchi/host_config.rb +1 -29
  38. data/lib/samagotchi/host_model.rb +0 -7
  39. data/lib/samagotchi/kernel_loop.rb +34 -7
  40. data/lib/samagotchi/llm/errors.rb +47 -1
  41. data/lib/samagotchi/llm/http.rb +20 -2
  42. data/lib/samagotchi/llm_context_stale.rb +8 -2
  43. data/lib/samagotchi/log_subscriber.rb +1 -0
  44. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  45. data/lib/samagotchi/model_price.rb +2 -4
  46. data/lib/samagotchi/peer_sources.rb +1 -1
  47. data/lib/samagotchi/plugin/api.rb +3 -2
  48. data/lib/samagotchi/plugin/peer.rb +35 -16
  49. data/lib/samagotchi/session_metrics.rb +4 -1
  50. data/lib/samagotchi/system_prompt.rb +19 -3
  51. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  52. data/lib/samagotchi/tool_activity.rb +3 -0
  53. data/lib/samagotchi/tool_output_cut.rb +312 -0
  54. data/lib/samagotchi/tool_output_files.rb +73 -0
  55. data/lib/samagotchi/tool_row_fields.rb +4 -2
  56. data/lib/samagotchi/tool_runner.rb +70 -15
  57. data/lib/samagotchi/toolkit.rb +27 -14
  58. data/lib/samagotchi/toolkit_binding.rb +8 -2
  59. data/lib/samagotchi/toolkit_command.rb +20 -4
  60. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  61. data/lib/samagotchi/tools/delegate.rb +4 -1
  62. data/lib/samagotchi/tools/delegate_wait.rb +12 -1
  63. data/lib/samagotchi/tools/ref_text.rb +27 -0
  64. data/lib/samagotchi/tools/registry.rb +95 -8
  65. data/lib/samagotchi/tools/task_wait.rb +4 -1
  66. data/lib/samagotchi/tools/tool_index.rb +223 -72
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +14 -9
  69. data/lib/samagotchi/web/message_parts.rb +9 -4
  70. data/lib/samagotchi/web/public/activity.js +3 -1
  71. data/lib/samagotchi/web/public/app.js +123 -22
  72. data/lib/samagotchi/web/public/chains.js +19 -2
  73. data/lib/samagotchi/web/public/families_view.js +15 -0
  74. data/lib/samagotchi/web/public/index.html +7 -5
  75. data/lib/samagotchi/web/public/model_picker.js +21 -6
  76. data/lib/samagotchi/web/public/session_state.js +5 -0
  77. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  78. data/lib/samagotchi/web/public/timing.js +3 -1
  79. data/lib/samagotchi/web/public/toast_place.js +16 -0
  80. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  81. data/lib/samagotchi/web/public/turn_html.js +4 -2
  82. data/lib/samagotchi/worker.rb +2 -2
  83. data/lib/samagotchi/worker_inbound.rb +3 -0
  84. metadata +5 -1
@@ -21,6 +21,7 @@ require_relative "steer"
21
21
  require_relative "reasoning_history"
22
22
  require_relative "thought_stream_splitter"
23
23
  require_relative "tools/builtins"
24
+ require_relative "tools/builtin_calls"
24
25
  require_relative "muted_memories"
25
26
  require_relative "tool_activity"
26
27
  require_relative "tool_runner"
@@ -177,6 +178,10 @@ module Samagotchi
177
178
  # a plugin tool's row refs resolve their files against (ToolRunner,
178
179
  # RowRefs); the Engine sets it. nil: the process's own (Dir.pwd).
179
180
  attr_accessor :session_cwd
181
+ # The session's folder (a callable → String or nil), where ToolRunner
182
+ # saves a cut output whole (ToolOutputFiles); the Engine sets it. nil:
183
+ # none (a bare kernel), the cut says the output wasn't saved.
184
+ attr_accessor :session_dir
180
185
  # Tools::Peers (or the Engine's live view of it): the session
181
186
  # list_sessions and send_note speak for; nil outside a session.
182
187
  attr_accessor :peers
@@ -1070,12 +1075,22 @@ module Samagotchi
1070
1075
  #
1071
1076
  # A tool the session's toolkit leaves out is a real one, so its error
1072
1077
  # does name it (deliberately, against the rule above): the model learns
1073
- # it exists and that only the user can load it.
1074
- def unknown_tool_error(name, layers)
1078
+ # it exists, and how to reach it (call_tool, when the toolkit reaches
1079
+ # it that way) or that only the user can load it. A call reached
1080
+ # through call_tool (+via+) of a tool the toolkit declares is told to
1081
+ # call it directly: the model's call_tool(tool: "chi/read") with read
1082
+ # declared, or a hook's rewrite (a direct call can't carry via:).
1083
+ def unknown_tool_error(name, layers, via: nil)
1075
1084
  names = @tools.names(layers: layers)
1076
- if @tools.respond_to?(:left_out) && @tools.left_out(name, layers: layers)
1077
- toolkit = @tools.toolkit&.name
1078
- "Error: #{name} isn't loaded in this session#{" (toolkit #{toolkit})" if toolkit} and can't be loaded from " \
1085
+ toolkit = @tools.toolkit&.name
1086
+ in_toolkit = " in this session#{" (toolkit #{toolkit})" if toolkit}"
1087
+ if via && @tools.offered(name, layers: layers)
1088
+ "Error: #{name} is declared#{in_toolkit}; call it directly. Available: #{names.join(", ")}"
1089
+ elsif !via && @tools.respond_to?(:indexed) && @tools.indexed(name, layers: layers)
1090
+ "Error: #{name} isn't declared#{in_toolkit}; call it with #{Tools::BuiltinCalls::VIA}(tool: \"chi/#{name}\", args: {…}) " \
1091
+ "(find_tools gives its inputSchema). Available: #{names.join(", ")}"
1092
+ elsif @tools.respond_to?(:left_out) && @tools.left_out(name, layers: layers)
1093
+ "Error: #{name} isn't loaded#{in_toolkit} and can't be loaded from " \
1079
1094
  "here; only the user can (/toolkit add #{name}). Available: #{names.join(", ")}"
1080
1095
  elsif SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
1081
1096
  "Error: no such tool. Shell commands run with `execute` (same arguments)."
@@ -1085,9 +1100,15 @@ module Samagotchi
1085
1100
  end
1086
1101
 
1087
1102
  def dispatch(call)
1088
- entry = @tools.offered(call[:name], layers: llm_context_layers)
1103
+ # A built-in reached through call_tool runs only while the toolkit
1104
+ # reaches it that way (Registry#indexed); any other call by its name.
1105
+ entry = if call[:via]
1106
+ @tools.respond_to?(:indexed) ? @tools.indexed(call[:name], layers: llm_context_layers) : nil
1107
+ else
1108
+ @tools.offered(call[:name], layers: llm_context_layers)
1109
+ end
1089
1110
  unless entry
1090
- result = unknown_tool_error(call[:name], llm_context_layers)
1111
+ result = unknown_tool_error(call[:name], llm_context_layers, via: call[:via])
1091
1112
  return {
1092
1113
  output: result,
1093
1114
  activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
@@ -1112,6 +1133,12 @@ module Samagotchi
1112
1133
  }
1113
1134
  # Images the tool returned (read's ImageResult, a plugin's ToolResult):
1114
1135
  # ToolRunner attaches them (or says why not).
1136
+ # A text that shows part of a file (Tools::RefText): the cut of a
1137
+ # long one names that file (ToolRunner).
1138
+ if result.respond_to?(:ref_path) && result.ref_path
1139
+ dispatched[:ref_path] = result.ref_path
1140
+ dispatched[:ref_stderr_path] = result.stderr_path if result.stderr_path
1141
+ end
1115
1142
  if result.respond_to?(:images) && !Array(result.images).empty?
1116
1143
  dispatched[:images] = Array(result.images)
1117
1144
  dispatched[:image_only] = true if result.respond_to?(:image_only?) && result.image_only?
@@ -20,7 +20,8 @@ module Samagotchi
20
20
  # asking again could help.
21
21
  #
22
22
  # ConnectionError no answer (refused, reset, timeout); RetryExhausted,
23
- # ConnectionRefused
23
+ # ConnectionRefused, TLSError (a handshake that can't
24
+ # work: certificate, host name, http vs https)
24
25
  # RateLimited 429, with retry_after when the server says
25
26
  # CreditsHeld 402 "… in-flight requests": credit reserved by other
26
27
  # requests; a RateLimited, retried after 20 s
@@ -271,6 +272,51 @@ module Samagotchi
271
272
  def summary = "can't reach host #{host}#{" at #{address}" if address} (connection refused) — is the server running?"
272
273
  end
273
274
 
275
+ # A TLS handshake that can't work however often it is tried: a
276
+ # certificate chi doesn't trust, a certificate for another name, or a
277
+ # server that doesn't speak TLS there (an https:// base_url on a plain
278
+ # HTTP port). Not retried. Any other OpenSSL::SSL::SSLError (an
279
+ # unexpected eof or a reset mid-handshake) is a network blip, retried.
280
+ class TLSError < ConnectionError
281
+ # problem => [pattern of the OpenSSL message, what to check]. The
282
+ # first match wins, so the host name comes before the general
283
+ # certificate check ("certificate verify failed (hostname mismatch)").
284
+ PROBLEMS = {
285
+ hostname: [/hostname mismatch|does not match the server certificate/i,
286
+ "the certificate is for another name: check the host's base_url"],
287
+ certificate: [/certificate verify failed/i,
288
+ "is its CA trusted? set SSL_CERT_FILE to a CA bundle that has it"],
289
+ scheme: [/wrong version number|packet length too long/i,
290
+ "the server doesn't answer TLS there: should the host's base_url start with http://?"]
291
+ }.freeze
292
+
293
+ attr_reader :address, :problem, :reason
294
+
295
+ # The permanent problem an OpenSSL::SSL::SSLError's +message+ names
296
+ # (:hostname, :certificate, :scheme), or nil for a transient one.
297
+ def self.problem_for(message)
298
+ PROBLEMS.find { |_problem, (pattern, _hint)| pattern.match?(message.to_s) }&.first
299
+ end
300
+
301
+ # The OpenSSL reason without Ruby's "SSL_connect returned=1 errno=0
302
+ # peeraddr=… state=…: " prefix.
303
+ def self.reason_for(message)
304
+ message.to_s[/\bstate=[^:]*:\s*(.+)\z/m, 1] || message.to_s
305
+ end
306
+
307
+ def initialize(host:, problem:, reason:, address: nil, attempts: 1)
308
+ @address = address
309
+ @problem = problem
310
+ @reason = reason
311
+ super("#{host}: TLS failed#{" at #{address}" if address}: #{reason}", host: host, retryable: false,
312
+ attempts: attempts)
313
+ end
314
+
315
+ def summary
316
+ "can't reach host #{host}#{" at #{address}" if address} over TLS (#{reason}) — #{PROBLEMS.fetch(problem).last}"
317
+ end
318
+ end
319
+
274
320
  # A stream that showed nothing (no text, reasoning or tool call) within
275
321
  # the host's first-token limit. A queued request can stay open for many
276
322
  # minutes on keep-alive comments alone, which reset the read timeout.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "openssl"
4
5
  require "uri"
5
6
  require_relative "../config"
6
7
  require_relative "../version"
@@ -31,9 +32,13 @@ module Samagotchi
31
32
  # the user, who can try again later.
32
33
  MAX_RETRY_AFTER = 60.0
33
34
 
35
+ # Errors that mean the request didn't get through (the probe treats
36
+ # them as "unreachable"). #retrying retries them, except a refused
37
+ # connection and a TLS error that can't work (.tls_problem).
34
38
  NETWORK_ERRORS = [
35
- Timeout::Error, EOFError, SocketError, IO::TimeoutError,
36
- Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT
39
+ Timeout::Error, EOFError, SocketError, IO::TimeoutError, OpenSSL::SSL::SSLError,
40
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ENETDOWN,
41
+ Errno::ETIMEDOUT, Errno::EPIPE, Errno::EADDRNOTAVAIL
37
42
  ].freeze
38
43
 
39
44
  # Requests of these purposes are the ones a turn waits on (or, for the
@@ -77,6 +82,13 @@ module Samagotchi
77
82
  NETWORK_ERRORS.any? { |klass| error.is_a?(klass) }
78
83
  end
79
84
 
85
+ # The permanent TLS problem +error+ is (TLSError::PROBLEMS: :certificate,
86
+ # :hostname, :scheme), or nil: not a TLS error, or a transient one
87
+ # (an unexpected eof or a reset mid-handshake) worth retrying.
88
+ def self.tls_problem(error)
89
+ error.is_a?(OpenSSL::SSL::SSLError) ? TLSError.problem_for(error.message) : nil
90
+ end
91
+
80
92
  # The ProviderError an SSE line carries (llama.cpp's `error:` event, a
81
93
  # `data:` line with an `error` object), or nil.
82
94
  def self.sse_error(line, host:)
@@ -278,6 +290,12 @@ module Samagotchi
278
290
  if e.is_a?(Errno::ECONNREFUSED)
279
291
  raise ConnectionRefused.new(host: @label, address: current[:address], attempts: attempts)
280
292
  end
293
+ # Nor is a handshake that can't work (an untrusted certificate,
294
+ # another name's, a plain HTTP server): it fails the same way.
295
+ if (problem = self.class.tls_problem(e))
296
+ raise TLSError.new(host: @label, address: current[:address], problem: problem,
297
+ reason: TLSError.reason_for(e.message), attempts: attempts)
298
+ end
281
299
 
282
300
  delay = current[:streamed] ? nil : @retry_policy.delay_for(attempts)
283
301
  raise RetryExhausted.new(attempts: attempts, last_error: e, label: @label) if delay.nil?
@@ -8,6 +8,7 @@ require_relative "model_profile"
8
8
  require_relative "tool_call_parser"
9
9
  require_relative "tools/builtin_calls"
10
10
  require_relative "tools/tool_path"
11
+ require_relative "tool_output_cut"
11
12
 
12
13
  module Samagotchi
13
14
  # The stale layer (LLMContextStrategy :stale): a file read that a later
@@ -191,11 +192,17 @@ module Samagotchi
191
192
  id = entry[:tool_call_id].to_s
192
193
  matches = id.empty? ? [] : batch.select { |call_id, _| call_id.to_s == id }
193
194
  call = matches.one? ? matches.first.last : nil
195
+ call &&= Tools::BuiltinCalls.unwrap_saved(call, entry[:content])
194
196
  texts = call && refs.size == 1 ? ToolResponse.runs_named(entry[:content], [call[:name]]) : nil
195
197
  return texts ? [[refs.first, call, texts.first.text]] : []
196
198
  end
197
199
 
200
+ # A call_tool of a built-in ran as it: its run's lead names the
201
+ # built-in (BuiltinCalls.unwrap_saved).
198
202
  calls = batch.map(&:last)
203
+ calls = calls.zip(ToolResponse.runs(entry[:content], calls.size)).map do |call, run|
204
+ Tools::BuiltinCalls.unwrap_saved(call, run&.text)
205
+ end
199
206
  texts = ToolResponse.runs_named(entry[:content], calls.map { |call| call[:name] })
200
207
  return [] unless texts && texts.size == refs.size
201
208
 
@@ -220,10 +227,9 @@ module Samagotchi
220
227
  # output ToolRunner cut at max_tool_output_chars: it doesn't hold all
221
228
  # the lines asked for, so it supersedes nothing.
222
229
  PREVIEW = "[TRUNCATED_PREVIEW_HEAD]"
223
- CUT = /\[cut: \d+ of \d+ chars; read it in parts\]\z/
224
230
 
225
231
  def partial?(body)
226
- body.start_with?("truncated=true") || body.include?(PREVIEW) || body.match?(CUT)
232
+ body.start_with?("truncated=true") || body.include?(PREVIEW) || body.match?(ToolOutputCut::HEAD_CUT)
227
233
  end
228
234
 
229
235
  # The lines a read asked for: from start_line (1) to end_line (the end).
@@ -138,6 +138,7 @@ module Samagotchi
138
138
  log(:info, :tool_call_completed, iteration: event[:iteration], tool: event[:tool],
139
139
  ms: ms, waited_ms: (waited if waited.positive?),
140
140
  output_chars: output.length, truncated: event[:output_truncated] || nil,
141
+ saved: (true if event[:output_file]),
141
142
  error: tool_error?(output) || nil)
142
143
  end
143
144
 
@@ -24,6 +24,9 @@ module Samagotchi
24
24
  # file: plugin.rb
25
25
  # sha256: sha256:0a1b...
26
26
  # requires_chi: ">= 0.1.28" # optional — a Gem::Requirement for chi's VERSION
27
+ # requires_chi_next: true # optional, shipped bundles — ignored here:
28
+ # # `rake release:bump` raises requires_chi to
29
+ # # the released chi (docs/releasing.md)
27
30
  # needs: # optional — outside commands the memories rely on
28
31
  # - command: gh # (checked on PATH, never installed; docs/memory.md)
29
32
  # why: reads PRs # optional
@@ -15,8 +15,8 @@ module Samagotchi
15
15
 
16
16
  values = raw.is_a?(Hash) ? raw.transform_keys(&:to_s).slice(*ModelPrice::KEYS) : nil
17
17
  valid = values && values.key?("input") && values.key?("output") &&
18
- # YAML's .inf and .nan are Floats too: JSON can't carry them
19
- # (hosts_json_for_env would drop every host) and NaN poisons the sums.
18
+ # YAML's .inf and .nan are Floats too: an infinite price
19
+ # isn't one, and NaN poisons the sums.
20
20
  values.values.all? { |v| v.is_a?(Numeric) && v.finite? && !v.negative? }
21
21
  unless valid
22
22
  warn_once "Warning: #{where}.price needs input and output, each a number >= 0 (USD per 1M tokens); ignored"
@@ -45,8 +45,6 @@ module Samagotchi
45
45
  ((uncached * input) + (cached_tokens * cache_read) + (cache_write_tokens * cache_write) +
46
46
  (completion_tokens * output)) / 1e6
47
47
  end
48
-
49
- def to_config = to_h.transform_keys(&:to_s)
50
48
  end
51
49
 
52
50
  # The keys a price may hold (config validation checks them too).
@@ -4,7 +4,7 @@ require_relative "context_sources"
4
4
 
5
5
  module Samagotchi
6
6
  # The attached context sources a loaded peer bundle declared as its
7
- # peer's answers (chi.peer_source: claude-review, codex-review), with the
7
+ # peer's answers (chi.peer_source: claude-outbox, codex-outbox), with the
8
8
  # peer's name ("Claude"). In-process only: a source is a peer's because
9
9
  # the bundle's code says so, never because of what a source file holds,
10
10
  # which a pushing process can write. Uninstalling the bundle ends it.
@@ -299,11 +299,12 @@ module Samagotchi
299
299
  end
300
300
 
301
301
  # A context source that holds a peer agent's answers (the peer
302
- # bundles' claude-review, codex-review): +label+ is the peer's name
302
+ # bundles' claude-outbox, codex-outbox): +label+ is the peer's name
303
303
  # ("Codex", PeerSources::LABEL), said in chi's own lines. Such a
304
304
  # source, when it is a pushed one of the session's own, wakes on
305
305
  # context.peer_turns rather than the 10-minute window, with the peer
306
- # line (docs/context.md, Waking). Declared here, in-process, never by
306
+ # line, or the closing line for an answer that agrees (docs/context.md,
307
+ # Waking). Declared here, in-process, never by
307
308
  # a source file; gone with the bundle.
308
309
  def peer_source(name, label:)
309
310
  raise ArgumentError, "this chi has no peer sources (plugins: false)" unless @registries.peer_sources
@@ -12,7 +12,7 @@ module Samagotchi
12
12
  # docs/plugins.md): a live link between a chi session and another agent's
13
13
  # session the user works with. The peer pairs (its chi-peer skill,
14
14
  # scripts/pair): it attaches the `<peer>-inbox` context, whose hint says
15
- # where to send (a subclass's #check_hint!), and `<peer>-review`, where
15
+ # where to send (a subclass's #check_hint!), and `<peer>-outbox`, where
16
16
  # its answers come back as pushes that wake chi.
17
17
  #
18
18
  # The `<peer>_send` tool sends a part of the work (a plan step, a diff, a
@@ -40,7 +40,7 @@ module Samagotchi
40
40
  # `From your thinking:` label.
41
41
  #
42
42
  # A subclass (the bundle's Plugin) sets the strings, as constants:
43
- # INBOX, REVIEW ("<peer>-inbox", "-review"), BUNDLE, NAME (the peer, as
43
+ # INBOX, OUTBOX ("<peer>-inbox", "-outbox"), BUNDLE, NAME (the peer, as
44
44
  # the model and user call it: "Claude"), APP (its product: "Claude
45
45
  # Code"), COMMAND ("/claude"), TOOL, TOOL_DESCRIPTION, TOOL_PARAMS,
46
46
  # USAGE, STATUS_CARD, SKILL_CARD, CHECKED_ON and SCRIPTS_DIRS (beside the
@@ -90,9 +90,9 @@ module Samagotchi
90
90
  end
91
91
  # A chi without chi.annotation (before 0.66) loads the bundle without the pill.
92
92
  chi.annotation "Confirm with #{peer::NAME}", command: "#{peer::COMMAND} validate" if chi.respond_to?(:annotation)
93
- # The review source is the peer's answers: its own wake budget and
93
+ # The outbox source is the peer's answers: its own wake budget and
94
94
  # line (context.peer_turns). A chi before 0.67 has no peer_source.
95
- chi.peer_source peer::REVIEW, label: peer::NAME if chi.respond_to?(:peer_source)
95
+ chi.peer_source peer::OUTBOX, label: peer::NAME if chi.respond_to?(:peer_source)
96
96
  chi.on(:before_turn) do |event|
97
97
  @lock.synchronize { @before_turn = Array(event[:messages]) }
98
98
  end
@@ -172,7 +172,7 @@ module Samagotchi
172
172
 
173
173
  def send_text(ctx, kind, text)
174
174
  "#{deliver(ctx, kind: kind, title: text_title(text), text: text)}; #{self.class::NAME}'s answer comes as the " \
175
- "#{self.class::REVIEW} context"
175
+ "#{self.class::OUTBOX} context"
176
176
  rescue SendError => e
177
177
  "#{self.class::COMMAND}: #{e.message}"
178
178
  end
@@ -240,7 +240,7 @@ module Samagotchi
240
240
  peer = self.class
241
241
  id = ctx.session_id
242
242
  sources = id ? sources(ctx) : []
243
- inbox, review = [peer::INBOX, peer::REVIEW].map { |name| sources.find { |source| source[:name] == name } }
243
+ inbox, outbox = [peer::INBOX, peer::OUTBOX].map { |name| sources.find { |source| source[:name] == name } }
244
244
  hint = inbox && inbox[:hint].to_s.strip
245
245
  paired = !(hint.nil? || hint.empty?)
246
246
  lines = if paired
@@ -250,7 +250,8 @@ module Samagotchi
250
250
  "#{peer::NAME} \"pair with chi #{id.to_s[0, 8]}\"."]
251
251
  end
252
252
  lines << "last send: #{last_send_line(ctx, id)}" if id && last_send(ctx, id)
253
- lines << "last answer (#{peer::REVIEW}): #{review_line(review)}" if review
253
+ lines << "last answer (#{peer::OUTBOX}): #{outbox_line(outbox)}" if outbox
254
+ lines << "skill: **differs from this bundle's**: #{peer::COMMAND} install-skill (Replace), then pair again" if skill_differs?(ctx)
254
255
  lines << transport_line
255
256
  ctx.card(id: peer::STATUS_CARD, title: "#{peer::BUNDLE}: #{paired ? "paired" : "not paired"}",
256
257
  body: lines.join("\n\n"), actions: [{ label: "Refresh", command: peer::COMMAND },
@@ -271,10 +272,10 @@ module Samagotchi
271
272
  "#{ago(sent["at"])} · #{sent["kind"]} · #{sent["title"]} (#{sent["chars"]} chars, #{where})"
272
273
  end
273
274
 
274
- def review_line(review)
275
- return "none yet" unless review[:fetched_at]
275
+ def outbox_line(outbox)
276
+ return "none yet" unless outbox[:fetched_at]
276
277
 
277
- [review[:summary], ago(review[:fetched_at])].compact.join(" · ")
278
+ [outbox[:summary], ago(outbox[:fetched_at])].compact.join(" · ")
278
279
  end
279
280
 
280
281
  def ago(stamp)
@@ -291,14 +292,32 @@ module Samagotchi
291
292
  # /<peer> install-skill [--yes]
292
293
  def install_skill(ctx, yes:)
293
294
  peer = self.class
294
- source = peer::SCRIPTS_DIRS.map { |dir| File.join(dir, SKILL_FILE) }.find { |path| File.file?(path) }
295
- return "#{peer::COMMAND} install-skill: this bundle has no scripts/#{SKILL_FILE}; reinstall it (chi bundle install #{peer::BUNDLE} --force)" unless source
295
+ target, text = skill_target_and_text(ctx)
296
+ return "#{peer::COMMAND} install-skill: this bundle has no scripts/#{SKILL_FILE}; reinstall it (chi bundle install #{peer::BUNDLE} --force)" unless text
297
+
298
+ yes ? write_skill(ctx, target, text) : skill_card(ctx, target, text)
299
+ end
300
+
301
+ # Where install-skill writes the peer's skill and what it writes:
302
+ # [target, text]; the text nil when the bundle has no skill file.
303
+ def skill_target_and_text(ctx)
304
+ target = File.join(peer_dir, "skills", "chi-peer", "SKILL.md")
305
+ source = self.class::SCRIPTS_DIRS.map { |dir| File.join(dir, SKILL_FILE) }.find { |path| File.file?(path) }
306
+ return [target, nil] unless source
296
307
 
297
308
  substitutions = { PAIR_PLACEHOLDER => File.join(File.dirname(source), "pair"),
298
309
  PAYLOADS_PLACEHOLDER => tilde(ctx.data_dir) }.merge(skill_substitutions(ctx))
299
- text = substitutions.reduce(File.read(source)) { |skill, (placeholder, value)| skill.gsub(placeholder, value) }
300
- target = File.join(peer_dir, "skills", "chi-peer", "SKILL.md")
301
- yes ? write_skill(ctx, target, text) : skill_card(ctx, target, text)
310
+ [target, substitutions.reduce(File.read(source)) { |skill, (placeholder, value)| skill.gsub(placeholder, value) }]
311
+ end
312
+
313
+ # Whether the peer's installed skill isn't what install-skill would
314
+ # write (an older bundle's, or the user's edit): the status card says
315
+ # so. Not installed, or unreadable: no.
316
+ def skill_differs?(ctx)
317
+ target, text = skill_target_and_text(ctx)
318
+ text && File.file?(target) && File.read(target) != text
319
+ rescue SystemCallError
320
+ false
302
321
  end
303
322
 
304
323
  def skill_card(ctx, target, text)
@@ -351,7 +370,7 @@ module Samagotchi
351
370
  def follow_up(kind)
352
371
  case kind
353
372
  when "context" then "#{self.class::NAME} takes it in; it answers only if something in it is wrong."
354
- else "#{self.class::NAME}'s answer comes as an update of the #{self.class::REVIEW} context; don't wait or poll."
373
+ else "#{self.class::NAME}'s answer comes as an update of the #{self.class::OUTBOX} context; don't wait or poll."
355
374
  end
356
375
  end
357
376
 
@@ -852,6 +852,9 @@ module Samagotchi
852
852
 
853
853
  called_as = event.dig(:activity, :called_as)
854
854
  active[:called_as] = called_as.to_s if called_as
855
+ # Reached through call_tool: the reloaded row says so (not an alias).
856
+ via = event.dig(:activity, :via)
857
+ active[:via] = via.to_s if via
855
858
  # A grep that found nothing (ok): the reloaded web row says "no match".
856
859
  active[:no_match] = true if event.dig(:activity, :no_match)
857
860
  finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
@@ -861,7 +864,7 @@ module Samagotchi
861
864
  # Caller holds the mutex.
862
865
  def finish_tool_record(active, status, waited_ms: 0)
863
866
  record = active.slice(
864
- :id, :turn_id, :iteration, :call_index, :tool, :called_as, :no_match, :started_at
867
+ :id, :turn_id, :iteration, :call_index, :tool, :called_as, :via, :no_match, :started_at
865
868
  ).merge(
866
869
  status: status,
867
870
  completed_at: now.iso8601(3),
@@ -331,7 +331,9 @@ module Samagotchi
331
331
  # The tools the session's toolkit leaves out, named once right after the
332
332
  # base prompt: sessions on another toolkit already differ at the tools,
333
333
  # and nothing later (a memory write) moves it. nil when none are (full):
334
- # the prompt is what it was before toolkits.
334
+ # the prompt is what it was before toolkits. The ones call_tool reaches
335
+ # and the ones the toolkit removes (only the user brings those back)
336
+ # get a sentence each; with call_tool not declared, one list.
335
337
  def not_loaded_line(layers)
336
338
  registry = @tools_lookup.call
337
339
  return nil unless registry.respond_to?(:left_out_names)
@@ -339,8 +341,22 @@ module Samagotchi
339
341
  names = registry.left_out_names(layers: layers)
340
342
  return nil if names.empty?
341
343
 
342
- "Not loaded in this session (toolkit #{registry.toolkit&.name}): #{names.join(", ")}. " \
343
- "Only the user can load one (/toolkit add <name>)."
344
+ toolkit = "(toolkit #{registry.toolkit&.name})"
345
+ reachable = registry.reachable_names(layers: layers)
346
+ if reachable.empty?
347
+ return "Not loaded in this session #{toolkit}: #{names.join(", ")}. Only the user can load one (/toolkit add <name>)."
348
+ end
349
+
350
+ removed = names - reachable
351
+ # find_tools removed (call_tool still works): the route without it.
352
+ how = if removed.include?("find_tools") then "Call one with call_tool(tool: \"chi/<name>\", args: {…})."
353
+ else "Search them with find_tools; call one with call_tool."
354
+ end
355
+ line = "Not declared in this session #{toolkit}: #{reachable.join(", ")}. #{how}"
356
+ return line if removed.empty?
357
+
358
+ "#{line} Removed #{toolkit}: #{removed.join(", ")}. Only the user can load #{removed.size == 1 ? "it" : "one"} " \
359
+ "(/toolkit add <name>)."
344
360
  end
345
361
 
346
362
  def with_tail_declarations(prompt, chat:, layers:)
@@ -34,7 +34,8 @@ module Samagotchi
34
34
  # What a tool line says the call did: the model's description of a
35
35
  # command (": List the specs") in place of its cut params, else the
36
36
  # params, dim; then, dim, the name the model called it by when that was
37
- # an alias's (" (called as bash)").
37
+ # an alias's (" (called as bash)"), or the tool it was reached through
38
+ # (" (via call_tool)").
38
39
  def tool_params_suffix(activity)
39
40
  description = activity[:description].to_s.strip
40
41
  params = activity[:params].to_s.strip
@@ -43,7 +44,9 @@ module Samagotchi
43
44
  else ""
44
45
  end
45
46
  called_as = activity[:called_as].to_s
46
- called_as.empty? ? suffix : "#{suffix}#{paint(" (called as #{called_as})", 90)}"
47
+ suffix = "#{suffix}#{paint(" (called as #{called_as})", 90)}" unless called_as.empty?
48
+ via = activity[:via].to_s
49
+ via.empty? ? suffix : "#{suffix}#{paint(" (via #{via})", 90)}"
47
50
  end
48
51
 
49
52
  # Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
@@ -56,6 +56,9 @@ module Samagotchi
56
56
  # The name the model used when an alias ran a built-in (called_as: "bash"
57
57
  # for an execute), for a UI that shows it.
58
58
  event[:called_as] = call[:called_as] if call.is_a?(Hash) && call[:called_as]
59
+ # The tool it was reached through (via: "call_tool", a built-in the
60
+ # toolkit doesn't declare): not an alias, the model named it.
61
+ event[:via] = call[:via] if call.is_a?(Hash) && call[:via]
59
62
  # The model's own words for a command, which the TUI's tool line
60
63
  # shows in place of the cut params.
61
64
  description = command_description(tool_name, call)