samagotchi 0.35.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 +57 -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/context.md +37 -0
- data/docs/guardrails.md +26 -3
- data/docs/hooks.md +1 -1
- data/docs/memory.md +3 -0
- data/docs/plugins.md +51 -4
- 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/coordinator/manifest.yml +7 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +72 -27
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
- 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/parent_approvals.rb +6 -0
- data/lib/samagotchi/guardrails/rules.rb +19 -6
- data/lib/samagotchi/guardrails/targets.rb +83 -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/memory_bundle/index_updater.rb +6 -3
- 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/session_commands.rb +1 -0
- data/lib/samagotchi/tool_activity.rb +14 -3
- data/lib/samagotchi/tool_declarations.rb +8 -4
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +103 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/question_card.js +4 -3
- metadata +5 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../tools/tool_path"
|
|
4
4
|
require_relative "../tools/memory"
|
|
5
|
+
require_relative "../tool_declarations"
|
|
5
6
|
require_relative "../log"
|
|
6
7
|
require_relative "model_size"
|
|
7
8
|
require_relative "outside"
|
|
@@ -21,6 +22,9 @@ module Samagotchi
|
|
|
21
22
|
SHELL_TOOLS = %w[execute task_create].freeze
|
|
22
23
|
PATH_TOOLS = %w[write edit read memory_write].freeze
|
|
23
24
|
|
|
25
|
+
# chi's own tools, by name, whether this session has them or not.
|
|
26
|
+
CORE_TOOLS = ToolDeclarations::TOOL_SCHEMAS.map { |schema| schema[:name] }.freeze
|
|
27
|
+
|
|
24
28
|
attr_reader :tool, :command, :paths, :cwd, :repo_root
|
|
25
29
|
|
|
26
30
|
# @return [String, nil] the call's repository (Context#repo: its
|
|
@@ -37,11 +41,30 @@ module Samagotchi
|
|
|
37
41
|
# prefix) and its key (ModelOverlay.key_for), for a rule's models:
|
|
38
42
|
attr_reader :model_name, :model_key
|
|
39
43
|
|
|
40
|
-
# @return [Hash, nil] a plugin tool's arguments
|
|
41
|
-
#
|
|
42
|
-
#
|
|
44
|
+
# @return [Hash, nil] a plugin tool's arguments, for an approval of a
|
|
45
|
+
# call whose targets name no command or path: its targets:' args:
|
|
46
|
+
# when given (the arguments it acts with, a dispatcher's inner
|
|
47
|
+
# ones), else call[:args]; nil for chi's own tools
|
|
43
48
|
attr_reader :args
|
|
44
49
|
|
|
50
|
+
# @return [String, nil] the tool a plugin tool's call acts as (its
|
|
51
|
+
# targets:' acts_as:, an MCP tool behind mcp_call): a rule's tool:
|
|
52
|
+
# matches it as well as the call's name. Never a core tool's or
|
|
53
|
+
# another bundle's (.plugin_targets drops those). #tool stays the
|
|
54
|
+
# call's name.
|
|
55
|
+
attr_reader :acts_as
|
|
56
|
+
|
|
57
|
+
# @return [String, nil] what a plugin tool's targets: says to name the
|
|
58
|
+
# call by in the approval question (label:, "github: x"), in place of
|
|
59
|
+
# the tool's registered label; display only (no rule or approval key
|
|
60
|
+
# reads it)
|
|
61
|
+
attr_reader :label
|
|
62
|
+
|
|
63
|
+
# @return [Boolean] a memory_write call that removes a memory (remove:
|
|
64
|
+
# true), for a rule's memory: remove
|
|
65
|
+
attr_reader :memory_remove
|
|
66
|
+
alias memory_remove? memory_remove
|
|
67
|
+
|
|
45
68
|
# @param call [Hash] the parsed tool call
|
|
46
69
|
# @param context [Context]
|
|
47
70
|
# @param model_key [String, nil] for a memory_write model overlay, and
|
|
@@ -56,6 +79,8 @@ module Samagotchi
|
|
|
56
79
|
paths = []
|
|
57
80
|
cwd = base
|
|
58
81
|
args = nil
|
|
82
|
+
acts_as = nil
|
|
83
|
+
label = nil
|
|
59
84
|
case tool
|
|
60
85
|
when *SHELL_TOOLS
|
|
61
86
|
command = call[:content].to_s
|
|
@@ -74,20 +99,27 @@ module Samagotchi
|
|
|
74
99
|
cwd = File.expand_path(given_cwd, base) unless given_cwd.empty?
|
|
75
100
|
paths.concat(given[:paths].map { |path| absolute(path, cwd) })
|
|
76
101
|
entry = registry && registry[tool]
|
|
77
|
-
args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
|
|
102
|
+
args = given[:args] || call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
|
|
103
|
+
acts_as = given[:acts_as]
|
|
104
|
+
label = given[:label]
|
|
78
105
|
end
|
|
106
|
+
memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
|
|
79
107
|
new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
|
|
80
|
-
repo: context.repo(cwd), args: args,
|
|
108
|
+
repo: context.repo(cwd), args: args, acts_as: acts_as, label: label, memory_remove: memory_remove,
|
|
81
109
|
model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
|
|
82
110
|
chi_dirs: -> { chi_dirs(context, base) })
|
|
83
111
|
end
|
|
84
112
|
|
|
85
113
|
# What a plugin tool's targets: callable says the call acts on:
|
|
86
|
-
# {command:, paths:, cwd:}, each optional; a callable
|
|
87
|
-
# gives something else counts as nothing (and is
|
|
114
|
+
# {command:, paths:, cwd:, acts_as:, args:, label:}, each optional; a callable
|
|
115
|
+
# that raises or gives something else counts as nothing (and is
|
|
116
|
+
# logged). args: counts when it is a Hash; acts_as: is dropped (and
|
|
117
|
+
# logged) when it names a core tool or another bundle's tool, so a
|
|
118
|
+
# call can't pass for one and match (or reuse the approvals of) its
|
|
119
|
+
# rules. label: counts when it is one short line (.label_of).
|
|
88
120
|
def self.plugin_targets(call, registry)
|
|
89
121
|
entry = registry && registry[call[:name].to_s]
|
|
90
|
-
none = { command: nil, paths: [], cwd: nil }
|
|
122
|
+
none = { command: nil, paths: [], cwd: nil, acts_as: nil, args: nil, label: nil }
|
|
91
123
|
return none unless entry && !entry.core? && entry.targets
|
|
92
124
|
|
|
93
125
|
given = entry.targets.call(call)
|
|
@@ -95,13 +127,50 @@ module Samagotchi
|
|
|
95
127
|
|
|
96
128
|
given = given.transform_keys(&:to_sym)
|
|
97
129
|
command = given[:command].to_s
|
|
98
|
-
{ command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), cwd: given[:cwd]
|
|
130
|
+
{ command: command.empty? ? nil : command, paths: Array(given[:paths]).map(&:to_s), cwd: given[:cwd],
|
|
131
|
+
acts_as: acts_as_of(given[:acts_as], entry, registry), args: given[:args].is_a?(Hash) ? given[:args] : nil,
|
|
132
|
+
label: label_of(given[:label], entry) }
|
|
99
133
|
rescue StandardError => e
|
|
100
134
|
Log.warn(:plugins, "plugin_targets_failed", tool: call[:name].to_s, error: e.class.name,
|
|
101
135
|
msg: "#{call[:name]} targets failed: #{e.message}")
|
|
102
136
|
none
|
|
103
137
|
end
|
|
104
138
|
|
|
139
|
+
# +name+ as the acts_as of +entry+'s calls: nil for none, and for a
|
|
140
|
+
# core tool or a tool another bundle registered (logged); the tool's
|
|
141
|
+
# own name is no alias either.
|
|
142
|
+
def self.acts_as_of(name, entry, registry)
|
|
143
|
+
name = name.to_s.strip
|
|
144
|
+
return nil if name.empty? || name == entry.name
|
|
145
|
+
|
|
146
|
+
other = registry[name]
|
|
147
|
+
whose = if CORE_TOOLS.include?(name) || other&.core? then "a core tool"
|
|
148
|
+
elsif other && other.source != entry.source then "bundle #{other.source}'s tool"
|
|
149
|
+
end
|
|
150
|
+
return name unless whose
|
|
151
|
+
|
|
152
|
+
Log.warn(:plugins, "plugin_acts_as_dropped", tool: entry.name, acts_as: name,
|
|
153
|
+
msg: "#{entry.name} targets: acts_as #{name} dropped: it is #{whose}")
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The longest label: the question takes from targets:.
|
|
158
|
+
LABEL_CHARS = 80
|
|
159
|
+
|
|
160
|
+
# +label+ as a call's question label: stripped; nil for none, and for
|
|
161
|
+
# anything but a String of one line up to LABEL_CHARS (logged).
|
|
162
|
+
def self.label_of(label, entry)
|
|
163
|
+
return nil if label.nil?
|
|
164
|
+
|
|
165
|
+
text = label.is_a?(String) ? label.strip : nil
|
|
166
|
+
return nil if text && text.empty?
|
|
167
|
+
return text if text && !text.include?("\n") && text.length <= LABEL_CHARS
|
|
168
|
+
|
|
169
|
+
Log.warn(:plugins, "plugin_label_ignored", tool: entry.name,
|
|
170
|
+
msg: "#{entry.name} targets: label ignored: not one line of at most #{LABEL_CHARS} characters")
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
105
174
|
# chi's own dirs (ParentApprovals.chi_dirs) and the session repo's
|
|
106
175
|
# git hooks dir.
|
|
107
176
|
def self.chi_dirs(context, base)
|
|
@@ -127,8 +196,11 @@ module Samagotchi
|
|
|
127
196
|
end
|
|
128
197
|
|
|
129
198
|
# @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
|
|
130
|
-
def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil,
|
|
131
|
-
session_root: nil, chi_dirs: nil)
|
|
199
|
+
def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, acts_as: nil, label: nil,
|
|
200
|
+
model_name: nil, model_key: nil, session_root: nil, chi_dirs: nil, memory_remove: false)
|
|
201
|
+
@acts_as = acts_as
|
|
202
|
+
@label = label
|
|
203
|
+
@memory_remove = memory_remove
|
|
132
204
|
@repo = repo
|
|
133
205
|
@chi_dirs = chi_dirs
|
|
134
206
|
@session_root = session_root || repo_root || cwd
|
|
@@ -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)
|
|
@@ -25,7 +25,7 @@ module Samagotchi
|
|
|
25
25
|
pattern = managed_pattern(entry_name)
|
|
26
26
|
if content.match?(pattern)
|
|
27
27
|
existing = content[pattern].to_s.chomp
|
|
28
|
-
resolved_desc = description
|
|
28
|
+
resolved_desc = blank_description?(description) ? extract_description(existing) : description
|
|
29
29
|
resolved_source = source.nil? ? extract_source(existing) : (source || nil)
|
|
30
30
|
new_line = managed_line(entry_name, scope, byte_count, resolved_desc, resolved_source)
|
|
31
31
|
next content.sub(pattern) { new_line + $1 }
|
|
@@ -72,11 +72,14 @@ module Samagotchi
|
|
|
72
72
|
def self.managed_line(name, scope, byte_count, description, source = nil)
|
|
73
73
|
line = "- **#{name}** · #{scope} · #{date_str} · #{byte_count}"
|
|
74
74
|
line += " · from #{source}" unless source.to_s.strip.empty?
|
|
75
|
-
desc = description
|
|
76
|
-
line += " — #{desc}" unless desc.
|
|
75
|
+
desc = description.to_s.gsub(/\s+/, " ").strip
|
|
76
|
+
line += " — #{desc}" unless desc.empty?
|
|
77
77
|
line
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# A blank description (nil, "", whitespace) keeps the line's own.
|
|
81
|
+
def self.blank_description?(description) = description.to_s.strip.empty?
|
|
82
|
+
|
|
80
83
|
def self.extract_description(line)
|
|
81
84
|
if line =~ /\s—\s(.*)\s*\z/
|
|
82
85
|
$1
|
|
@@ -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
|