samagotchi 0.3.0 → 0.4.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 +83 -1
- data/README.md +16 -0
- data/bin/chi +32 -31
- data/docs/cli.md +77 -5
- data/docs/configuration.md +104 -2
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +88 -6
- data/docs/releasing.md +6 -6
- data/docs/sessions.md +19 -17
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/client.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +64 -20
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +201 -104
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +26 -79
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +21 -7
- data/lib/samagotchi/llm/http.rb +15 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +23 -0
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/self_report.rb +17 -3
- data/lib/samagotchi/send_command.rb +107 -12
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +1 -16
- data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +62 -248
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +3 -3
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +36 -24
- data/lib/samagotchi/web/public/chat_view.js +3 -0
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +22 -1
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/turn_events.js +29 -5
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/worker.rb +5 -4
- metadata +12 -1
|
@@ -20,12 +20,14 @@ module Samagotchi
|
|
|
20
20
|
# @param label [String, nil] a plugin tool's label ("chrome:
|
|
21
21
|
# screenshot"): the question names the tool by it, as its row does;
|
|
22
22
|
# approval[:tool] stays the raw name
|
|
23
|
+
# @param preview [Hash, nil] EditPreview.for(verdict.call) for edit and
|
|
24
|
+
# write: the card shows its diff, the question text one change line
|
|
23
25
|
# @return [Hash] open_question fields
|
|
24
|
-
def payload(verdict, label: nil)
|
|
26
|
+
def payload(verdict, label: nil, preview: nil)
|
|
25
27
|
scopes = offered_scopes(verdict)
|
|
26
28
|
targets = verdict.targets
|
|
27
29
|
{
|
|
28
|
-
question: question_text(verdict, label: label),
|
|
30
|
+
question: question_text(verdict, label: label, preview: preview),
|
|
29
31
|
options: scopes.map { |scope| label(scope, verdict) } + [DENY],
|
|
30
32
|
header: HEADER,
|
|
31
33
|
multi_select: false,
|
|
@@ -44,7 +46,8 @@ module Samagotchi
|
|
|
44
46
|
rule: verdict.rule,
|
|
45
47
|
source: verdict.source,
|
|
46
48
|
reason: verdict.reason,
|
|
47
|
-
scopes: scopes
|
|
49
|
+
scopes: scopes,
|
|
50
|
+
preview: preview
|
|
48
51
|
}.compact
|
|
49
52
|
}
|
|
50
53
|
end
|
|
@@ -112,7 +115,8 @@ module Samagotchi
|
|
|
112
115
|
# execute: git push origin main
|
|
113
116
|
# in /path/to/repo (repo samagotchi, branch main)
|
|
114
117
|
# why: git push publishes commits (rule git-push, bundle guardrails)
|
|
115
|
-
|
|
118
|
+
# change: +3 −1 (edit/write, from the preview)
|
|
119
|
+
def question_text(verdict, label: nil, preview: nil)
|
|
116
120
|
targets = verdict.targets
|
|
117
121
|
tool = label || targets&.tool || verdict.call[:name].to_s
|
|
118
122
|
what = targets&.command || (targets && targets.paths.join(", "))
|
|
@@ -127,8 +131,26 @@ module Samagotchi
|
|
|
127
131
|
who = verdict.rule ? ["rule #{verdict.rule}", verdict.source].compact.join(", ") : (verdict.source || "hook")
|
|
128
132
|
reason = verdict.reason.to_s.strip
|
|
129
133
|
lines << " why: #{reason.empty? ? "(no reason given)" : reason} (#{who})"
|
|
134
|
+
change = change_text(preview)
|
|
135
|
+
lines << " change: #{change}" if change
|
|
130
136
|
lines.join("\n")
|
|
131
137
|
end
|
|
138
|
+
|
|
139
|
+
# One line for an EditPreview: "+3 −1", "new file, 12 lines",
|
|
140
|
+
# "would fail: …" or "not shown (binary file)". The diff itself stays
|
|
141
|
+
# out of the question text (size); the card and the TUI show it.
|
|
142
|
+
def change_text(preview)
|
|
143
|
+
return nil unless preview.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
get = ->(key) { preview[key] || preview[key.to_s] }
|
|
146
|
+
return "would fail: #{get.(:error)}" if get.(:error)
|
|
147
|
+
return "not shown (#{get.(:skipped)})" if get.(:skipped)
|
|
148
|
+
|
|
149
|
+
added = get.(:added).to_i
|
|
150
|
+
return "new file, #{added} #{added == 1 ? "line" : "lines"}" if get.(:new_file)
|
|
151
|
+
|
|
152
|
+
"+#{added} −#{get.(:removed).to_i}"
|
|
153
|
+
end
|
|
132
154
|
end
|
|
133
155
|
end
|
|
134
156
|
end
|
|
@@ -7,7 +7,7 @@ module Samagotchi
|
|
|
7
7
|
# guardrail that silently vanished is the failure this guards against);
|
|
8
8
|
# the rest are only announced.
|
|
9
9
|
class LoadFailures
|
|
10
|
-
Failure = Struct.new(:what, :reason, :required, keyword_init: true)
|
|
10
|
+
Failure = Struct.new(:what, :reason, :required, :group, keyword_init: true)
|
|
11
11
|
|
|
12
12
|
def initialize
|
|
13
13
|
@list = []
|
|
@@ -15,8 +15,14 @@ module Samagotchi
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# @param what [String] e.g. "hook guard.rb (config)"
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
# @param group [Symbol, nil] what loads it again (#drop): :rules
|
|
19
|
+
def add(what, reason, required:, group: nil)
|
|
20
|
+
@mutex.synchronize { @list << Failure.new(what: what, reason: reason, required: required, group: group) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Forget a group's failures before it loads again.
|
|
24
|
+
def drop(group)
|
|
25
|
+
@mutex.synchronize { @list.reject! { |failure| failure.group == group } }
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
def list = @mutex.synchronize { @list.dup }
|
|
@@ -29,9 +29,10 @@ module Samagotchi
|
|
|
29
29
|
# profile: the configured prompt profile name, if any;
|
|
30
30
|
# first_token_timeout: the configured first-token limit (see #first_token_limit);
|
|
31
31
|
# vision: the configured true/false (VisionSupport), nil when unset;
|
|
32
|
-
# sampling: the configured request parameters (SamplingSettings), nil when unset
|
|
32
|
+
# sampling: the configured request parameters (SamplingSettings), nil when unset;
|
|
33
|
+
# thinking: the configured level (Thinking), nil when unset.
|
|
33
34
|
HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
|
|
34
|
-
:first_token_timeout, :vision, :sampling, keyword_init: true) do
|
|
35
|
+
:first_token_timeout, :vision, :sampling, :thinking, keyword_init: true) do
|
|
35
36
|
# Talks the OpenAI chat API (the chat loop); nil and raw apis use the
|
|
36
37
|
# raw-prompt loop.
|
|
37
38
|
def chat? = api == :openai
|
|
@@ -87,9 +88,10 @@ module Samagotchi
|
|
|
87
88
|
entry = HostEntry.new(name: key.to_s.downcase, host: cfg[:host], port: cfg[:port].to_i, transport: transport,
|
|
88
89
|
api: cfg[:api]&.to_sym, scheme: cfg[:scheme], url: cfg[:url], api_key_env: cfg[:api_key_env],
|
|
89
90
|
profile: cfg[:profile], first_token_timeout: cfg[:first_token_timeout],
|
|
90
|
-
vision: cfg[:vision], sampling: cfg[:sampling])
|
|
91
|
+
vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking])
|
|
91
92
|
entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
|
|
92
|
-
first_token_timeout: entry.first_token_limit, name: entry.name
|
|
93
|
+
first_token_timeout: entry.first_token_limit, name: entry.name,
|
|
94
|
+
api_key_env: entry.api_key_env, env: env)
|
|
93
95
|
@entries[entry.name] = entry
|
|
94
96
|
end
|
|
95
97
|
# Fallback single entry (should already be synthesized by hosts_config, but guard)
|
|
@@ -117,10 +119,6 @@ module Samagotchi
|
|
|
117
119
|
nil
|
|
118
120
|
end
|
|
119
121
|
|
|
120
|
-
def entry_names
|
|
121
|
-
@entries.keys
|
|
122
|
-
end
|
|
123
|
-
|
|
124
122
|
def default_entry
|
|
125
123
|
@entries["default"] || @entries.values.first
|
|
126
124
|
end
|
|
@@ -149,10 +147,8 @@ module Samagotchi
|
|
|
149
147
|
rescue StandardError
|
|
150
148
|
nil
|
|
151
149
|
end
|
|
152
|
-
|
|
153
|
-
return [
|
|
154
|
-
# Unknown prefix — treat as bare model on default host
|
|
155
|
-
return [default_entry, raw_model.to_s.strip]
|
|
150
|
+
# parse_qualified_model names a host only when it is one of ours
|
|
151
|
+
return [find_entry(host_ref), bare]
|
|
156
152
|
end
|
|
157
153
|
# Unqualified: also try alias resolution for discovery (small -> gemma-small or small -> small-box:gemma-small)
|
|
158
154
|
begin
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "llm/openai_chat"
|
|
5
|
+
require_relative "thinking"
|
|
6
|
+
require_relative "log"
|
|
5
7
|
|
|
6
8
|
module Samagotchi
|
|
7
9
|
# Standalone summarizer for the idle session-recap feature.
|
|
@@ -33,17 +35,6 @@ module Samagotchi
|
|
|
33
35
|
# Up to 10 sentences (recap.sentences) need ~350 tokens with thinking off.
|
|
34
36
|
MAX_TOKENS = 512
|
|
35
37
|
|
|
36
|
-
# Request fields that turn thinking off. A reasoning model otherwise
|
|
37
|
-
# spends the budget thinking and the recap stops mid-sentence.
|
|
38
|
-
# - chat_template_kwargs.enable_thinking: the chat template's switch
|
|
39
|
-
# (llama.cpp with Qwen/Gemma templates); templates without it ignore it.
|
|
40
|
-
# - reasoning_effort "none": the OpenAI-style knob, for servers that
|
|
41
|
-
# ignore the template switch (Splash thought until max_tokens).
|
|
42
|
-
THINKING_OFF = {
|
|
43
|
-
chat_template_kwargs: { enable_thinking: false },
|
|
44
|
-
reasoning_effort: "none"
|
|
45
|
-
}.freeze
|
|
46
|
-
|
|
47
38
|
# @param base_url [String] the OpenAI API base, e.g. http://host:8081/v1
|
|
48
39
|
# @param api_key_env [String, nil] the variable holding the host's key
|
|
49
40
|
# @param timeout [Numeric] HTTP request timeout. Kept to the recap's own
|
|
@@ -119,10 +110,17 @@ module Samagotchi
|
|
|
119
110
|
# +max_tokens+, the text keeps its finished sentences (+whole_sentences+)
|
|
120
111
|
# or all of it, with "…".
|
|
121
112
|
def generate(messages, max_tokens: MAX_TOKENS, cancel_controller: nil, whole_sentences: true)
|
|
122
|
-
response =
|
|
123
|
-
messages
|
|
124
|
-
|
|
125
|
-
|
|
113
|
+
response = begin
|
|
114
|
+
request(messages, max_tokens, cancel_controller)
|
|
115
|
+
rescue LLM::BadRequest => e
|
|
116
|
+
raise unless e.reasoning_refused? && !@thinking_refused
|
|
117
|
+
|
|
118
|
+
# The host won't turn thinking off (gpt-oss): ask again without the
|
|
119
|
+
# fields, and leave them out from now on.
|
|
120
|
+
@thinking_refused = true
|
|
121
|
+
Log.info(:recap, "thinking_refused", model: @model, detail: e.detail)
|
|
122
|
+
request(messages, max_tokens, cancel_controller)
|
|
123
|
+
end
|
|
126
124
|
content = response.text
|
|
127
125
|
reasoning = response.reasoning
|
|
128
126
|
raise SummarizeError, "server returned no parseable assistant content" if content.empty? && reasoning.empty?
|
|
@@ -143,5 +141,16 @@ module Samagotchi
|
|
|
143
141
|
raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
|
|
144
142
|
end
|
|
145
143
|
|
|
144
|
+
def request(messages, max_tokens, cancel_controller)
|
|
145
|
+
@chat.chat(messages: messages, model: @model, tools: [], cancel_controller: cancel_controller,
|
|
146
|
+
options: { max_tokens: max_tokens, **thinking_fields })
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A recap or side answer is short and tool-less: thinking off, whatever
|
|
150
|
+
# the model's level (a reasoning model otherwise spends the budget
|
|
151
|
+
# thinking and the recap stops mid-sentence). None once the host refused them.
|
|
152
|
+
def thinking_fields
|
|
153
|
+
@thinking_refused ? {} : Thinking.chat_fields(:off)
|
|
154
|
+
end
|
|
146
155
|
end
|
|
147
156
|
end
|
|
@@ -9,7 +9,7 @@ module Samagotchi
|
|
|
9
9
|
# (one background thread for the whole idle layer).
|
|
10
10
|
#
|
|
11
11
|
# Two delivery paths cooperate:
|
|
12
|
-
# 1. Pull-based: Engine#
|
|
12
|
+
# 1. Pull-based: Engine#collect_due_reminders
|
|
13
13
|
# reads ReminderStore#due_reminders at turn start → injects
|
|
14
14
|
# [SYSTEM: REMINDERS DUE] → marks all as fired atomically.
|
|
15
15
|
# 2. Synthetic turns: when this job's tick finds due reminders while
|
|
@@ -43,7 +43,7 @@ module Samagotchi
|
|
|
43
43
|
@mutex.synchronize { @due_reminder_name }
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
# Get all due reminders (from ReminderStore). Called by Engine#
|
|
46
|
+
# Get all due reminders (from ReminderStore). Called by Engine#collect_due_reminders.
|
|
47
47
|
# Thread-safe. Returns all due reminders, not just one.
|
|
48
48
|
# @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
|
|
49
49
|
def due_reminders
|
|
@@ -262,12 +262,7 @@ module Samagotchi
|
|
|
262
262
|
next message unless images.is_a?(Array) && !images.empty?
|
|
263
263
|
|
|
264
264
|
kept, gone = from ? images.partition { |ref| valid_ref?(from, ref) } : [[], images]
|
|
265
|
-
kept.each
|
|
266
|
-
file = (ref[:file] || ref["file"]).to_s
|
|
267
|
-
target = File.join(to.to_s, file)
|
|
268
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
269
|
-
FileUtils.cp(File.join(from.to_s, file), target) unless File.exist?(target)
|
|
270
|
-
end
|
|
265
|
+
kept.each { |ref| copy_file(ref, from: from, to: to) }
|
|
271
266
|
dropped += gone.size
|
|
272
267
|
message = message.reject { |key, _| key.to_s == "images" }
|
|
273
268
|
message[:images] = kept unless kept.empty?
|
|
@@ -280,6 +275,15 @@ module Samagotchi
|
|
|
280
275
|
[copied, dropped]
|
|
281
276
|
end
|
|
282
277
|
|
|
278
|
+
# One stored image's file from session dir +from+ into +to+ (kept when
|
|
279
|
+
# it is there already: the name is the content's hash).
|
|
280
|
+
def self.copy_file(ref, from:, to:)
|
|
281
|
+
file = (ref[:file] || ref["file"]).to_s
|
|
282
|
+
target = File.join(to.to_s, file)
|
|
283
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
284
|
+
FileUtils.cp(File.join(from.to_s, file), target) unless File.exist?(target)
|
|
285
|
+
end
|
|
286
|
+
|
|
283
287
|
# A ref for an image already stored in this session (a web upload),
|
|
284
288
|
# rebuilt from the file itself: only its name comes from the caller.
|
|
285
289
|
def self.ref_for(session_dir, file:, name: nil, source: "user")
|
|
@@ -11,6 +11,7 @@ require_relative "client"
|
|
|
11
11
|
require_relative "llm/errors"
|
|
12
12
|
require_relative "log"
|
|
13
13
|
require_relative "empty_answer_retry"
|
|
14
|
+
require_relative "thinking"
|
|
14
15
|
require_relative "hooks"
|
|
15
16
|
require_relative "pending_input_queue"
|
|
16
17
|
require_relative "steer"
|
|
@@ -179,6 +180,9 @@ module Samagotchi
|
|
|
179
180
|
# The turn's request parameters (SamplingSettings.for), set by the Engine
|
|
180
181
|
# per turn; empty or nil sends none.
|
|
181
182
|
attr_accessor :sampling
|
|
183
|
+
# The turn's thinking level (Thinking.resolve), set by the Engine per
|
|
184
|
+
# turn; its own accessor, since the native path sends @sampling as is.
|
|
185
|
+
attr_accessor :thinking
|
|
182
186
|
# Tools::Peers (or the Engine's live view of it): the session
|
|
183
187
|
# list_sessions and send_note speak for; nil outside a session.
|
|
184
188
|
attr_accessor :peers
|
|
@@ -217,13 +221,17 @@ module Samagotchi
|
|
|
217
221
|
@retry_generation = false
|
|
218
222
|
context_status = nil
|
|
219
223
|
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
224
|
+
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
225
|
+
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
226
|
+
# prompt starts with what the server already has cached.
|
|
227
|
+
prefill = Thinking.native(@thinking || Thinking::DEFAULT, @profile).prefill
|
|
220
228
|
partial_assistant_buffer = +""
|
|
221
229
|
|
|
222
230
|
effective_max_iterations = @no_interrupt ? 1000 : max_iterations
|
|
223
231
|
effective_max_tool_output_chars = resolve_output_char_cap(max_tool_output_chars)
|
|
224
232
|
effective_max_iterations.times do |iteration_index|
|
|
225
233
|
inject_pending_input!(conversation, pending_input, on_stream_event, iteration_index + 1, cancel_controller)
|
|
226
|
-
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision)
|
|
234
|
+
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
|
|
227
235
|
image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(conversation)
|
|
228
236
|
context_window = ContextWindow.resolve(client: @client, model: resolved_model_name)
|
|
229
237
|
context_status = emit_context_status_event(on_stream_event, prompt, iteration_index: iteration_index, state: context_state, window: context_window,
|
|
@@ -232,7 +240,7 @@ module Samagotchi
|
|
|
232
240
|
# The model's own copy, on the tail (the prompt cache keeps its
|
|
233
241
|
# prefix), then the prompt again with it.
|
|
234
242
|
conversation << line
|
|
235
|
-
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision)
|
|
243
|
+
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
|
|
236
244
|
end
|
|
237
245
|
emit_stream_event(
|
|
238
246
|
on_stream_event,
|
|
@@ -310,7 +318,7 @@ module Samagotchi
|
|
|
310
318
|
after_gen_event = { type: :after_generation, iteration: iteration_index + 1, response: response,
|
|
311
319
|
messages: AnswerDisplay.strip_all(conversation).map(&:dup).freeze }
|
|
312
320
|
fire_hook(:after_generation, after_gen_event) if @hooks
|
|
313
|
-
conversation << { role: "model", content: response }
|
|
321
|
+
conversation << { role: "model", content: prefill + response.to_s }
|
|
314
322
|
|
|
315
323
|
# Profile-specific parse (incl. Qwen unterminated-block recovery); the
|
|
316
324
|
# returned fragment (non-nil only for Qwen) is fed back on the next
|
|
@@ -365,6 +373,7 @@ module Samagotchi
|
|
|
365
373
|
image_counts = []
|
|
366
374
|
shown_params = []
|
|
367
375
|
shown_labels = []
|
|
376
|
+
diffs = []
|
|
368
377
|
results = calls.map.with_index do |call, call_index|
|
|
369
378
|
run = tool_runner.run(call, iteration: iteration_index + 1, call_index: call_index + 1,
|
|
370
379
|
call_count: calls.length, on_stream_event: on_stream_event,
|
|
@@ -374,6 +383,7 @@ module Samagotchi
|
|
|
374
383
|
image_counts << Array(run[:images]).size
|
|
375
384
|
shown_params << run[:shown_params]
|
|
376
385
|
shown_labels << run[:shown_label]
|
|
386
|
+
diffs << run[:diff]
|
|
377
387
|
run[:output]
|
|
378
388
|
end.join("\n\n---\n\n")
|
|
379
389
|
emit_stream_event(on_stream_event, type: :tool_dispatch_completed, iteration: iteration_index + 1, call_count: calls.length)
|
|
@@ -387,6 +397,9 @@ module Samagotchi
|
|
|
387
397
|
# a built-in), for the web's reload; the prompt never reads it.
|
|
388
398
|
tool_response[:tool_params] = shown_params if shown_params.any?
|
|
389
399
|
tool_response[:tool_labels] = shown_labels if shown_labels.any?
|
|
400
|
+
# What each edit/write changed (nil for other calls), in call order,
|
|
401
|
+
# for the web's reload; the prompt never reads it.
|
|
402
|
+
tool_response[:tool_diffs] = diffs if diffs.any?
|
|
390
403
|
conversation << tool_response
|
|
391
404
|
pending_tool_calls = true
|
|
392
405
|
rescue Client::RequestCancelled => e
|
|
@@ -828,14 +841,6 @@ module Samagotchi
|
|
|
828
841
|
end
|
|
829
842
|
end
|
|
830
843
|
|
|
831
|
-
# Parse tool calls from raw model output using the active profile's
|
|
832
|
-
# ToolCallParser strategy (Gemma 4 or Qwen 3.6).
|
|
833
|
-
# Thought content is intentionally left intact while a tool-call turn is in
|
|
834
|
-
# progress to preserve same-turn reasoning context between tool calls.
|
|
835
|
-
def parse_tool_calls(text)
|
|
836
|
-
parser.parse(text.to_s)
|
|
837
|
-
end
|
|
838
|
-
|
|
839
844
|
public
|
|
840
845
|
|
|
841
846
|
# Public wrapper so other loops (e.g. the chat loop) can strip
|
|
@@ -974,76 +979,18 @@ module Samagotchi
|
|
|
974
979
|
}
|
|
975
980
|
end
|
|
976
981
|
|
|
982
|
+
# ask_user_question: validate the call, then hand the payload to the
|
|
983
|
+
# Engine's question flow (question_handler, which blocks until the user
|
|
984
|
+
# answers). Without one (headless), the payload as JSON so the model sees
|
|
985
|
+
# the options and can ask in plain text.
|
|
977
986
|
def handle_ask_user_question(call)
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw_opts)
|
|
982
|
-
# Fallback for case where raw was String like '["a","b"]' but lenient returned [] due to edge parse, try raw string of params
|
|
983
|
-
if options.empty? && raw_opts.is_a?(String)
|
|
984
|
-
options = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw_opts.to_s)
|
|
985
|
-
end
|
|
986
|
-
header = call[:header].to_s.strip
|
|
987
|
-
header = nil if header.empty?
|
|
988
|
-
multi = call[:multi_select]
|
|
989
|
-
free = call[:allow_freeform]
|
|
990
|
-
# Normalize booleans from string forms (Gemma passes "true"/"false" as strings)
|
|
991
|
-
multi = normalize_ask_bool(multi)
|
|
992
|
-
free = normalize_ask_bool(free)
|
|
993
|
-
|
|
994
|
-
if question.empty?
|
|
995
|
-
return "Error: ask_user_question requires 'question'"
|
|
996
|
-
end
|
|
997
|
-
# Dumb-model tolerant: salvage single-option parse glitches, but still require at least 1
|
|
998
|
-
if options.size < 1
|
|
999
|
-
alt = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(call[:content].to_s) if call[:content]
|
|
1000
|
-
options = alt unless alt.empty?
|
|
1001
|
-
end
|
|
1002
|
-
if options.empty?
|
|
1003
|
-
return "Error: ask_user_question requires 2-8 options (got 0). Provide e.g. options=[\"Cats\",\"Dogs\"]"
|
|
1004
|
-
end
|
|
1005
|
-
if options.size == 1
|
|
1006
|
-
# Allow single-option salvage for dumb models (will still render, user can answer or provide freeform)
|
|
1007
|
-
elsif options.size < 2 || options.size > 8
|
|
1008
|
-
return "Error: ask_user_question requires 2-8 options (got #{options.size}). Provide e.g. options=[\"Cats\",\"Dogs\"]"
|
|
1009
|
-
end
|
|
1010
|
-
|
|
1011
|
-
# If an Engine-level blocking handler is registered (TUI/Web), delegate
|
|
1012
|
-
# there (Engine sets question_handler). Otherwise fall back to a
|
|
1013
|
-
# non-blocking JSON preview so the model can still see a structured response.
|
|
1014
|
-
handler = @question_handler
|
|
1015
|
-
|
|
1016
|
-
payload = {
|
|
1017
|
-
question: question,
|
|
1018
|
-
options: options,
|
|
1019
|
-
header: header,
|
|
1020
|
-
multi_select: !!multi,
|
|
1021
|
-
allow_freeform: !!free
|
|
1022
|
-
}.compact
|
|
1023
|
-
|
|
1024
|
-
if handler
|
|
1025
|
-
begin
|
|
1026
|
-
result = handler.call(payload)
|
|
1027
|
-
return result.to_s
|
|
1028
|
-
rescue => e
|
|
1029
|
-
return "Error: ask_user_question handler failed: #{e.message}"
|
|
1030
|
-
end
|
|
1031
|
-
end
|
|
1032
|
-
|
|
1033
|
-
# Headless fallback: return JSON so model sees structured options and can
|
|
1034
|
-
# fallback to plain text qualification.
|
|
1035
|
-
JSON.pretty_generate(payload)
|
|
1036
|
-
end
|
|
987
|
+
payload = Samagotchi::Tools::AskUserQuestion.validate(call)
|
|
988
|
+
return payload if payload.is_a?(String)
|
|
989
|
+
return JSON.pretty_generate(payload) unless @question_handler
|
|
1037
990
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
s = v.to_s.strip.downcase
|
|
1043
|
-
return true if %w[1 true yes on].include?(s)
|
|
1044
|
-
return false if %w[0 false no off].include?(s)
|
|
1045
|
-
|
|
1046
|
-
nil
|
|
991
|
+
@question_handler.call(payload).to_s
|
|
992
|
+
rescue => e
|
|
993
|
+
"Error: ask_user_question handler failed: #{e.message}"
|
|
1047
994
|
end
|
|
1048
995
|
end
|
|
1049
996
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "socket"
|
|
6
|
+
require_relative "session"
|
|
7
|
+
require_relative "version"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
# Which chi versions the running processes run, for `chi update`: session
|
|
11
|
+
# workers (their bridge.json sidecar names the version since chi update exists; an
|
|
12
|
+
# older one names none) and a `chi web` on its port (/api/info). Read-only:
|
|
13
|
+
# a dead worker's sidecar is left for the next client to clean up.
|
|
14
|
+
module LiveVersions
|
|
15
|
+
PROBE_TIMEOUT = 0.2
|
|
16
|
+
WEB_TIMEOUT = 0.5
|
|
17
|
+
|
|
18
|
+
# version is nil for a sidecar written before sidecars carried one.
|
|
19
|
+
Worker = Struct.new(:session_id, :version, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# @return [Array<Worker>] the workers whose Bridge answers, by session id
|
|
24
|
+
def workers(state_dir: Session.default_state_dir)
|
|
25
|
+
Dir[File.join(state_dir, "*", "bridge.json")].sort.filter_map do |sidecar|
|
|
26
|
+
data = JSON.parse(File.read(sidecar))
|
|
27
|
+
next unless data.is_a?(Hash) && listening?(data["port"].to_i)
|
|
28
|
+
|
|
29
|
+
Worker.new(session_id: File.basename(File.dirname(sidecar)), version: data["version"])
|
|
30
|
+
rescue JSON::ParserError, SystemCallError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The live workers on another version than +version+ (unknown counts).
|
|
36
|
+
def stale_workers(version = VERSION, state_dir: Session.default_state_dir)
|
|
37
|
+
workers(state_dir: state_dir).reject { |w| w.version == version }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The version a chi web on host:port runs, or nil when nothing (or not
|
|
41
|
+
# chi web) answers.
|
|
42
|
+
def web_version(host, port, timeout: WEB_TIMEOUT)
|
|
43
|
+
response = Net::HTTP.start(host, port, open_timeout: timeout, read_timeout: timeout) { |http| http.get("/api/info") }
|
|
44
|
+
info = response.code.to_i == 200 ? JSON.parse(response.body.to_s) : nil
|
|
45
|
+
info.is_a?(Hash) && info["app"] == "chi-web" ? info["version"].to_s : nil
|
|
46
|
+
rescue StandardError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def listening?(port, host: "127.0.0.1")
|
|
51
|
+
return false unless port.positive?
|
|
52
|
+
|
|
53
|
+
Socket.tcp(host, port, connect_timeout: PROBE_TIMEOUT).close
|
|
54
|
+
true
|
|
55
|
+
rescue StandardError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module LLM
|
|
7
|
+
# A host's API key: the environment variable its api_key_env: names,
|
|
8
|
+
# sent as `Authorization: Bearer <key>` on every request LLM::HTTP makes
|
|
9
|
+
# for the host (llama.cpp started with --api-key, or a provider). The
|
|
10
|
+
# key itself never goes into a message.
|
|
11
|
+
ApiKey = Data.define(:env_name, :host, :env) do
|
|
12
|
+
# nil for a host without api_key_env: its requests carry no header.
|
|
13
|
+
def self.for(env_name, host:, env: ENV)
|
|
14
|
+
name = env_name.to_s.strip
|
|
15
|
+
name.empty? ? nil : new(env_name: name, host: host.to_s, env: env)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# What to try after a 401/403 from a host that has no api_key_env.
|
|
19
|
+
def self.missing_hint(host)
|
|
20
|
+
"the server may want an API key: put it in an environment variable and name it with api_key_env: on host #{host}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Sets the header, or raises AuthError when the variable is not set.
|
|
24
|
+
def authorize(request)
|
|
25
|
+
key = env[env_name].to_s
|
|
26
|
+
raise AuthError.new("#{host}: set #{env_name} (the API key for host #{host})", host: host) if key.strip.empty?
|
|
27
|
+
|
|
28
|
+
request["Authorization"] = "Bearer #{key}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# What to try after a 401/403 with the key sent.
|
|
32
|
+
def hint = "check #{env_name} (the API key for host #{host})"
|
|
33
|
+
|
|
34
|
+
# Without the environment: it holds this key and every other secret.
|
|
35
|
+
def inspect = "#<#{self.class.name} #{env_name} host=#{host}>"
|
|
36
|
+
alias_method :to_s, :inspect
|
|
37
|
+
|
|
38
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|