samagotchi 0.36.0 → 0.38.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 +63 -1
- data/README.md +3 -3
- data/bin/chi +1 -1
- data/docs/broadcast.md +95 -13
- data/docs/configuration.md +9 -2
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +17 -2
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/plugins.md +107 -34
- data/lib/samagotchi/broadcast/scope_card.rb +15 -2
- data/lib/samagotchi/broadcast/triage.rb +284 -0
- data/lib/samagotchi/broadcast/triage_log.rb +170 -0
- data/lib/samagotchi/broadcast/triage_model.rb +56 -0
- data/lib/samagotchi/broadcast_command.rb +275 -69
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/plugin.rb +439 -92
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +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/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/Broadcast.swift +97 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +12 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +123 -26
- data/lib/samagotchi/desktop/macos.rb +27 -5
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +7 -8
- 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/kernel_loop.rb +6 -4
- 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/plugin/api.rb +5 -3
- data/lib/samagotchi/recap_setup.rb +38 -52
- data/lib/samagotchi/send_command.rb +0 -22
- data/lib/samagotchi/tool_response.rb +5 -5
- data/lib/samagotchi/tool_runner.rb +18 -9
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/question_card.js +4 -3
- metadata +6 -1
|
@@ -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
|
|
@@ -176,8 +176,9 @@ module Samagotchi
|
|
|
176
176
|
# @param on_stream_event [Proc, nil] optional callback for generation events
|
|
177
177
|
# @param cancel_controller [CancellationController, nil] optional cancellation source
|
|
178
178
|
# @param model_name [String, nil] optional per-run model override
|
|
179
|
-
# @param max_tool_output_chars [Integer, nil] per-output char cap
|
|
180
|
-
# :tool_call_completed event's `output:`
|
|
179
|
+
# @param max_tool_output_chars [Integer, nil] per-output char cap on what
|
|
180
|
+
# the model gets back and the :tool_call_completed event's `output:`
|
|
181
|
+
# (nil → max_tool_output_chars)
|
|
181
182
|
# @param pending_input [#call, nil] optional drain proc returning
|
|
182
183
|
# Array<String> of user steering messages queued while the turn runs.
|
|
183
184
|
# Drained at iteration boundaries (llama.cpp's /completion cannot accept
|
|
@@ -223,7 +224,8 @@ module Samagotchi
|
|
|
223
224
|
@model_key_fallback = fallback
|
|
224
225
|
end
|
|
225
226
|
|
|
226
|
-
# Resolve the per-output character cap for the
|
|
227
|
+
# Resolve the per-output character cap for the tool outputs the model
|
|
228
|
+
# gets back and the emitted tool call events.
|
|
227
229
|
#
|
|
228
230
|
# Precedence: an explicit override wins, then max_tool_output_chars
|
|
229
231
|
# (Config). A non-positive value falls back to
|
|
@@ -539,7 +541,7 @@ module Samagotchi
|
|
|
539
541
|
runs = ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
|
|
540
542
|
on_stream_event: turn.on_stream_event, cap: turn.cap)
|
|
541
543
|
turn.tool_activity.concat(ToolResponse.activities(runs))
|
|
542
|
-
# One entry for the batch: the
|
|
544
|
+
# One entry for the batch: the capped outputs joined (ToolResponse.joined).
|
|
543
545
|
turn.conversation << ToolResponse.joined(runs)
|
|
544
546
|
turn.pending_tool_calls = true
|
|
545
547
|
:next
|
|
@@ -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
|
|
@@ -178,9 +178,11 @@ module Samagotchi
|
|
|
178
178
|
# down) and returns a short summary ("3 tools") or raises (the UIs
|
|
179
179
|
# show a warn card). Every UI shows it running (the label) and done.
|
|
180
180
|
# @param label [String] what it does, shown while it runs
|
|
181
|
-
# @param provides_tools [Boolean] it
|
|
182
|
-
#
|
|
183
|
-
#
|
|
181
|
+
# @param provides_tools [Boolean] it provides what the plugin's tools
|
|
182
|
+
# need (its own, or through chi.replace_tools; the mcp bundle's
|
|
183
|
+
# index of a server's tools): a turn sent meanwhile waits for it
|
|
184
|
+
# before its first model request, up to +timeout+; a Ctrl-C ends
|
|
185
|
+
# the wait
|
|
184
186
|
# @param quiet [Boolean] shown only if it fails (a background refresh)
|
|
185
187
|
# @param timeout [Numeric, nil] seconds a turn waits for it (default 60)
|
|
186
188
|
# @param failed [String, nil] the warn card's short title if it raises
|
|
@@ -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
|
|
@@ -4,9 +4,9 @@ module Samagotchi
|
|
|
4
4
|
# A tool batch and the conversation entries its results become, for both
|
|
5
5
|
# loops. The calls run through ToolRunner between the dispatch events;
|
|
6
6
|
# what the model gets back is per format: the native loop answers a batch
|
|
7
|
-
# with ONE tool_response (#joined
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# with ONE tool_response (#joined), the chat loop each call with its own,
|
|
8
|
+
# paired by tool_call_id (#single). Both feed the capped outputs
|
|
9
|
+
# (max_tool_output_chars).
|
|
10
10
|
module ToolResponse
|
|
11
11
|
SEPARATOR = "\n\n---\n\n"
|
|
12
12
|
# Saved with a result for the web's reload (a plugin tool's params line
|
|
@@ -37,12 +37,12 @@ module Samagotchi
|
|
|
37
37
|
runs.filter_map { |run| run[:activity] }
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
# The native loop's entry for a batch: the outputs joined, every image
|
|
40
|
+
# The native loop's entry for a batch: the capped outputs joined, every image
|
|
41
41
|
# in call order with how many each call returned (the web's reload puts
|
|
42
42
|
# each on its own tool row), and the saved-only fields one per call.
|
|
43
43
|
def joined(runs)
|
|
44
44
|
images = runs.flat_map { |run| Array(run[:images]) }
|
|
45
|
-
entry = { role: "tool_response", content: runs.map { |run| run[:
|
|
45
|
+
entry = { role: "tool_response", content: runs.map { |run| run[:capped_output] }.join(SEPARATOR) }
|
|
46
46
|
unless images.empty?
|
|
47
47
|
entry[:images] = images
|
|
48
48
|
entry[:image_counts] = runs.map { |run| Array(run[:images]).size }
|
|
@@ -13,9 +13,9 @@ module Samagotchi
|
|
|
13
13
|
# The single per-call path both loops use: the tool_call_started and
|
|
14
14
|
# tool_call_completed events, the guardrail gate (before_tool_call hooks
|
|
15
15
|
# and their veto), dispatch through KernelLoop, the after_tool_call hook
|
|
16
|
-
# and the output cap.
|
|
17
|
-
#
|
|
18
|
-
#
|
|
16
|
+
# and the output cap. Both loops feed the model `capped_output:` (cut to
|
|
17
|
+
# the cap with a "[cut: N of M chars …]" line), the text the event and
|
|
18
|
+
# the hook get too.
|
|
19
19
|
class ToolRunner
|
|
20
20
|
# More images in one result are left out, each with a line (a tool
|
|
21
21
|
# that returns many screenshots can't flood the context).
|
|
@@ -83,12 +83,8 @@ module Samagotchi
|
|
|
83
83
|
if (changed = changed_call_line(original, call))
|
|
84
84
|
output = "#{changed}\n#{output}"
|
|
85
85
|
end
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if max_tool_output_chars && output.length > max_tool_output_chars
|
|
89
|
-
truncated = true
|
|
90
|
-
capped = output[0, max_tool_output_chars]
|
|
91
|
-
end
|
|
86
|
+
truncated = !max_tool_output_chars.nil? && output.length > max_tool_output_chars
|
|
87
|
+
capped = truncated ? cut(output, max_tool_output_chars) : output
|
|
92
88
|
|
|
93
89
|
# The call's outcome as its activity line has it (worked out from the
|
|
94
90
|
# full output: ok, error, blocked, stopped); a dispatcher that raised
|
|
@@ -118,6 +114,19 @@ module Samagotchi
|
|
|
118
114
|
|
|
119
115
|
private
|
|
120
116
|
|
|
117
|
+
# +output+ cut to +cap+ chars, the cut line included: the model knows
|
|
118
|
+
# what it didn't get. A cap smaller than the line keeps +cap+ chars and
|
|
119
|
+
# the line goes past it.
|
|
120
|
+
def cut(output, cap)
|
|
121
|
+
keep = cap - cut_line(cap, output.length).length
|
|
122
|
+
keep = cap unless keep.positive?
|
|
123
|
+
"#{output[0, keep]}#{cut_line(keep, output.length)}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cut_line(kept, total)
|
|
127
|
+
"\n[cut: #{kept} of #{total} chars; read it in parts]"
|
|
128
|
+
end
|
|
129
|
+
|
|
121
130
|
# edit/write only: the file just before the call runs. The row diffs it
|
|
122
131
|
# with the file after, whatever the result says, so an edit that fails
|
|
123
132
|
# after writing still shows its change and one that wrote nothing shows
|
|
@@ -80,7 +80,7 @@ module Samagotchi
|
|
|
80
80
|
@stderr = stderr
|
|
81
81
|
@gem_spec = gem_spec
|
|
82
82
|
@shipped_dir = shipped_dir
|
|
83
|
-
@platform = platform ||
|
|
83
|
+
@platform = platform || Desktop::MacOS.method(:from_config)
|
|
84
84
|
@supported = supported
|
|
85
85
|
@state_dir = state_dir
|
|
86
86
|
@web = web || [Web::Lan.local_host(Config.get("web.host")), Config.get("web.port").to_i]
|
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.38.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
|
|
@@ -234,6 +237,7 @@ files:
|
|
|
234
237
|
- lib/samagotchi/desktop.rb
|
|
235
238
|
- lib/samagotchi/desktop/macos.rb
|
|
236
239
|
- lib/samagotchi/desktop/macos/App.swift
|
|
240
|
+
- lib/samagotchi/desktop/macos/Broadcast.swift
|
|
237
241
|
- lib/samagotchi/desktop/macos/ChiRunner.swift
|
|
238
242
|
- lib/samagotchi/desktop/macos/Hotkey.swift
|
|
239
243
|
- lib/samagotchi/desktop/macos/Images.swift
|
|
@@ -284,6 +288,7 @@ files:
|
|
|
284
288
|
- lib/samagotchi/idle_recap.rb
|
|
285
289
|
- lib/samagotchi/idle_reminders.rb
|
|
286
290
|
- lib/samagotchi/idle_scheduler.rb
|
|
291
|
+
- lib/samagotchi/idle_target.rb
|
|
287
292
|
- lib/samagotchi/image_store.rb
|
|
288
293
|
- lib/samagotchi/installed_gem.rb
|
|
289
294
|
- lib/samagotchi/installed_versions.rb
|