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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +6 -1
- data/docs/configuration.md +31 -8
- data/docs/context.md +15 -6
- data/docs/guardrails.md +23 -1
- data/docs/hooks.md +5 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +25 -13
- data/docs/internals/prompt-caching.md +9 -2
- data/docs/internals/tool-guardrails.md +61 -1
- data/docs/plugins.md +66 -38
- data/docs/releasing.md +14 -6
- data/docs/sessions.md +5 -5
- data/docs/sub-agent.md +2 -1
- data/docs/testing.md +8 -1
- data/lib/samagotchi/bootstrap/probe.rb +1 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +10 -8
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +4 -4
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +6 -6
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +3 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +6 -5
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +10 -8
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +4 -4
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +6 -6
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +3 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +6 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +6 -21
- data/lib/samagotchi/context_absorber.rb +35 -21
- data/lib/samagotchi/engine.rb +7 -2
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/host_config.rb +1 -29
- data/lib/samagotchi/host_model.rb +0 -7
- data/lib/samagotchi/kernel_loop.rb +34 -7
- data/lib/samagotchi/llm/errors.rb +47 -1
- data/lib/samagotchi/llm/http.rb +20 -2
- data/lib/samagotchi/llm_context_stale.rb +8 -2
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
- data/lib/samagotchi/model_price.rb +2 -4
- data/lib/samagotchi/peer_sources.rb +1 -1
- data/lib/samagotchi/plugin/api.rb +3 -2
- data/lib/samagotchi/plugin/peer.rb +35 -16
- data/lib/samagotchi/session_metrics.rb +4 -1
- data/lib/samagotchi/system_prompt.rb +19 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
- data/lib/samagotchi/tool_activity.rb +3 -0
- data/lib/samagotchi/tool_output_cut.rb +312 -0
- data/lib/samagotchi/tool_output_files.rb +73 -0
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +70 -15
- data/lib/samagotchi/toolkit.rb +27 -14
- data/lib/samagotchi/toolkit_binding.rb +8 -2
- data/lib/samagotchi/toolkit_command.rb +20 -4
- data/lib/samagotchi/tools/builtin_calls.rb +45 -1
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/tools/delegate_wait.rb +12 -1
- data/lib/samagotchi/tools/ref_text.rb +27 -0
- data/lib/samagotchi/tools/registry.rb +95 -8
- data/lib/samagotchi/tools/task_wait.rb +4 -1
- data/lib/samagotchi/tools/tool_index.rb +223 -72
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -9
- data/lib/samagotchi/web/message_parts.rb +9 -4
- data/lib/samagotchi/web/public/activity.js +3 -1
- data/lib/samagotchi/web/public/app.js +123 -22
- data/lib/samagotchi/web/public/chains.js +19 -2
- data/lib/samagotchi/web/public/families_view.js +15 -0
- data/lib/samagotchi/web/public/index.html +7 -5
- data/lib/samagotchi/web/public/model_picker.js +21 -6
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +5 -2
- data/lib/samagotchi/web/public/timing.js +3 -1
- data/lib/samagotchi/web/public/toast_place.js +16 -0
- data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/worker.rb +2 -2
- data/lib/samagotchi/worker_inbound.rb +3 -0
- 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
|
|
1074
|
-
|
|
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
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
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.
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -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::
|
|
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?(
|
|
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:
|
|
19
|
-
#
|
|
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-
|
|
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-
|
|
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,
|
|
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>-
|
|
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,
|
|
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
|
|
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::
|
|
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::
|
|
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,
|
|
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::
|
|
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
|
|
275
|
-
return "none yet" unless
|
|
275
|
+
def outbox_line(outbox)
|
|
276
|
+
return "none yet" unless outbox[:fetched_at]
|
|
276
277
|
|
|
277
|
-
[
|
|
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
|
-
|
|
295
|
-
return "#{peer::COMMAND} install-skill: this bundle has no scripts/#{SKILL_FILE}; reinstall it (chi bundle install #{peer::BUNDLE} --force)" unless
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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::
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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)
|