samagotchi 0.11.0 → 0.13.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 +90 -1
- data/README.md +20 -7
- data/bin/chi +10 -4
- data/docs/architecture.md +8 -6
- data/docs/cli.md +59 -11
- data/docs/configuration.md +29 -8
- data/docs/desktop.md +34 -8
- data/docs/guardrails.md +73 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +4 -4
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge.rb +15 -5
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +50 -18
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +480 -491
- data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +26 -3
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +82 -120
- data/lib/samagotchi/llm/chat_loop.rb +17 -38
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +10 -2
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +13 -1
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +31 -17
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/self_report.rb +8 -1
- data/lib/samagotchi/send_command.rb +51 -76
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_commands.rb +5 -5
- data/lib/samagotchi/session_manager.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +10 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
- data/lib/samagotchi/terminal_ui.rb +23 -29
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tool_runner.rb +4 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +15 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +17 -35
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- metadata +14 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require_relative "../log"
|
|
4
5
|
require_relative "../hooks/registry"
|
|
5
6
|
require_relative "../tools/args"
|
|
@@ -298,14 +299,26 @@ module Samagotchi
|
|
|
298
299
|
schema: spec[:schema], label: spec[:label],
|
|
299
300
|
handler: lambda { |call, _kctx|
|
|
300
301
|
result = block.call(Api.args_of(call, parameters), context)
|
|
301
|
-
|
|
302
|
-
result.is_a?(String) ? result : result.to_s
|
|
302
|
+
Api.result_text(result)
|
|
303
303
|
},
|
|
304
304
|
preview: preview && ->(call) { preview.call(Api.args_of(call, parameters))&.to_s },
|
|
305
305
|
targets: targets && ->(call) { targets.call(Api.args_of(call, parameters)) }
|
|
306
306
|
}
|
|
307
307
|
end
|
|
308
308
|
|
|
309
|
+
# A tool's result as the model reads it: a String (a ToolResult too,
|
|
310
|
+
# with its images) as it is, a Hash or Array as JSON (its #to_s is
|
|
311
|
+
# Hash#inspect, which changes with the Ruby version), else #to_s.
|
|
312
|
+
def self.result_text(result)
|
|
313
|
+
case result
|
|
314
|
+
when String then result
|
|
315
|
+
when Hash, Array then JSON.generate(result)
|
|
316
|
+
else result.to_s
|
|
317
|
+
end
|
|
318
|
+
rescue JSON::GeneratorError
|
|
319
|
+
result.to_s
|
|
320
|
+
end
|
|
321
|
+
|
|
309
322
|
# A tool call's arguments as a plugin sees them: the parsers' args:
|
|
310
323
|
# (a call built without one, in a spec say: the call without its
|
|
311
324
|
# name), string keys, typed by +parameters+, frozen.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "model_profile"
|
|
4
|
+
|
|
3
5
|
module Samagotchi
|
|
4
6
|
# Escapes literal control-token text when arbitrary source/output is embedded
|
|
5
7
|
# inside prompts, then restores those placeholders when model text is used.
|
|
@@ -13,8 +15,8 @@ module Samagotchi
|
|
|
13
15
|
["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_CLOSE]]", "<tool_response|>"],
|
|
14
16
|
["[[SAMAGOTCHI_LITERAL_GEMMA_STRING_DELIM]]", '<|"|>'],
|
|
15
17
|
["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<|think|>"],
|
|
16
|
-
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]",
|
|
17
|
-
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]",
|
|
18
|
+
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN],
|
|
19
|
+
["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE]
|
|
18
20
|
].freeze
|
|
19
21
|
|
|
20
22
|
QWEN_TOKEN_PAIRS = [
|
|
@@ -7,6 +7,7 @@ require "time"
|
|
|
7
7
|
|
|
8
8
|
require_relative "log"
|
|
9
9
|
require_relative "tools/ask_user_question"
|
|
10
|
+
require_relative "guardrails/parent_approvals"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
# An Engine's question flow: the one open question (ask_user_question, a
|
|
@@ -21,6 +22,20 @@ module Samagotchi
|
|
|
21
22
|
# of ArgumentError for existing callers; transports map it to 409 Conflict.
|
|
22
23
|
class NotPending < ArgumentError; end
|
|
23
24
|
|
|
25
|
+
# Raised by #answer when a parent agent's answer (chi answer's client
|
|
26
|
+
# id) allows an approval further than this worker's
|
|
27
|
+
# guardrails.parent_approvals lets it (Guardrails::ParentApprovals).
|
|
28
|
+
# The question stays open; transports map it to 403.
|
|
29
|
+
class Refused < StandardError
|
|
30
|
+
# @return [Symbol] :off or :once_only
|
|
31
|
+
attr_reader :reason
|
|
32
|
+
|
|
33
|
+
def initialize(reason)
|
|
34
|
+
@reason = reason
|
|
35
|
+
super("a parent may not allow this approval (#{reason})")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
24
39
|
DISMISSED_NOTE = "The user dismissed the question without answering. Don't do what you asked about, " \
|
|
25
40
|
"or anything else that changes files or state. Finish your reply with what you found " \
|
|
26
41
|
"and what you would do, and wait."
|
|
@@ -170,11 +185,16 @@ module Samagotchi
|
|
|
170
185
|
# @param id [String] pending id
|
|
171
186
|
# @param selected [Array<String>] values/labels
|
|
172
187
|
# @param freeform [String, nil]
|
|
188
|
+
# @param client_id [String, nil] who answers; chi answer's (a parent
|
|
189
|
+
# agent) is held to guardrails.parent_approvals on an approval
|
|
173
190
|
# @return [Hash] normalized answer
|
|
174
|
-
|
|
191
|
+
# @raise [NotPending, ArgumentError, Refused]
|
|
192
|
+
def answer(id:, selected:, freeform: nil, client_id: nil)
|
|
175
193
|
sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
|
|
176
194
|
fm = freeform.to_s.strip
|
|
177
195
|
fm = nil if fm.empty?
|
|
196
|
+
# Read before the lock (config may touch the disk); this worker's own.
|
|
197
|
+
parent_setting = Guardrails::ParentApprovals.setting if client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID
|
|
178
198
|
@lock.synchronize do
|
|
179
199
|
pending = @pending
|
|
180
200
|
raise NotPending, "no pending question" unless pending
|
|
@@ -197,10 +217,17 @@ module Samagotchi
|
|
|
197
217
|
if pending[:multi_select] == false && sel.empty? && fm.nil?
|
|
198
218
|
raise ArgumentError, "selection required"
|
|
199
219
|
end
|
|
220
|
+
indices = sel.map { |v| opts.index(v) }
|
|
221
|
+
# Checked against the question pending now, under the lock: the one
|
|
222
|
+
# whose answer settles the approval (Approval.settle, by index).
|
|
223
|
+
if parent_setting
|
|
224
|
+
reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: parent_setting)
|
|
225
|
+
raise Refused, reason if reason
|
|
226
|
+
end
|
|
200
227
|
# Persist pending cleared elsewhere; just set answer
|
|
201
228
|
answer = { id: id.to_s, selected: sel, freeform: fm }
|
|
202
229
|
# Derive indices for convenience
|
|
203
|
-
answer[:selected_indices] =
|
|
230
|
+
answer[:selected_indices] = indices.compact
|
|
204
231
|
@answer = answer
|
|
205
232
|
@cv.broadcast
|
|
206
233
|
answer
|
|
@@ -13,7 +13,8 @@ module Samagotchi
|
|
|
13
13
|
# host or model configured it asks the session's current model on its
|
|
14
14
|
# host, resolved at each attempt the way a turn does (a /model switch
|
|
15
15
|
# counts). An explicit `recap: {host_ref:, model:}` or `{base_url:,
|
|
16
|
-
# model:}` pins it;
|
|
16
|
+
# model:}` pins it; a model alone goes to its host, or where a bare
|
|
17
|
+
# --model goes; an incomplete one warns and leaves recap off.
|
|
17
18
|
#
|
|
18
19
|
# Single precedence path: explicit `recap:` kwarg > Config registry
|
|
19
20
|
# (CLI > ENV > file > default). An explicit disable (`recap: false` as
|
|
@@ -42,14 +43,23 @@ module Samagotchi
|
|
|
42
43
|
model = string_config(kwarg_config, :model) || registry_string("recap.model")
|
|
43
44
|
label = model
|
|
44
45
|
|
|
46
|
+
# The model as any model ref (ModelRef): its alias applied once, and
|
|
47
|
+
# its host (own or the alias's) picks the recap host when host_ref
|
|
48
|
+
# doesn't.
|
|
49
|
+
parsed = model && host_registry.model_ref(model)
|
|
45
50
|
target = nil
|
|
46
51
|
if base_url.nil? && host_ref.nil? && model.nil?
|
|
47
52
|
target = -> { session_target.call }
|
|
53
|
+
elsif base_url.nil? && host_ref.nil? && !parsed.host_name
|
|
54
|
+
# A model naming no host goes where a bare --model goes
|
|
55
|
+
# (HostRegistry#host_for_model: the default host unless another's
|
|
56
|
+
# list has it), resolved at each attempt.
|
|
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
|
|
48
62
|
else
|
|
49
|
-
# The model as any model ref (ModelRef): its alias applied once, and
|
|
50
|
-
# its host (own or the alias's) picks the recap host when host_ref
|
|
51
|
-
# doesn't.
|
|
52
|
-
parsed = model && host_registry.model_ref(model)
|
|
53
63
|
host_ref ||= parsed.host_name if parsed && base_url.nil?
|
|
54
64
|
# If host_ref given, derive base_url (the host's OpenAI base) and its
|
|
55
65
|
# API key variable from the host_registry entry
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# The session's due reminders, on their way into a turn.
|
|
7
|
+
#
|
|
8
|
+
# The ReminderStore is the truth (a reminder is due once its interval
|
|
9
|
+
# passed). This adds the names the idle tick found due and handed to the
|
|
10
|
+
# UI's callback (#note_pending): the worker and the REPL run a reminder
|
|
11
|
+
# turn while they are pending. A turn injects whatever is due then
|
|
12
|
+
# (#inject!), which marks it fired and clears the pending names.
|
|
13
|
+
class ReminderQueue
|
|
14
|
+
# @param store [ReminderStore, nil]
|
|
15
|
+
def initialize(store:)
|
|
16
|
+
@store = store
|
|
17
|
+
@lock = Monitor.new
|
|
18
|
+
@pending = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Array<Hash>] the reminders due now ({name:, description:, interval_minutes:})
|
|
22
|
+
def due
|
|
23
|
+
@store&.due_reminders || []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Boolean] whether any reminder is due now (the store's view,
|
|
27
|
+
# which #inject! would inject), regardless of the pending names
|
|
28
|
+
def due?
|
|
29
|
+
!due.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Array<String>] the names queued for a reminder turn
|
|
33
|
+
def pending_names
|
|
34
|
+
@lock.synchronize { @pending.dup }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Queue names for a reminder turn (the idle tick's callback).
|
|
38
|
+
def note_pending(names)
|
|
39
|
+
@lock.synchronize { @pending = Array(names).dup }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Boolean] whether names are queued (the idle tick waits meanwhile)
|
|
43
|
+
def pending?
|
|
44
|
+
@lock.synchronize { !@pending.empty? }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def clear_pending!
|
|
48
|
+
@lock.synchronize { @pending = [] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Inject the due reminders into a turn's messages as one tail system
|
|
52
|
+
# message (after the history, before the user's message), mark them
|
|
53
|
+
# all fired under one store lock, and clear the pending names.
|
|
54
|
+
#
|
|
55
|
+
# A tail message, not the head system prompt: changing the head would
|
|
56
|
+
# make the model server re-evaluate the whole conversation's prefix
|
|
57
|
+
# (its KV cache) every interval; a tail costs only the new suffix.
|
|
58
|
+
# @param messages [Array<Hash>] the turn's messages (appended to)
|
|
59
|
+
# @return [Array<Hash>] the reminders injected (maybe none)
|
|
60
|
+
def inject!(messages)
|
|
61
|
+
reminders = due
|
|
62
|
+
return [] if reminders.empty?
|
|
63
|
+
|
|
64
|
+
lines = reminders.map { |r| " #{r[:name]}: #{r[:description]} (interval: #{r[:interval_minutes]}m)" }.join("\n")
|
|
65
|
+
messages << { role: "system", content: "[SYSTEM: REMINDERS DUE]\n#{lines}\n[END REMINDERS]" }
|
|
66
|
+
@store.mark_fired_batch(reminders.map { |r| r[:name] })
|
|
67
|
+
# Without this a turn's injection leaves a stale name, and the next
|
|
68
|
+
# reminder turn would run with nothing due.
|
|
69
|
+
clear_pending!
|
|
70
|
+
reminders
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -15,8 +15,8 @@ module Samagotchi
|
|
|
15
15
|
#
|
|
16
16
|
# Reminder lifecycle:
|
|
17
17
|
# 1. Agent calls register_reminder → store adds entry
|
|
18
|
-
# 2. IdleReminders detects due →
|
|
19
|
-
# 3. Engine#run_turn
|
|
18
|
+
# 2. IdleReminders detects due while idle → the UI queues a reminder turn
|
|
19
|
+
# 3. Engine#run_turn injects what is due as a [SYSTEM:] message (ReminderQueue#inject!)
|
|
20
20
|
# 4. Agent acts on reminder → calls cancel_reminder → store removes entry
|
|
21
21
|
class ReminderStore
|
|
22
22
|
# @return [Hash{String => Hash}] id => {id:, name:, description:, interval_minutes:, next_fire_at:}
|
|
@@ -90,7 +90,7 @@ module Samagotchi
|
|
|
90
90
|
|
|
91
91
|
# Get all due reminders whose interval has elapsed.
|
|
92
92
|
# Returns an Array of {name:, description:, interval_minutes:} hashes.
|
|
93
|
-
#
|
|
93
|
+
# Read by ReminderQueue (a turn's injection, the idle tick).
|
|
94
94
|
# Thread-safe. Returns a frozen copy so the caller can't mutate internal state.
|
|
95
95
|
# @return [Array<Hash>] array of due reminder hashes (may be empty)
|
|
96
96
|
def due_reminders
|
|
@@ -72,8 +72,12 @@ module Samagotchi
|
|
|
72
72
|
return Result.new(status: :stopped)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# Only a question a live worker holds waits for an answer (the
|
|
76
|
+
# lists' rule): one a dead worker left waits for no one, and a chi
|
|
77
|
+
# REPL's is answered there.
|
|
75
78
|
pending = session.pending_question
|
|
76
|
-
if pending && !(baseline && pending[:id] == baseline[:question_id])
|
|
79
|
+
if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
|
|
80
|
+
session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
|
|
77
81
|
return Result.new(status: :waiting_for_answer, question: pending)
|
|
78
82
|
end
|
|
79
83
|
|
|
@@ -111,6 +111,13 @@ module Samagotchi
|
|
|
111
111
|
nil
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
+
# `chi self --model`, the desktop helper's hint: the default as the ref
|
|
115
|
+
# it resolves to (an alias applied, ModelRef), as `chi models` names it.
|
|
116
|
+
def model_ref_name(env: ENV)
|
|
117
|
+
name = model_name
|
|
118
|
+
name && ConfigFile.model_ref(name, env: env).ref
|
|
119
|
+
end
|
|
120
|
+
|
|
114
121
|
# Routed without model discovery, so an unqualified name may land on
|
|
115
122
|
# another host at runtime after /models.
|
|
116
123
|
def loop_for(model, env)
|
|
@@ -132,7 +139,7 @@ module Samagotchi
|
|
|
132
139
|
return "reported per turn (remote host)" if entry.remote?
|
|
133
140
|
|
|
134
141
|
client = registry.client_for(entry)
|
|
135
|
-
props = client.server_props(model: bare)
|
|
142
|
+
props = client.server_props(model: bare)
|
|
136
143
|
return "reported per turn (the server has no /props)" if props.nil?
|
|
137
144
|
return "unknown (the server didn't answer; is it running?)" if props.status == :network_error
|
|
138
145
|
|
|
@@ -5,12 +5,14 @@ require_relative "session_inbox"
|
|
|
5
5
|
require_relative "session_manager"
|
|
6
6
|
require_relative "context_quote"
|
|
7
7
|
require_relative "reply_wait"
|
|
8
|
+
require_relative "parent_report"
|
|
8
9
|
require_relative "image_store"
|
|
9
10
|
require_relative "host_registry"
|
|
10
11
|
require_relative "model_profile"
|
|
11
12
|
require_relative "vision_support"
|
|
12
13
|
require_relative "cli/command"
|
|
13
14
|
require_relative "cli/flags"
|
|
15
|
+
require_relative "cli/parent_wait"
|
|
14
16
|
|
|
15
17
|
module Samagotchi
|
|
16
18
|
# `chi send`: put text into sessions as the user's message, the same as
|
|
@@ -22,20 +24,17 @@ module Samagotchi
|
|
|
22
24
|
# prints it: an agent's one-shot the user can watch in the web.
|
|
23
25
|
class SendCommand
|
|
24
26
|
include CLI::Command
|
|
27
|
+
include CLI::ParentWait
|
|
25
28
|
|
|
26
29
|
CLIENT_ID = "cli:send"
|
|
27
|
-
POLL_INTERVAL = ReplyWait::POLL_INTERVAL
|
|
28
|
-
# No live worker this long while waiting: it died before it could mark
|
|
29
|
-
# the session (a worker takes well under a second to start).
|
|
30
|
-
WORKER_GONE_AFTER = 5
|
|
31
30
|
# The cap on one turn's images the Bridge checks.
|
|
32
31
|
MAX_IMAGES = ImageStore::MAX_TURN_REFS
|
|
33
32
|
|
|
34
33
|
USAGE = <<~TEXT
|
|
35
34
|
Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
|
|
36
35
|
chi send --new [--dir DIR] [--model M] [-m TEXT] [--image PATH]...
|
|
37
|
-
chi send --wait [--timeout S] [-m TEXT] [--image PATH]... (--new | ID)
|
|
38
|
-
chi send --wait [--timeout S] ID
|
|
36
|
+
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
37
|
+
chi send --wait [--timeout S] [--format json] ID
|
|
39
38
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
40
39
|
or a running one picks it up. A stopped session's worker starts.
|
|
41
40
|
-m TEXT the message; stdin, when piped too, goes above it as a
|
|
@@ -53,11 +52,21 @@ module Samagotchi
|
|
|
53
52
|
--model M (--new) its model; default the configured one
|
|
54
53
|
--wait wait for the answer and print it (one session); the
|
|
55
54
|
other lines go to stderr. Exit 3: it waits for an
|
|
56
|
-
answer from you
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
answer from you: the question, its options and the
|
|
56
|
+
chi answer command go to stderr (or chi --attach ID,
|
|
57
|
+
the web). With no message (no -m, nothing piped)
|
|
58
|
+
nothing is sent: it waits for the session's next
|
|
59
|
+
reply, a running turn's too (after exit 3, 4 or 130,
|
|
60
|
+
wait again this way)
|
|
61
|
+
--timeout S (--wait) give up after S seconds, exit 4 (the turn
|
|
62
|
+
goes on); default no limit
|
|
63
|
+
--format json
|
|
64
|
+
(--wait) one JSON object on stdout, whatever the end:
|
|
65
|
+
status answered (text), question (question,
|
|
66
|
+
answer_with), running, or failed, canceled,
|
|
67
|
+
no_answer, error, worker_gone, stopped (detail)
|
|
68
|
+
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
69
|
+
question waits, 4 still running (--timeout), 130 Ctrl-C.
|
|
61
70
|
Only sessions on this machine. Answers show in the attached TUI
|
|
62
71
|
or web page, not here.
|
|
63
72
|
Find ids with: chi sessions list --live [--scope=all] [--format tsv]
|
|
@@ -71,6 +80,7 @@ module Samagotchi
|
|
|
71
80
|
f.value "--image", key: :images, repeat: true
|
|
72
81
|
f.switch "--wait"
|
|
73
82
|
f.value "--timeout"
|
|
83
|
+
f.value "--format"
|
|
74
84
|
# Starting a turn in every live session at once is too easy to do
|
|
75
85
|
# by accident.
|
|
76
86
|
f.refuse "--all", "there is no --all: name the sessions"
|
|
@@ -95,7 +105,7 @@ module Samagotchi
|
|
|
95
105
|
|
|
96
106
|
profile = ModelProfile.resolve(names: names, entry: entry, client: target.client, bare_model: target.bare_model).profile
|
|
97
107
|
answer = VisionSupport.for(target, profile: profile, names: names)
|
|
98
|
-
props = target.client.
|
|
108
|
+
props = target.client.server_props(model: target.bare_model)
|
|
99
109
|
return UNKNOWN_VISION if answer.no? && !props&.answered? && !configured_no?(answer)
|
|
100
110
|
|
|
101
111
|
answer
|
|
@@ -119,13 +129,13 @@ module Samagotchi
|
|
|
119
129
|
@state_dir = state_dir || Session.default_state_dir
|
|
120
130
|
end
|
|
121
131
|
|
|
122
|
-
|
|
123
|
-
# 2 usage; with --wait 0 answered, 3 waiting for an answer, 130
|
|
124
|
-
# Ctrl-C (the turn goes on)
|
|
125
|
-
def run
|
|
126
|
-
options = parse
|
|
127
|
-
return options if options.is_a?(Integer)
|
|
132
|
+
private
|
|
128
133
|
|
|
134
|
+
# Under #run (CLI::ParentWait).
|
|
135
|
+
# @return [Integer] exit status: 0 all sent, 1 any refused or failed,
|
|
136
|
+
# 2 usage; with --wait 0 answered, 3 waiting for an answer, 4 still
|
|
137
|
+
# running after --timeout, 130 Ctrl-C (the turn goes on)
|
|
138
|
+
def run_parsed(options)
|
|
129
139
|
# With --wait stdout is the answer alone.
|
|
130
140
|
@info = options[:wait] ? @stderr : @stdout
|
|
131
141
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
@@ -140,7 +150,9 @@ module Samagotchi
|
|
|
140
150
|
@stderr.puts("chi send: #{e.message}")
|
|
141
151
|
return 1
|
|
142
152
|
end
|
|
143
|
-
|
|
153
|
+
# A file that is not there or not an image: a run-time failure (1),
|
|
154
|
+
# reported in the --format json line too; --image itself was fine.
|
|
155
|
+
return CLI::Exit::FAILED unless ingest_images(options[:images])
|
|
144
156
|
|
|
145
157
|
return run_new(prompt, options) if options[:new]
|
|
146
158
|
return run_wait(prompt, options) if options[:wait]
|
|
@@ -160,8 +172,6 @@ module Samagotchi
|
|
|
160
172
|
FileUtils.rm_rf(@image_dir) if @image_dir
|
|
161
173
|
end
|
|
162
174
|
|
|
163
|
-
private
|
|
164
|
-
|
|
165
175
|
def command_name = "chi send"
|
|
166
176
|
|
|
167
177
|
# @return [Hash, Integer] the options, or the exit status after the
|
|
@@ -171,6 +181,10 @@ module Samagotchi
|
|
|
171
181
|
return parsed if parsed.is_a?(Integer)
|
|
172
182
|
|
|
173
183
|
options = parsed.options.merge(ids: parsed.args)
|
|
184
|
+
if options[:format]
|
|
185
|
+
return usage_error("--format needs --wait") unless options[:wait]
|
|
186
|
+
return usage_error("--format takes text or json") unless FORMATS.include?(options[:format])
|
|
187
|
+
end
|
|
174
188
|
if options[:timeout]
|
|
175
189
|
return usage_error("--timeout needs --wait") unless options[:wait]
|
|
176
190
|
|
|
@@ -220,16 +234,6 @@ module Samagotchi
|
|
|
220
234
|
@stdin.read
|
|
221
235
|
end
|
|
222
236
|
|
|
223
|
-
def resolve(given)
|
|
224
|
-
id = Session.resolve_id(given, state_dir: @state_dir)
|
|
225
|
-
Session.load(id, state_dir: @state_dir)
|
|
226
|
-
id
|
|
227
|
-
rescue ArgumentError => e
|
|
228
|
-
message = e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"
|
|
229
|
-
error_line("chi send: #{message}")
|
|
230
|
-
nil
|
|
231
|
-
end
|
|
232
|
-
|
|
233
237
|
# A worker session like the web start page's: saved as running with the
|
|
234
238
|
# message before its worker spawns, so lists and the web show it at
|
|
235
239
|
# once. The full id, so a script can pass it on. Only the model's host
|
|
@@ -257,6 +261,7 @@ module Samagotchi
|
|
|
257
261
|
else
|
|
258
262
|
@info.puts("#{session.id} started")
|
|
259
263
|
end
|
|
264
|
+
@session_id = session.id
|
|
260
265
|
return 0 unless options[:wait]
|
|
261
266
|
|
|
262
267
|
wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
|
|
@@ -272,8 +277,20 @@ module Samagotchi
|
|
|
272
277
|
def run_wait(prompt, options)
|
|
273
278
|
id = resolve(options[:ids].first) or return 1
|
|
274
279
|
session = Session.load(id, state_dir: @state_dir)
|
|
275
|
-
if session.status == Session::STATUS_RUNNING && SessionManager.session_owner(id, state_dir: @state_dir)
|
|
276
|
-
|
|
280
|
+
if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir))
|
|
281
|
+
# The turn waits on a question: the message would only queue
|
|
282
|
+
# behind it, and the answer goes in with chi answer.
|
|
283
|
+
if (waiting = session.waiting_question(live: owner.worker?))
|
|
284
|
+
if ParentReport.approval?(waiting)
|
|
285
|
+
error_line("#{id[0, 8]} waiting for an approval: #{ParentReport::DENY_AND_TELL} " \
|
|
286
|
+
"(#{ParentReport.answer_with(id, waiting)})")
|
|
287
|
+
else
|
|
288
|
+
error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, waiting)} (or --text); " \
|
|
289
|
+
"chi --attach #{id} shows it")
|
|
290
|
+
end
|
|
291
|
+
else
|
|
292
|
+
error_line("#{id[0, 8]} busy: a turn is running; wait or attach")
|
|
293
|
+
end
|
|
277
294
|
return 1
|
|
278
295
|
end
|
|
279
296
|
|
|
@@ -298,7 +315,7 @@ module Samagotchi
|
|
|
298
315
|
live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
|
|
299
316
|
wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
|
|
300
317
|
baseline: baseline_of(session).merge(messages: nil),
|
|
301
|
-
timeout: options[:timeout], owner_grace: live ? WORKER_GONE_AFTER : nil)
|
|
318
|
+
timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil)
|
|
302
319
|
end
|
|
303
320
|
|
|
304
321
|
# The session as it was before the message went in (ReplyWait's
|
|
@@ -307,48 +324,6 @@ module Samagotchi
|
|
|
307
324
|
ReplyWait.baseline_of(session, **options)
|
|
308
325
|
end
|
|
309
326
|
|
|
310
|
-
# @return [Integer] the exit status
|
|
311
|
-
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: WORKER_GONE_AFTER)
|
|
312
|
-
result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
|
|
313
|
-
owner_grace: owner_grace, poll_interval: POLL_INTERVAL)
|
|
314
|
-
return reply(result.text) if result.status == :done
|
|
315
|
-
|
|
316
|
-
attach = "chi --attach #{id}"
|
|
317
|
-
line, status = case result.status
|
|
318
|
-
when :waiting_for_answer
|
|
319
|
-
question = result.question[:question].to_s.strip.lines.first.to_s.strip
|
|
320
|
-
["waiting for an answer: #{question}; open it: #{attach} or the web", 3]
|
|
321
|
-
when :no_reply then ["#{no_reply_line(result)}; #{attach} shows it", 1]
|
|
322
|
-
when :error then ["the worker failed: #{result.text}; #{attach} shows what happened", 1]
|
|
323
|
-
when :worker_gone then ["the worker is gone; #{attach} shows what happened", 1]
|
|
324
|
-
when :stopped then ["the session was stopped (chi sessions stop)", 1]
|
|
325
|
-
else ["still running after #{format("%g", timeout)} s: #{attach}", 1]
|
|
326
|
-
end
|
|
327
|
-
error_line("chi send: #{line}")
|
|
328
|
-
status
|
|
329
|
-
rescue Interrupt
|
|
330
|
-
error_line("chi send: still running: chi --attach #{id}")
|
|
331
|
-
130
|
|
332
|
-
rescue ArgumentError
|
|
333
|
-
error_line("chi send: the session is gone (deleted while waiting)")
|
|
334
|
-
1
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
def no_reply_line(result)
|
|
338
|
-
case result.outcome
|
|
339
|
-
when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
|
|
340
|
-
when "canceled" then "the turn was canceled"
|
|
341
|
-
when "completed" then "the turn ended with no visible answer"
|
|
342
|
-
else "the turn ended without a reply (canceled, failed or empty)"
|
|
343
|
-
end
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
def reply(text)
|
|
347
|
-
@stdout.puts(utf8(text))
|
|
348
|
-
@stdout.flush
|
|
349
|
-
0
|
|
350
|
-
end
|
|
351
|
-
|
|
352
327
|
# A new idle session's first turn, with the images. Its worker's Bridge
|
|
353
328
|
# first: deliver_turn wakes a worker when none owns the session yet,
|
|
354
329
|
# and the spawned one takes its lock only once it runs.
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -99,6 +99,21 @@ module Samagotchi
|
|
|
99
99
|
@archived = false
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
+
# The question this session waits on, as the lists show it: {id:, kind:}
|
|
103
|
+
# with kind "question" (the model's), "approval" or "hook"; nil with
|
|
104
|
+
# none. Only while a worker runs it (+live+): a question saved by a
|
|
105
|
+
# worker that died waits for no one (the next worker drops it). The
|
|
106
|
+
# web's summaries and `chi sessions list` both ask this.
|
|
107
|
+
# @param live [Boolean] a worker owns the session now
|
|
108
|
+
# @return [Hash, nil]
|
|
109
|
+
def waiting_question(live:)
|
|
110
|
+
pending = pending_question
|
|
111
|
+
return nil unless live && pending.is_a?(Hash) && pending[:id]
|
|
112
|
+
|
|
113
|
+
kind = pending[:kind].to_s
|
|
114
|
+
{ id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question" }
|
|
115
|
+
end
|
|
116
|
+
|
|
102
117
|
# The stored project root, else (a file from before the field) the
|
|
103
118
|
# project of the working directory now: nil when that is in no repo or
|
|
104
119
|
# gone. +cache+ is ProjectScope.root_for's, shared across one listing.
|
|
@@ -404,7 +404,7 @@ module Samagotchi
|
|
|
404
404
|
|
|
405
405
|
def persist_session_model
|
|
406
406
|
session = @engine.session
|
|
407
|
-
return unless session
|
|
407
|
+
return unless session
|
|
408
408
|
|
|
409
409
|
@engine.store_model!(session)
|
|
410
410
|
begin
|
|
@@ -416,7 +416,7 @@ module Samagotchi
|
|
|
416
416
|
|
|
417
417
|
# "; served: <name>" when the server serves another model than asked.
|
|
418
418
|
def served_note
|
|
419
|
-
served, asked = @engine.
|
|
419
|
+
served, asked = @engine.served_model
|
|
420
420
|
ServedModel.differs?(asked, served) ? "; served: #{served}" : ""
|
|
421
421
|
rescue StandardError
|
|
422
422
|
""
|
|
@@ -424,7 +424,7 @@ module Samagotchi
|
|
|
424
424
|
|
|
425
425
|
# "; sampling: temperature=0.6 (hosts.work)" when the model has any.
|
|
426
426
|
def sampling_note
|
|
427
|
-
summary = @engine.
|
|
427
|
+
summary = @engine.sampling_summary
|
|
428
428
|
summary ? "; sampling: #{summary}" : ""
|
|
429
429
|
rescue StandardError
|
|
430
430
|
""
|
|
@@ -432,7 +432,7 @@ module Samagotchi
|
|
|
432
432
|
|
|
433
433
|
# "; thinking: off (models: qwen)" when the model has a level set.
|
|
434
434
|
def thinking_note
|
|
435
|
-
summary = @engine.
|
|
435
|
+
summary = @engine.thinking_summary
|
|
436
436
|
summary ? "; thinking: #{summary}" : ""
|
|
437
437
|
rescue StandardError
|
|
438
438
|
""
|
|
@@ -447,7 +447,7 @@ module Samagotchi
|
|
|
447
447
|
end
|
|
448
448
|
|
|
449
449
|
def chat_model?
|
|
450
|
-
@engine.
|
|
450
|
+
@engine.chat_model?
|
|
451
451
|
rescue StandardError
|
|
452
452
|
false
|
|
453
453
|
end
|