samagotchi 0.36.0 → 0.37.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 +25 -1
- data/README.md +2 -2
- data/bin/chi +1 -1
- data/docs/broadcast.md +91 -12
- data/docs/configuration.md +8 -1
- data/docs/guardrails.md +15 -0
- data/docs/plugins.md +23 -2
- data/lib/samagotchi/broadcast/triage.rb +267 -0
- data/lib/samagotchi/broadcast/triage_log.rb +165 -0
- data/lib/samagotchi/broadcast/triage_model.rb +56 -0
- data/lib/samagotchi/broadcast_command.rb +273 -69
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +35 -23
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cli/command.rb +27 -3
- data/lib/samagotchi/config.rb +9 -0
- data/lib/samagotchi/context_command.rb +0 -17
- data/lib/samagotchi/engine.rb +4 -6
- data/lib/samagotchi/guardrail_wiring.rb +7 -2
- data/lib/samagotchi/guardrails/approval.rb +5 -2
- data/lib/samagotchi/guardrails/approvals.rb +9 -5
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/targets.rb +76 -11
- data/lib/samagotchi/idle_client.rb +48 -28
- data/lib/samagotchi/idle_recap.rb +6 -5
- data/lib/samagotchi/idle_target.rb +95 -0
- data/lib/samagotchi/llm/openai_chat.rb +29 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/note_command.rb +0 -21
- data/lib/samagotchi/recap_setup.rb +38 -52
- data/lib/samagotchi/send_command.rb +0 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/question_card.js +4 -3
- metadata +5 -1
|
@@ -24,10 +24,14 @@ module Samagotchi
|
|
|
24
24
|
# Raised when summarization fails (server down, timeout, malformed body…).
|
|
25
25
|
class SummarizeError < StandardError; end
|
|
26
26
|
|
|
27
|
-
# What #summarize
|
|
28
|
-
# (the server's name for it; nil when the reply names none).
|
|
29
|
-
# the
|
|
30
|
-
|
|
27
|
+
# What #summarize and #ask return: the text and the model that
|
|
28
|
+
# answered (the server's name for it; nil when the reply names none).
|
|
29
|
+
# +top_logprobs+: the first token's alternatives (LLM::TokenLogprob)
|
|
30
|
+
# when #ask asked for logprobs and the server sent them, else [].
|
|
31
|
+
# #to_s is the text.
|
|
32
|
+
Summary = Data.define(:text, :model, :top_logprobs) do
|
|
33
|
+
def initialize(top_logprobs: [].freeze, **) = super
|
|
34
|
+
|
|
31
35
|
def to_s = text
|
|
32
36
|
end
|
|
33
37
|
|
|
@@ -40,12 +44,21 @@ module Samagotchi
|
|
|
40
44
|
# @param timeout [Numeric] HTTP request timeout. Kept to the recap's own
|
|
41
45
|
# wait budget (not the chat's global request_timeout) so an abandoned
|
|
42
46
|
# summarize thread can't outlive the recap attempt by minutes.
|
|
43
|
-
|
|
47
|
+
# @param purpose [String] what the requests are for, in the HTTP log
|
|
48
|
+
# lines and error messages ("recap", "broadcast")
|
|
49
|
+
def initialize(model:, base_url: nil, api_key_env: nil, timeout: DEFAULT_TIMEOUT_SECONDS, env: ENV, purpose: "recap")
|
|
44
50
|
@model = model
|
|
45
51
|
# A recap is best-effort: one short attempt, no retries. The idle job
|
|
46
52
|
# tries again after the next activity, never on its own.
|
|
47
|
-
@chat = LLM::OpenAIChat.new(base_url: base_url.to_s, host_name:
|
|
48
|
-
stream: false, retries: false, timeout: timeout, env: env, purpose:
|
|
53
|
+
@chat = LLM::OpenAIChat.new(base_url: base_url.to_s, host_name: purpose, api_key_env: api_key_env,
|
|
54
|
+
stream: false, retries: false, timeout: timeout, env: env, purpose: purpose)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A client for +target+ (an IdleTarget: a recap's, ctx.ask_model's, a
|
|
58
|
+
# broadcast's triage model).
|
|
59
|
+
# @return [IdleClient]
|
|
60
|
+
def self.for(target, **)
|
|
61
|
+
new(model: target.model, base_url: target.base_url, api_key_env: target.api_key_env, **)
|
|
49
62
|
end
|
|
50
63
|
|
|
51
64
|
THINK_RE = /<\|think\|.*?\|think\|>/m
|
|
@@ -77,25 +90,31 @@ module Samagotchi
|
|
|
77
90
|
messages = prompt.is_a?(Array) ? prompt : [{ role: "user", content: prompt.to_s.strip }]
|
|
78
91
|
return nil if messages.all? { |m| m[:content].to_s.strip.empty? }
|
|
79
92
|
|
|
80
|
-
|
|
81
|
-
cleaned =
|
|
82
|
-
cleaned.empty? ? nil : Summary.new(text: cleaned, model:
|
|
93
|
+
response = generate(messages)
|
|
94
|
+
cleaned = response.text.to_s.strip
|
|
95
|
+
cleaned.empty? ? nil : Summary.new(text: cleaned, model: response.model)
|
|
83
96
|
rescue SummarizeError
|
|
84
97
|
raise
|
|
85
98
|
rescue StandardError => e
|
|
86
99
|
raise SummarizeError, "recap summarization failed: #{e.class}: #{e.message}"
|
|
87
100
|
end
|
|
88
101
|
|
|
89
|
-
# One side answer (a plugin's ctx.ask_model
|
|
90
|
-
# tools, thinking off. An answer cut off by
|
|
91
|
-
# is, marked with "…". Cancelling
|
|
102
|
+
# One side answer (a plugin's ctx.ask_model, a broadcast's triage):
|
|
103
|
+
# +messages+ as they are, no tools, thinking off. An answer cut off by
|
|
104
|
+
# +max_tokens+ is kept as it is, marked with "…". Cancelling
|
|
105
|
+
# +cancel_controller+ aborts the request.
|
|
106
|
+
# @param kind [String] what it is for, in the usage log line
|
|
107
|
+
# @param options [Hash] more request body fields (logprobs: true,
|
|
108
|
+
# top_logprobs: 5)
|
|
92
109
|
# @return [Summary] the answer ("" when the model said nothing)
|
|
93
|
-
# @raise [SummarizeError] any failure but a cancel
|
|
110
|
+
# @raise [SummarizeError] any failure but a cancel; its #cause is an
|
|
111
|
+
# LLM::BadRequest when the host refused the request (+options+ it
|
|
112
|
+
# doesn't take, say)
|
|
94
113
|
# @raise [LLM::RequestCancelled] +cancel_controller+ was cancelled
|
|
95
|
-
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
Summary.new(text:
|
|
114
|
+
def ask(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, kind: "ask", options: {})
|
|
115
|
+
response = generate(messages, max_tokens: max_tokens, cancel_controller: cancel_controller, whole_sentences: false,
|
|
116
|
+
kind: kind, options: options)
|
|
117
|
+
Summary.new(text: response.text.to_s.strip, model: response.model, top_logprobs: response.top_logprobs)
|
|
99
118
|
rescue SummarizeError, LLM::RequestCancelled
|
|
100
119
|
raise
|
|
101
120
|
rescue StandardError => e
|
|
@@ -104,15 +123,16 @@ module Samagotchi
|
|
|
104
123
|
|
|
105
124
|
private
|
|
106
125
|
|
|
107
|
-
# One plain /chat/completions request. Returns the cleaned
|
|
108
|
-
# ("" when there is nothing after stripping)
|
|
109
|
-
# (nil when the reply names none)
|
|
126
|
+
# One plain /chat/completions request. Returns a Summary of the cleaned
|
|
127
|
+
# assistant text ("" when there is nothing after stripping), the served
|
|
128
|
+
# model's name (nil when the reply names none) and the first token's
|
|
129
|
+
# logprobs. Raises SummarizeError when
|
|
110
130
|
# the reply has neither content nor reasoning_content. Cut off by
|
|
111
131
|
# +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
|
|
112
132
|
# or all of it, with "…".
|
|
113
|
-
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap")
|
|
133
|
+
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true, kind: "recap", options: {})
|
|
114
134
|
response = begin
|
|
115
|
-
request(messages, max_tokens, cancel_controller)
|
|
135
|
+
request(messages, max_tokens, cancel_controller, options)
|
|
116
136
|
rescue LLM::BadRequest => e
|
|
117
137
|
raise unless e.reasoning_refused? && !@thinking_refused
|
|
118
138
|
|
|
@@ -120,7 +140,7 @@ module Samagotchi
|
|
|
120
140
|
# fields, and leave them out from now on.
|
|
121
141
|
@thinking_refused = true
|
|
122
142
|
Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
|
|
123
|
-
request(messages, max_tokens, cancel_controller)
|
|
143
|
+
request(messages, max_tokens, cancel_controller, options)
|
|
124
144
|
end
|
|
125
145
|
log_usage(response, kind)
|
|
126
146
|
content = response.text
|
|
@@ -130,7 +150,7 @@ module Samagotchi
|
|
|
130
150
|
cut_off = response.finish_reason == "length"
|
|
131
151
|
# Reasoning cut off by max_tokens is the model's thinking, not a recap
|
|
132
152
|
# (a server that ignores the thinking switch thinks until the limit).
|
|
133
|
-
return
|
|
153
|
+
return Summary.new(text: "", model: response.model, top_logprobs: response.top_logprobs) if content.empty? && cut_off
|
|
134
154
|
|
|
135
155
|
# Prefer content, fall back to a finished reasoning_content (e.g.
|
|
136
156
|
# Qwen3.6), and strip thinking tokens some models (Qwen, Gemma) leave
|
|
@@ -138,7 +158,7 @@ module Samagotchi
|
|
|
138
158
|
text = self.class.strip_thinking(content.empty? ? reasoning : content)
|
|
139
159
|
# Cut off by max_tokens: keep the sentences that finished ("" if none).
|
|
140
160
|
text = whole_sentences ? self.class.full_sentences(text) : "#{text}…" if cut_off && !text.empty?
|
|
141
|
-
|
|
161
|
+
Summary.new(text: text, model: response.model, top_logprobs: response.top_logprobs)
|
|
142
162
|
rescue LLM::ProtocolError => e
|
|
143
163
|
raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
|
|
144
164
|
end
|
|
@@ -153,9 +173,9 @@ module Samagotchi
|
|
|
153
173
|
cached: fields[:cached_tokens], cache_write: fields[:cache_write_tokens])
|
|
154
174
|
end
|
|
155
175
|
|
|
156
|
-
def request(messages, max_tokens, cancel_controller)
|
|
176
|
+
def request(messages, max_tokens, cancel_controller, options = {})
|
|
157
177
|
@chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
|
|
158
|
-
options: { max_tokens: max_tokens, **thinking_fields })
|
|
178
|
+
options: { max_tokens: max_tokens, **thinking_fields, **options })
|
|
159
179
|
end
|
|
160
180
|
|
|
161
181
|
# A recap or side answer is short and tool-less: thinking off, whatever
|
|
@@ -6,6 +6,7 @@ require "monitor"
|
|
|
6
6
|
require "time"
|
|
7
7
|
|
|
8
8
|
require_relative "idle_client"
|
|
9
|
+
require_relative "idle_target"
|
|
9
10
|
require_relative "output_formatter"
|
|
10
11
|
require_relative "steer"
|
|
11
12
|
require_relative "recap_store"
|
|
@@ -279,7 +280,7 @@ module Samagotchi
|
|
|
279
280
|
# attempt (the session's current model, so a /model switch counts).
|
|
280
281
|
# @param base_url [String] the OpenAI API base the recap asks
|
|
281
282
|
# @param api_key_env [String, nil] the variable holding its key
|
|
282
|
-
# @param target [#call, nil] ->
|
|
283
|
+
# @param target [#call, nil] -> IdleTarget
|
|
283
284
|
def initialize(engine:, model: nil, base_url: nil, api_key_env: nil, target: nil,
|
|
284
285
|
inactivity: DEFAULT_INACTIVITY_SECONDS,
|
|
285
286
|
min_user_turns: DEFAULT_MIN_USER_TURNS,
|
|
@@ -292,7 +293,7 @@ module Samagotchi
|
|
|
292
293
|
|
|
293
294
|
@engine = engine
|
|
294
295
|
@target = target || lambda {
|
|
295
|
-
|
|
296
|
+
IdleTarget.new(base_url: base_url, api_key_env: api_key_env, model: model, label: model)
|
|
296
297
|
}
|
|
297
298
|
@inactivity = inactivity
|
|
298
299
|
@min_user_turns = min_user_turns
|
|
@@ -455,7 +456,7 @@ module Samagotchi
|
|
|
455
456
|
|
|
456
457
|
asked = target
|
|
457
458
|
@in_flight = { thread: spawn_summarize(client_for(asked), prompt), generation: gen, deadline: @clock.call + @timeout,
|
|
458
|
-
covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked
|
|
459
|
+
covered: parsed.size, covered_digest: self.class.digest(parsed.last), model: asked.label }
|
|
459
460
|
:started
|
|
460
461
|
rescue StandardError
|
|
461
462
|
:failed
|
|
@@ -534,9 +535,9 @@ module Samagotchi
|
|
|
534
535
|
def client_for(asked)
|
|
535
536
|
return @client_override if @client_override
|
|
536
537
|
|
|
537
|
-
key = asked.
|
|
538
|
+
key = [asked.base_url, asked.api_key_env, asked.model]
|
|
538
539
|
unless @client && @client_key == key
|
|
539
|
-
@client = IdleClient.
|
|
540
|
+
@client = IdleClient.for(asked, timeout: @timeout)
|
|
540
541
|
@client_key = key
|
|
541
542
|
end
|
|
542
543
|
@client
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# Where a side request goes (a recap, a plugin's ctx.ask_model, a
|
|
5
|
+
# broadcast's triage): a model on a host's OpenAI API, asked through its
|
|
6
|
+
# own IdleClient (IdleClient.for), apart from the session's turns.
|
|
7
|
+
# @!attribute base_url [String] the host's OpenAI API base
|
|
8
|
+
# @!attribute api_key_env [String, nil] the variable holding its key
|
|
9
|
+
# @!attribute model [String] the model id sent to the server
|
|
10
|
+
# @!attribute label [String] the model as configured, for logs
|
|
11
|
+
IdleTarget = Data.define(:base_url, :api_key_env, :model, :label) do
|
|
12
|
+
# A configured side model can't be resolved.
|
|
13
|
+
# @!attribute reason [Symbol] :host_ref_unknown (no such host),
|
|
14
|
+
# :model_host_mismatch (the model names another host than host_ref)
|
|
15
|
+
# or :incomplete (no base_url or no model)
|
|
16
|
+
class self::Unresolved < StandardError
|
|
17
|
+
attr_reader :reason, :model, :host_ref, :named
|
|
18
|
+
|
|
19
|
+
def initialize(reason, model: nil, host_ref: nil, named: nil)
|
|
20
|
+
@reason = reason
|
|
21
|
+
@model = model
|
|
22
|
+
@host_ref = host_ref
|
|
23
|
+
@named = named
|
|
24
|
+
super("side model #{model.inspect} can't be resolved: #{reason}")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# +name+ (a model ref) as a turn resolves it: its alias applied, its
|
|
29
|
+
# host, or where a bare --model goes (HostRegistry#resolve, which may
|
|
30
|
+
# list a host's models).
|
|
31
|
+
# @param host_registry [HostRegistry]
|
|
32
|
+
# @return [IdleTarget]
|
|
33
|
+
def self.of_model(host_registry, name)
|
|
34
|
+
resolved = host_registry.resolve(name)
|
|
35
|
+
new(base_url: resolved.openai_base_url, api_key_env: resolved.entry.api_key_env, model: resolved.bare_model,
|
|
36
|
+
label: name.to_s.strip)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A side model's settings (recap.*, broadcast.triage_*) as a target:
|
|
40
|
+
# +base_url+ with +model+; +host_ref+ (a hosts: name) with +model+;
|
|
41
|
+
# +model+ alone (its own host, its alias's, or where a bare --model
|
|
42
|
+
# goes). Blank values count as unset.
|
|
43
|
+
# @param host_registry [HostRegistry]
|
|
44
|
+
# @return [IdleTarget, nil] nil when all three are unset (the caller
|
|
45
|
+
# picks its default)
|
|
46
|
+
# @raise [Unresolved]
|
|
47
|
+
def self.resolve(model:, host_ref:, base_url:, host_registry:)
|
|
48
|
+
model, host_ref, base_url = [model, host_ref, base_url].map { |value| blank(value) }
|
|
49
|
+
return nil if model.nil? && host_ref.nil? && base_url.nil?
|
|
50
|
+
|
|
51
|
+
label = model.to_s
|
|
52
|
+
parsed = model && host_registry.model_ref(model)
|
|
53
|
+
return of_model(host_registry, model) unless host_named?(model: model, host_ref: host_ref, base_url: base_url,
|
|
54
|
+
host_registry: host_registry)
|
|
55
|
+
|
|
56
|
+
host_ref ||= parsed.host_name if parsed && base_url.nil?
|
|
57
|
+
api_key_env = nil
|
|
58
|
+
if host_ref
|
|
59
|
+
entry = host_registry.find_entry(host_ref)
|
|
60
|
+
raise self::Unresolved.new(:host_ref_unknown, model: model, host_ref: host_ref) unless entry
|
|
61
|
+
|
|
62
|
+
base_url = entry.openai_base_url
|
|
63
|
+
api_key_env = entry.api_key_env
|
|
64
|
+
named = parsed&.host_conflict || parsed&.host_name
|
|
65
|
+
if named && named != entry.name
|
|
66
|
+
raise self::Unresolved.new(:model_host_mismatch, model: model, host_ref: host_ref, named: named)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
model = parsed.id if parsed
|
|
70
|
+
elsif parsed && !parsed.host_name
|
|
71
|
+
model = parsed.id
|
|
72
|
+
end
|
|
73
|
+
raise self::Unresolved.new(:incomplete, model: model, host_ref: host_ref) if blank(base_url).nil? || blank(model).nil?
|
|
74
|
+
|
|
75
|
+
new(base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.strip)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Whether the settings fix a host (+base_url+, +host_ref+, or a model
|
|
79
|
+
# ref naming one), so resolving them once holds; a model alone that
|
|
80
|
+
# names none goes where a bare --model goes, which a host's model list
|
|
81
|
+
# decides (resolve it at each request).
|
|
82
|
+
def self.host_named?(model:, host_ref:, base_url:, host_registry:)
|
|
83
|
+
return true if blank(base_url) || blank(host_ref)
|
|
84
|
+
|
|
85
|
+
model = blank(model)
|
|
86
|
+
!model.nil? && !host_registry.model_ref(model).host_name.nil?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.blank(value)
|
|
90
|
+
text = value.to_s.strip
|
|
91
|
+
text.empty? ? nil : text
|
|
92
|
+
end
|
|
93
|
+
private_class_method :blank
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -40,8 +40,34 @@ module Samagotchi
|
|
|
40
40
|
# OpenRouter's routing name); nil when the server says nothing.
|
|
41
41
|
# +cut+ is set on the empty response the chat loop makes for a
|
|
42
42
|
# generation a plugin cut (stop_generation): {by:, reason:}.
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
# +top_logprobs+: the first answer token's likeliest alternatives
|
|
44
|
+
# (TokenLogprob) when the request asked for logprobs and the server
|
|
45
|
+
# sent them (a non-streamed answer only); [] otherwise.
|
|
46
|
+
ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut,
|
|
47
|
+
:top_logprobs) do
|
|
48
|
+
def initialize(model: nil, provider: nil, cut: nil, top_logprobs: [].freeze, **fields) = super
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# One alternative for an answer token: its text and log probability.
|
|
52
|
+
TokenLogprob = Data.define(:token, :logprob) do
|
|
53
|
+
# The first token's alternatives in a chat completion body's
|
|
54
|
+
# choices[0].logprobs (OpenAI's shape); [] when there are none or the
|
|
55
|
+
# body has another shape (it runs on every non-streamed answer: it
|
|
56
|
+
# never raises).
|
|
57
|
+
def self.first_of(body)
|
|
58
|
+
top = [%w[choices], [0], %w[logprobs], %w[content], [0], %w[top_logprobs]].reduce(body) do |node, (key)|
|
|
59
|
+
break nil unless key.is_a?(Integer) ? node.is_a?(Array) : node.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
node[key]
|
|
62
|
+
end
|
|
63
|
+
return [].freeze unless top.is_a?(Array)
|
|
64
|
+
|
|
65
|
+
top.filter_map do |entry|
|
|
66
|
+
next unless entry.is_a?(Hash) && entry["token"].is_a?(String) && entry["logprob"].is_a?(Numeric)
|
|
67
|
+
|
|
68
|
+
new(token: entry["token"], logprob: entry["logprob"].to_f)
|
|
69
|
+
end.freeze
|
|
70
|
+
end
|
|
45
71
|
end
|
|
46
72
|
|
|
47
73
|
# The OpenAI Chat Completions API (llama.cpp's /v1, and any compatible
|
|
@@ -334,7 +360,7 @@ module Samagotchi
|
|
|
334
360
|
reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
|
|
335
361
|
tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
|
|
336
362
|
finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body),
|
|
337
|
-
provider: self.class.served_provider(body))
|
|
363
|
+
provider: self.class.served_provider(body), top_logprobs: TokenLogprob.first_of(body))
|
|
338
364
|
end
|
|
339
365
|
|
|
340
366
|
def served_model(payload) = self.class.served_model(payload)
|
data/lib/samagotchi/log_line.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Samagotchi
|
|
|
16
16
|
# (debug dumps) are indented by four spaces and belong to the record above.
|
|
17
17
|
module LogLine
|
|
18
18
|
LEVELS = %w[DEBUG INFO WARN ERROR].freeze
|
|
19
|
-
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context].freeze
|
|
19
|
+
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast].freeze
|
|
20
20
|
INDENT = " "
|
|
21
21
|
|
|
22
22
|
Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
|
|
@@ -91,20 +91,6 @@ module Samagotchi
|
|
|
91
91
|
options
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
# Only a pipe or a file is read, as chi send does. A terminal means
|
|
95
|
-
# nobody piped a note in, and a socket a launcher or an agent's shell
|
|
96
|
-
# passes down may never close: waiting on either would hang a script.
|
|
97
|
-
def read_stdin
|
|
98
|
-
return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
|
|
99
|
-
|
|
100
|
-
if @stdin.respond_to?(:stat)
|
|
101
|
-
stat = @stdin.stat
|
|
102
|
-
return nil unless stat.pipe? || stat.file?
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
@stdin.read
|
|
106
|
-
end
|
|
107
|
-
|
|
108
94
|
# --all: every live session. @return [Integer] exit status
|
|
109
95
|
def deliver_to_live(text, source)
|
|
110
96
|
ids = SessionManager.session_summaries(live: true, include_tests: Session.test_session_env?,
|
|
@@ -141,12 +127,5 @@ module Samagotchi
|
|
|
141
127
|
else "waits for the session's next start (#{result.queued} #{result.queued == 1 ? "note" : "notes"} queued)"
|
|
142
128
|
end
|
|
143
129
|
end
|
|
144
|
-
|
|
145
|
-
# The note as UTF-8 whatever the locale says: with no LANG/LC_* (an app
|
|
146
|
-
# started from Finder, launchd) stdin reads as US-ASCII and ARGV as
|
|
147
|
-
# binary. Invalid bytes become U+FFFD rather than an error.
|
|
148
|
-
def utf8(text)
|
|
149
|
-
text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
150
|
-
end
|
|
151
130
|
end
|
|
152
131
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "config"
|
|
4
4
|
require_relative "log"
|
|
5
5
|
require_relative "idle_recap"
|
|
6
|
+
require_relative "idle_target"
|
|
6
7
|
require_relative "recap_store"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
@@ -41,60 +42,21 @@ module Samagotchi
|
|
|
41
42
|
base_url = string_config(kwarg_config, :base_url) || registry_string("recap.base_url")
|
|
42
43
|
host_ref = string_config(kwarg_config, :host_ref) || string_config(kwarg_config, :host) || registry_string("recap.host_ref")
|
|
43
44
|
model = string_config(kwarg_config, :model) || registry_string("recap.model")
|
|
44
|
-
label = model
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
target = lambda do
|
|
58
|
-
resolved = host_registry.resolve(model)
|
|
59
|
-
{ base_url: resolved.openai_base_url, api_key_env: resolved.entry.api_key_env,
|
|
60
|
-
model: resolved.bare_model, label: label.to_s.strip }
|
|
61
|
-
end
|
|
62
|
-
else
|
|
63
|
-
host_ref ||= parsed.host_name if parsed && base_url.nil?
|
|
64
|
-
# If host_ref given, derive base_url (the host's OpenAI base) and its
|
|
65
|
-
# API key variable from the host_registry entry
|
|
66
|
-
api_key_env = nil
|
|
67
|
-
if host_ref && !host_ref.empty?
|
|
68
|
-
entry = host_registry.find_entry(host_ref)
|
|
69
|
-
if entry
|
|
70
|
-
base_url = entry.openai_base_url
|
|
71
|
-
api_key_env = entry.api_key_env
|
|
72
|
-
named = parsed&.host_conflict || parsed&.host_name
|
|
73
|
-
if named && named != entry.name
|
|
74
|
-
Log.warn(:recap, "model_host_mismatch",
|
|
75
|
-
echo: "Warning: recap model '#{model}' names host '#{named}', not recap.host_ref '#{host_ref}'; recap disabled.",
|
|
76
|
-
model: model, host_ref: host_ref)
|
|
77
|
-
return nil
|
|
78
|
-
end
|
|
79
|
-
model = parsed.id if parsed
|
|
80
|
-
else
|
|
81
|
-
Log.warn(:recap, "host_ref_unknown", echo: "Warning: recap host_ref '#{host_ref}' not found in hosts:; recap disabled.", host_ref: host_ref)
|
|
82
|
-
return nil
|
|
83
|
-
end
|
|
84
|
-
elsif parsed && !parsed.host_name
|
|
85
|
-
model = parsed.id
|
|
86
|
-
end
|
|
46
|
+
settings = { model: model, host_ref: host_ref, base_url: base_url, host_registry: host_registry }
|
|
47
|
+
target = if base_url.nil? && host_ref.nil? && model.nil?
|
|
48
|
+
-> { session_target.call }
|
|
49
|
+
elsif !IdleTarget.host_named?(**settings)
|
|
50
|
+
# A model naming no host goes where a bare --model goes
|
|
51
|
+
# (HostRegistry#host_for_model: the default host unless
|
|
52
|
+
# another's list has it), resolved at each attempt.
|
|
53
|
+
-> { IdleTarget.resolve(**settings) }
|
|
54
|
+
else
|
|
55
|
+
fixed = fixed_target(settings)
|
|
56
|
+
return nil unless fixed
|
|
87
57
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
echo: "Warning: SAMAGOTCHI session recap is enabled but base_url/model are missing; recap disabled. " \
|
|
91
|
-
"Set recap: {host_ref:, model:} or SAMAGOTCHI_RECAP_BASE_URL and SAMAGOTCHI_RECAP_MODEL (or pass recap: {base_url:, model:}), " \
|
|
92
|
-
"or leave them all out to recap with the session's own model.")
|
|
93
|
-
return nil
|
|
94
|
-
end
|
|
95
|
-
fixed = { base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.to_s.strip }
|
|
96
|
-
target = -> { fixed }
|
|
97
|
-
end
|
|
58
|
+
-> { fixed }
|
|
59
|
+
end
|
|
98
60
|
|
|
99
61
|
IdleRecap.new(
|
|
100
62
|
engine: engine,
|
|
@@ -107,6 +69,30 @@ module Samagotchi
|
|
|
107
69
|
)
|
|
108
70
|
end
|
|
109
71
|
|
|
72
|
+
# The recap's pinned host and model (IdleTarget.resolve), or nil after
|
|
73
|
+
# a warning when they can't be resolved (recap stays off).
|
|
74
|
+
# @return [IdleTarget, nil]
|
|
75
|
+
def self.fixed_target(settings)
|
|
76
|
+
IdleTarget.resolve(**settings)
|
|
77
|
+
rescue IdleTarget::Unresolved => e
|
|
78
|
+
case e.reason
|
|
79
|
+
when :host_ref_unknown
|
|
80
|
+
Log.warn(:recap, "host_ref_unknown", echo: "Warning: recap host_ref '#{e.host_ref}' not found in hosts:; recap disabled.",
|
|
81
|
+
host_ref: e.host_ref)
|
|
82
|
+
when :model_host_mismatch
|
|
83
|
+
Log.warn(:recap, "model_host_mismatch",
|
|
84
|
+
echo: "Warning: recap model '#{e.model}' names host '#{e.named}', not recap.host_ref '#{e.host_ref}'; recap disabled.",
|
|
85
|
+
model: e.model, host_ref: e.host_ref)
|
|
86
|
+
else
|
|
87
|
+
Log.warn(:recap, "recap_unconfigured",
|
|
88
|
+
echo: "Warning: SAMAGOTCHI session recap is enabled but base_url/model are missing; recap disabled. " \
|
|
89
|
+
"Set recap: {host_ref:, model:} or SAMAGOTCHI_RECAP_BASE_URL and SAMAGOTCHI_RECAP_MODEL (or pass recap: {base_url:, model:}), " \
|
|
90
|
+
"or leave them all out to recap with the session's own model.")
|
|
91
|
+
end
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
private_class_method :fixed_target
|
|
95
|
+
|
|
110
96
|
# recap.sentences as [min, max]; an invalid value warns and falls back to
|
|
111
97
|
# the default range (the recap stays on).
|
|
112
98
|
def self.recap_sentences(value)
|
|
@@ -225,21 +225,6 @@ module Samagotchi
|
|
|
225
225
|
"#{ContextQuote.block(context)}#{message}"
|
|
226
226
|
end
|
|
227
227
|
|
|
228
|
-
# Only a pipe or a file is read. A terminal means nobody piped anything
|
|
229
|
-
# in, and a socket a launcher or an agent's shell passes down may never
|
|
230
|
-
# close: with -m, waiting on either would hang a script. (A pipe the
|
|
231
|
-
# caller never closes still hangs, as it would for cat.)
|
|
232
|
-
def read_stdin
|
|
233
|
-
return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
|
|
234
|
-
|
|
235
|
-
if @stdin.respond_to?(:stat)
|
|
236
|
-
stat = @stdin.stat
|
|
237
|
-
return nil unless stat.pipe? || stat.file?
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
@stdin.read
|
|
241
|
-
end
|
|
242
|
-
|
|
243
228
|
# A worker session like the web start page's: saved as running with the
|
|
244
229
|
# message before its worker spawns, so lists and the web show it at
|
|
245
230
|
# once. The full id, so a script can pass it on. The model's host is
|
|
@@ -477,12 +462,5 @@ module Samagotchi
|
|
|
477
462
|
else " with #{@images.size} images"
|
|
478
463
|
end
|
|
479
464
|
end
|
|
480
|
-
|
|
481
|
-
# The text as UTF-8 whatever the locale says: with no LANG/LC_* (an app
|
|
482
|
-
# started from Finder, launchd) stdin reads as US-ASCII and ARGV as
|
|
483
|
-
# binary. Invalid bytes become U+FFFD rather than an error.
|
|
484
|
-
def utf8(text)
|
|
485
|
-
text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
486
|
-
end
|
|
487
465
|
end
|
|
488
466
|
end
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -61,8 +61,9 @@ export function approvalView(pq) {
|
|
|
61
61
|
const who = a.rule ? [`rule ${a.rule}`, a.source].filter(Boolean).join(", ") : (a.source || "hook");
|
|
62
62
|
const reason = a.reason ? String(a.reason) : "(no reason given)";
|
|
63
63
|
return {
|
|
64
|
-
// A plugin tool's label ("chrome: screenshot"), as its row shows it
|
|
65
|
-
tool
|
|
64
|
+
// A plugin tool's label ("chrome: screenshot"), as its row shows it;
|
|
65
|
+
// else the tool the call acts as (an MCP tool behind mcp_call).
|
|
66
|
+
tool: a.label || a.acts_as || a.tool || "",
|
|
66
67
|
// A tool without a command or path (an MCP tool): its compact args.
|
|
67
68
|
what: command || paths.join("\n") || a.args || "",
|
|
68
69
|
isCommand: !!command,
|
|
@@ -142,7 +143,7 @@ export function summaryText(pq, { answer = null, cancelled = false, reason = ""
|
|
|
142
143
|
const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
|
|
143
144
|
return truncate(relay ? `${relay.who}: ${text}` : text, 120);
|
|
144
145
|
}
|
|
145
|
-
const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?`
|
|
146
|
+
const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.acts_as || pq.approval?.tool || "call"}?`
|
|
146
147
|
: isContinue(pq) ? "Step limit" : "Question";
|
|
147
148
|
return truncate(pq.header || label);
|
|
148
149
|
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.37.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -167,6 +167,9 @@ files:
|
|
|
167
167
|
- lib/samagotchi/broadcast/recipients.rb
|
|
168
168
|
- lib/samagotchi/broadcast/scope_card.rb
|
|
169
169
|
- lib/samagotchi/broadcast/tags.rb
|
|
170
|
+
- lib/samagotchi/broadcast/triage.rb
|
|
171
|
+
- lib/samagotchi/broadcast/triage_log.rb
|
|
172
|
+
- lib/samagotchi/broadcast/triage_model.rb
|
|
170
173
|
- lib/samagotchi/broadcast_command.rb
|
|
171
174
|
- lib/samagotchi/bundle_command.rb
|
|
172
175
|
- lib/samagotchi/bundle_needs.rb
|
|
@@ -284,6 +287,7 @@ files:
|
|
|
284
287
|
- lib/samagotchi/idle_recap.rb
|
|
285
288
|
- lib/samagotchi/idle_reminders.rb
|
|
286
289
|
- lib/samagotchi/idle_scheduler.rb
|
|
290
|
+
- lib/samagotchi/idle_target.rb
|
|
287
291
|
- lib/samagotchi/image_store.rb
|
|
288
292
|
- lib/samagotchi/installed_gem.rb
|
|
289
293
|
- lib/samagotchi/installed_versions.rb
|