samagotchi 0.46.0 → 0.47.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 +75 -1
- data/docs/cli.md +14 -8
- data/docs/configuration.md +3 -1
- data/docs/desktop.md +2 -1
- data/docs/plugins.md +6 -0
- data/docs/sessions.md +24 -6
- data/lib/samagotchi/bundles/coordinator/manifest.yml +4 -4
- data/lib/samagotchi/bundles/coordinator/plugin.rb +15 -2
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +20 -0
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +26 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +5 -1
- data/lib/samagotchi/engine.rb +4 -5
- data/lib/samagotchi/host_model.rb +14 -7
- data/lib/samagotchi/host_registry.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +27 -7
- data/lib/samagotchi/llm/errors.rb +8 -2
- data/lib/samagotchi/llm_context_strategy.rb +1 -0
- data/lib/samagotchi/model_catalog.rb +13 -12
- data/lib/samagotchi/model_profile.rb +4 -3
- data/lib/samagotchi/models_command.rb +12 -5
- data/lib/samagotchi/question_desk.rb +29 -4
- data/lib/samagotchi/send_command.rb +59 -9
- data/lib/samagotchi/served_model.rb +9 -0
- data/lib/samagotchi/session.rb +13 -4
- data/lib/samagotchi/session_chain.rb +93 -0
- data/lib/samagotchi/session_commands.rb +8 -7
- data/lib/samagotchi/session_manager.rb +225 -14
- data/lib/samagotchi/sessions_command.rb +5 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -11
- data/lib/samagotchi/terminal_ui.rb +4 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +86 -4
- data/lib/samagotchi/web/public/app.js +200 -18
- data/lib/samagotchi/web/public/chains.js +234 -0
- data/lib/samagotchi/web/public/data.js +12 -0
- data/lib/samagotchi/web/public/families_view.js +3 -2
- data/lib/samagotchi/web/public/index.html +32 -6
- data/lib/samagotchi/web/public/model_pick.js +22 -15
- data/lib/samagotchi/web/public/model_picker.js +6 -3
- data/lib/samagotchi/web/public/question_card.js +8 -0
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/sessions_list.js +4 -2
- data/lib/samagotchi/web/public/turn_events.js +14 -6
- data/lib/samagotchi/web/session_summary.rb +3 -0
- data/lib/samagotchi/worker.rb +7 -3
- metadata +3 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -945,6 +945,8 @@ module Samagotchi
|
|
|
945
945
|
# context window, the served model and (on a native host) the prompt
|
|
946
946
|
# profile come from the effective model's server, so this may make one
|
|
947
947
|
# short /props GET; the cheap #session_state_snapshot never does.
|
|
948
|
+
# price_host: the effective model's host, whose hosts.<name>.models
|
|
949
|
+
# prices the cost estimate names.
|
|
948
950
|
# @return [Hash] @metrics.snapshot, filled in
|
|
949
951
|
def stats_snapshot
|
|
950
952
|
snapshot = @metrics.snapshot
|
|
@@ -952,7 +954,7 @@ module Samagotchi
|
|
|
952
954
|
served, served_for = served_model_for(snapshot, target: target)
|
|
953
955
|
snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
|
|
954
956
|
served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
|
|
955
|
-
prompt_notes: prompt_notes.map(&:to_h))
|
|
957
|
+
prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
|
|
956
958
|
unless snapshot.dig(:context, :window_tokens)
|
|
957
959
|
window = current_context_window(target)
|
|
958
960
|
if window
|
|
@@ -1051,8 +1053,7 @@ module Samagotchi
|
|
|
1051
1053
|
# warn about. nil when none does.
|
|
1052
1054
|
# @return [String, nil]
|
|
1053
1055
|
def served_expected_by(served, target: nil)
|
|
1054
|
-
target
|
|
1055
|
-
target.entry.name if target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.serves?(served)
|
|
1056
|
+
ServedModel.expected_by(target || @host_registry.resolve(@effective_model_name), served)
|
|
1056
1057
|
rescue StandardError
|
|
1057
1058
|
nil
|
|
1058
1059
|
end
|
|
@@ -1447,8 +1448,6 @@ module Samagotchi
|
|
|
1447
1448
|
@question_desk.request(payload)
|
|
1448
1449
|
end
|
|
1449
1450
|
|
|
1450
|
-
QUESTION_DISMISSED_NOTE = QuestionDesk::DISMISSED_NOTE
|
|
1451
|
-
|
|
1452
1451
|
# Open a question for the UIs and wait for its answer
|
|
1453
1452
|
# (QuestionDesk#open_question: BLOCKS until answered or cancelled).
|
|
1454
1453
|
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
@@ -78,18 +78,22 @@ module Samagotchi
|
|
|
78
78
|
# One host's rows as the listings show them, declared ids first, then
|
|
79
79
|
# the listed ones in the host's order (see Row). An id both declared and
|
|
80
80
|
# listed is one row. Pure: +results+ (list_all_models') is only read.
|
|
81
|
-
# A host that errored
|
|
82
|
-
#
|
|
81
|
+
# A host that errored keeps only its declared ids, marked unavailable
|
|
82
|
+
# (still pickable; the listings warn it's unreachable); [] when it
|
|
83
|
+
# declares none.
|
|
83
84
|
# @param results [Hash{String => Hash}] host name => {models: [ModelInfo], error:}
|
|
84
85
|
# @param entries [Hash{String => HostRegistry::HostEntry}]
|
|
85
|
-
# @return [Hash{String => Array<Row>}] for
|
|
86
|
+
# @return [Hash{String => Array<Row>}] for every host in +results+
|
|
86
87
|
def self.rows(results, entries)
|
|
87
88
|
results.each_with_object({}) do |(name, data), out|
|
|
88
|
-
|
|
89
|
+
declared = entries[name]&.models || {}
|
|
90
|
+
if data[:error]
|
|
91
|
+
out[name] = declared.values.map { |m| HostModel::Row.new(id: m.id, configured: true, info: nil, unavailable: true) }
|
|
92
|
+
next
|
|
93
|
+
end
|
|
89
94
|
|
|
90
95
|
infos = Array(data[:models])
|
|
91
96
|
by_id = infos.group_by { |info| info.id.to_s.downcase }
|
|
92
|
-
declared = entries[name]&.models || {}
|
|
93
97
|
out[name] = declared.values.map do |m|
|
|
94
98
|
info = by_id[m.id.downcase]&.first
|
|
95
99
|
# A listed id keeps the host's spelling: a case-sensitive server takes only that.
|
|
@@ -111,6 +115,9 @@ module Samagotchi
|
|
|
111
115
|
# A listing row (HostModel.rows): id as the host lists it, else as
|
|
112
116
|
# declared;
|
|
113
117
|
# configured: declared under hosts.<name>.models; info: the host's
|
|
114
|
-
# ModelInfo for it, nil when only declared
|
|
115
|
-
|
|
118
|
+
# ModelInfo for it, nil when only declared; unavailable: the host failed
|
|
119
|
+
# to list its models; only its declared ids are shown.
|
|
120
|
+
HostModel::Row = Data.define(:id, :configured, :info, :unavailable) do
|
|
121
|
+
def initialize(id:, configured:, info:, unavailable: false) = super
|
|
122
|
+
end
|
|
116
123
|
end
|
|
@@ -322,7 +322,7 @@ module Samagotchi
|
|
|
322
322
|
save_model_list(name, models)
|
|
323
323
|
rescue StandardError => e
|
|
324
324
|
Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
|
|
325
|
-
data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e
|
|
325
|
+
data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: list_error(e) }
|
|
326
326
|
end
|
|
327
327
|
results_mutex.synchronize { results[name] = data }
|
|
328
328
|
end
|
|
@@ -374,6 +374,11 @@ module Samagotchi
|
|
|
374
374
|
|
|
375
375
|
private
|
|
376
376
|
|
|
377
|
+
# A listing error as text without the host's name (a provider error's
|
|
378
|
+
# #detail: "connection refused", "request failed after …"): every line
|
|
379
|
+
# that shows it names the host itself.
|
|
380
|
+
def list_error(error) = error.is_a?(LLM::ProviderError) ? error.detail : error.message
|
|
381
|
+
|
|
377
382
|
# Save a host's just-listed models on disk, for the processes that spawn
|
|
378
383
|
# a worker without listing (`chi send --new --model`, delegate):
|
|
379
384
|
# ModelListStore is what they check an id against.
|
|
@@ -510,15 +510,18 @@ module Samagotchi
|
|
|
510
510
|
generation_controller = nil
|
|
511
511
|
response = with_generation do |controller|
|
|
512
512
|
generation_controller = controller
|
|
513
|
+
# One count for the step: the ask-again without thinking fields
|
|
514
|
+
# doesn't get drops of its own.
|
|
515
|
+
drops = DropCount.new(0, 0)
|
|
513
516
|
begin
|
|
514
|
-
request_with_drops(iteration, retry_generation, streamed, thought,
|
|
517
|
+
request_with_drops(iteration, retry_generation, streamed, thought, drops, controller)
|
|
515
518
|
rescue BadRequest => e
|
|
516
519
|
raise unless thinking_refused?(e)
|
|
517
520
|
|
|
518
521
|
# Once per model: asked again without the thinking fields.
|
|
519
522
|
@loop.thinking_refused!(@model_name)
|
|
520
523
|
emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
|
|
521
|
-
request_with_drops(iteration, retry_generation, streamed, thought,
|
|
524
|
+
request_with_drops(iteration, retry_generation, streamed, thought, drops, controller)
|
|
522
525
|
end
|
|
523
526
|
rescue RequestCancelled
|
|
524
527
|
raise unless generation_controller&.cancelled? && !@cancel_controller.cancelled?
|
|
@@ -596,9 +599,14 @@ module Samagotchi
|
|
|
596
599
|
# retries.
|
|
597
600
|
MAX_STREAM_DROPS = 2
|
|
598
601
|
|
|
599
|
-
#
|
|
600
|
-
#
|
|
601
|
-
|
|
602
|
+
# A step's drops asked again (+drops+) and the HTTP requests made so
|
|
603
|
+
# far (+requests+: each failed request's transport attempts), so the
|
|
604
|
+
# error that ends the step counts every request, not the last one's.
|
|
605
|
+
DropCount = Struct.new(:drops, :requests)
|
|
606
|
+
|
|
607
|
+
# Runs one request, asking again on a mid-stream drop; +count+ (a
|
|
608
|
+
# DropCount) carries on across calls for one step.
|
|
609
|
+
def request_with_drops(iteration, retry_generation, streamed, thought, count, generation_controller)
|
|
602
610
|
streamed_any = false
|
|
603
611
|
request(iteration, retry_generation, streamed, thought, generation_controller, -> { streamed_any = true })
|
|
604
612
|
rescue RetryExhausted => e
|
|
@@ -607,10 +615,16 @@ module Samagotchi
|
|
|
607
615
|
cancelled = [@cancel_controller, generation_controller].compact.find(&:cancelled?)
|
|
608
616
|
raise RequestCancelled, cancelled.reason if cancelled
|
|
609
617
|
|
|
618
|
+
count.requests += e.attempts.to_i
|
|
610
619
|
limit = @loop.stream_drop_limit(MAX_STREAM_DROPS)
|
|
611
|
-
|
|
620
|
+
if count.drops >= limit || !streamed_any
|
|
621
|
+
raise if count.requests == e.attempts.to_i
|
|
622
|
+
|
|
623
|
+
raise RetryExhausted.new(attempts: count.requests, last_error: e.last_error, label: e.host)
|
|
624
|
+
end
|
|
612
625
|
|
|
613
|
-
|
|
626
|
+
count.drops += 1
|
|
627
|
+
attempt = count.drops
|
|
614
628
|
streamed.clear
|
|
615
629
|
thought.clear
|
|
616
630
|
delay = @loop.generation_retry_delay(attempt)
|
|
@@ -620,6 +634,12 @@ module Samagotchi
|
|
|
620
634
|
next_delay: delay, error_class: e.class.name, error_message: e.message, status: nil, restarted: true)
|
|
621
635
|
@loop.wait_retry(delay, @cancel_controller, generation_controller)
|
|
622
636
|
retry
|
|
637
|
+
rescue ProviderError => e
|
|
638
|
+
# Another failure (a 503, a refusal) after earlier requests this
|
|
639
|
+
# step: its count takes theirs in.
|
|
640
|
+
count.requests += e.attempts.to_i
|
|
641
|
+
e.attempts = count.requests
|
|
642
|
+
raise
|
|
623
643
|
end
|
|
624
644
|
|
|
625
645
|
# A 400 about reasoning, for a request that carried thinking fields
|
|
@@ -223,11 +223,17 @@ module Samagotchi
|
|
|
223
223
|
|
|
224
224
|
def initialize(attempts:, last_error:, label: "llama.cpp")
|
|
225
225
|
@last_error = last_error
|
|
226
|
-
super("#{label} request failed after #{attempts}
|
|
226
|
+
super("#{label} request failed after #{self.class.count(attempts)}: #{last_error.class}: #{last_error.message}",
|
|
227
227
|
host: label, retryable: false, attempts: attempts)
|
|
228
228
|
end
|
|
229
229
|
|
|
230
|
-
def summary = "network error after #{attempts}
|
|
230
|
+
def summary = "network error after #{self.class.count(attempts)} (host #{host}: #{last_error.class})"
|
|
231
|
+
|
|
232
|
+
# The message without its leading "host ": "request failed after …".
|
|
233
|
+
def detail = host ? message.delete_prefix("#{host} ") : message
|
|
234
|
+
|
|
235
|
+
# "1 attempt", "3 attempts".
|
|
236
|
+
def self.count(attempts) = "#{attempts} attempt#{"s" unless attempts == 1}"
|
|
231
237
|
end
|
|
232
238
|
|
|
233
239
|
# A refused connection: nothing listens at the host's address. Not
|
|
@@ -9,13 +9,14 @@ module Samagotchi
|
|
|
9
9
|
module ModelCatalog
|
|
10
10
|
# host: the host that listed (or declares) the id; ref: the name
|
|
11
11
|
# `--model` takes for it (see .spell); configured: declared under
|
|
12
|
-
# hosts.<name>.models (HostModel.rows)
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
# hosts.<name>.models (HostModel.rows); unavailable: a declared id of a
|
|
13
|
+
# host that failed to list its models (still pickable).
|
|
14
|
+
Row = Data.define(:host, :id, :ref, :configured, :unavailable) do
|
|
15
|
+
def initialize(host:, id:, ref:, configured: false, unavailable: false) = super
|
|
15
16
|
end
|
|
16
17
|
# rows: the default host's first, then the others by name, each host's
|
|
17
|
-
# declared ids first (HostModel.rows
|
|
18
|
-
# "host: error" per host that failed.
|
|
18
|
+
# declared ids first (HostModel.rows; a host that failed keeps only
|
|
19
|
+
# those); warnings: "host: error" per host that failed.
|
|
19
20
|
Listing = Data.define(:rows, :warnings)
|
|
20
21
|
|
|
21
22
|
module_function
|
|
@@ -29,15 +30,13 @@ module Samagotchi
|
|
|
29
30
|
warnings = []
|
|
30
31
|
host_rows = HostModel.rows(results, registry.entries)
|
|
31
32
|
results.keys.sort_by { |name| [name == default_host ? 0 : 1, name] }.each do |host|
|
|
32
|
-
if results[host][:error]
|
|
33
|
-
|
|
34
|
-
next
|
|
35
|
-
end
|
|
36
|
-
host_rows[host].each do |row|
|
|
33
|
+
warnings << "#{host}: #{results[host][:error]}" if results[host][:error]
|
|
34
|
+
host_rows.fetch(host, []).each do |row|
|
|
37
35
|
id = row.id
|
|
38
36
|
next if id.strip.empty? || (!row.configured && id.end_with?(":batch"))
|
|
39
37
|
|
|
40
|
-
rows << Row.new(host: host, id: id, ref: spell(host, id, default_host), configured: row.configured
|
|
38
|
+
rows << Row.new(host: host, id: id, ref: spell(host, id, default_host), configured: row.configured,
|
|
39
|
+
unavailable: row.unavailable)
|
|
41
40
|
end
|
|
42
41
|
end
|
|
43
42
|
Listing.new(rows: rows, warnings: warnings)
|
|
@@ -55,7 +54,8 @@ module Samagotchi
|
|
|
55
54
|
# go first (its rows are the "default host" group). default: the default's listed
|
|
56
55
|
# row name when it resolves to one, else its resolved ref; default_typed:
|
|
57
56
|
# the alias it was typed as. A row whose name resolves elsewhere (an
|
|
58
|
-
# alias named like the id shadows it) carries shadowed_by
|
|
57
|
+
# alias named like the id shadows it) carries shadowed_by; a declared id
|
|
58
|
+
# of a host that failed to list carries unavailable.
|
|
59
59
|
# @param default_name [String, nil] default.model as configured
|
|
60
60
|
# @return [Hash]
|
|
61
61
|
def payload(results, registry:, default_name:)
|
|
@@ -63,6 +63,7 @@ module Samagotchi
|
|
|
63
63
|
models = listing.rows.map do |row|
|
|
64
64
|
hash = { name: row.ref, host: row.host, id: row.id }
|
|
65
65
|
hash[:configured] = true if row.configured
|
|
66
|
+
hash[:unavailable] = true if row.unavailable
|
|
66
67
|
shadow = shadowed_by(row, registry)
|
|
67
68
|
shadow ? hash.merge(shadowed_by: shadow) : hash
|
|
68
69
|
end
|
|
@@ -232,10 +232,11 @@ module Samagotchi
|
|
|
232
232
|
return unknown_model_message(parsed.id, host, list.ids) if list.age < RELIST_AFTER_SECONDS
|
|
233
233
|
|
|
234
234
|
fresh_ids = relist_or_nil(relist || default_relist(hosts), host, env)
|
|
235
|
-
# A re-list that answered: the saved list catches up
|
|
236
|
-
# re-list, HostRegistry#list_models, saved it already
|
|
235
|
+
# A re-list that answered: the saved list catches up. The default
|
|
236
|
+
# re-list, HostRegistry#list_models, saved it already; a given one
|
|
237
|
+
# is saved here.
|
|
237
238
|
if fresh_ids
|
|
238
|
-
Samagotchi::ModelListStore.save(host, fresh_ids, env: env)
|
|
239
|
+
Samagotchi::ModelListStore.save(host, fresh_ids, env: env) if relist
|
|
239
240
|
return nil if fresh_ids.any? { |id| id.to_s.casecmp?(parsed.id.to_s.strip) }
|
|
240
241
|
|
|
241
242
|
return unknown_model_message(parsed.id, host, fresh_ids)
|
|
@@ -23,13 +23,14 @@ module Samagotchi
|
|
|
23
23
|
Lists the models every configured host offers, as the names
|
|
24
24
|
--model takes: the default first, a default-host id bare, another
|
|
25
25
|
host's as host:id, then the aliases as "name -> ref". A host that
|
|
26
|
-
fails or doesn't answer in time is noted on stderr
|
|
26
|
+
fails or doesn't answer in time is noted on stderr; the ids
|
|
27
|
+
declared under its hosts.<name>.models are still listed.
|
|
27
28
|
TEXT only the names containing it (any case)
|
|
28
29
|
--format json the default, the models (host, id, name), the aliases
|
|
29
30
|
and the warnings as one JSON object
|
|
30
31
|
--timeout S wait at most S seconds for the hosts; default 4
|
|
31
32
|
Exit 0 when any host listed its models, 1 when none did (the
|
|
32
|
-
default
|
|
33
|
+
default and declared names still print), 2 on a usage error.
|
|
33
34
|
TEXT
|
|
34
35
|
|
|
35
36
|
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
|
|
@@ -59,8 +60,10 @@ module Samagotchi
|
|
|
59
60
|
return usage_error("--timeout takes a number of seconds above 0") unless timeout&.positive?
|
|
60
61
|
|
|
61
62
|
payload, listed = catalog(timeout)
|
|
63
|
+
# Before the filter: a down host's declared ids are listed even when TEXT hides them.
|
|
64
|
+
down_hosts = payload[:models].select { |m| m[:unavailable] }.map { |m| m[:host] }.uniq
|
|
62
65
|
payload = filtered(payload, parsed.args.join(" ").strip)
|
|
63
|
-
options[:format] == "json" ? print_json(payload) : print_text(payload)
|
|
66
|
+
options[:format] == "json" ? print_json(payload) : print_text(payload, down_hosts)
|
|
64
67
|
listed ? 0 : 1
|
|
65
68
|
end
|
|
66
69
|
|
|
@@ -99,14 +102,18 @@ module Samagotchi
|
|
|
99
102
|
@stdout.puts(JSON.generate(payload.except(:filter)))
|
|
100
103
|
end
|
|
101
104
|
|
|
102
|
-
|
|
105
|
+
# @param down_hosts [Array<String>] hosts that failed but declare ids
|
|
106
|
+
def print_text(payload, down_hosts)
|
|
103
107
|
default = payload[:default]
|
|
104
108
|
names = payload[:models].reject { |m| m[:shadowed_by] }.map { |m| m[:name] }
|
|
105
109
|
shown_default = default && (payload[:filter].nil? || default.downcase.include?(payload[:filter].downcase))
|
|
106
110
|
@stdout.puts(default) if shown_default
|
|
107
111
|
(names - [default]).each { |name| @stdout.puts(name) }
|
|
108
112
|
payload[:aliases].each { |a| @stdout.puts("#{a[:name]} -> #{a[:ref]}") }
|
|
109
|
-
payload[:warnings].each
|
|
113
|
+
payload[:warnings].each do |w|
|
|
114
|
+
kept = down_hosts.any? { |host| w.start_with?("#{host}: ") }
|
|
115
|
+
@stderr.puts("chi models: #{w}#{" (its declared ids are still listed)" if kept}")
|
|
116
|
+
end
|
|
110
117
|
end
|
|
111
118
|
end
|
|
112
119
|
end
|
|
@@ -64,6 +64,13 @@ module Samagotchi
|
|
|
64
64
|
"or anything else that changes files or state. Finish your reply with what you found " \
|
|
65
65
|
"and what you would do, and wait."
|
|
66
66
|
|
|
67
|
+
# The cancel reason of a question no one could answer: a
|
|
68
|
+
# --non-interactive run has no one to ask (TerminalUI).
|
|
69
|
+
UNANSWERABLE_REASON = "non_interactive"
|
|
70
|
+
UNANSWERABLE_NOTE = "No one could answer the question: this is a non-interactive run. Don't do what you " \
|
|
71
|
+
"asked about, or anything else that changes files or state. Finish your reply with " \
|
|
72
|
+
"what you found and what you would do."
|
|
73
|
+
|
|
67
74
|
# @param session [#call] → Session, nil
|
|
68
75
|
# @param state_dir [#call] → String, the session's state dir
|
|
69
76
|
# @param emit [#call] (event) → emits an event to the turn sink + observers
|
|
@@ -82,6 +89,8 @@ module Samagotchi
|
|
|
82
89
|
@cv = @lock.new_cond
|
|
83
90
|
@pending = nil
|
|
84
91
|
@answer = nil
|
|
92
|
+
# Why the open question was cancelled (#cancel), for the sync path.
|
|
93
|
+
@cancel_reason = nil
|
|
85
94
|
@sync_handler = nil
|
|
86
95
|
# The standing question while it is @pending: {id:, fields:,
|
|
87
96
|
# on_answer:, on_superseded_close:}; @shelved, one superseded by (or
|
|
@@ -106,7 +115,10 @@ module Samagotchi
|
|
|
106
115
|
.merge(multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform]))
|
|
107
116
|
# Dismissed (the card's dismiss, Esc): an answer of its own, not a
|
|
108
117
|
# tool failure the model learns to avoid the tool from.
|
|
109
|
-
|
|
118
|
+
if result.is_a?(Hash) && result[:error] == "no answer"
|
|
119
|
+
note = result[:reason] == UNANSWERABLE_REASON ? UNANSWERABLE_NOTE : DISMISSED_NOTE
|
|
120
|
+
result = { dismissed: true, id: result[:id], note: note }
|
|
121
|
+
end
|
|
110
122
|
# Who answered is for the relay, not the model's tool result.
|
|
111
123
|
result = result.except(:by) if result.is_a?(Hash)
|
|
112
124
|
result.is_a?(String) ? result : JSON.generate(result)
|
|
@@ -213,7 +225,15 @@ module Samagotchi
|
|
|
213
225
|
emit({ type: :question_answered, id: id, answer: ans })
|
|
214
226
|
return ans
|
|
215
227
|
end
|
|
216
|
-
|
|
228
|
+
if sync_res.is_a?(String) && !sync_res.strip.empty?
|
|
229
|
+
# The handler's text is the answer: nothing stays pending, and
|
|
230
|
+
# every UI hears it was answered (the text as its freeform).
|
|
231
|
+
@lock.synchronize { @pending = nil }
|
|
232
|
+
clear_saved_question
|
|
233
|
+
emit({ type: :question_answered, id: id,
|
|
234
|
+
answer: { id: id.to_s, selected: [], freeform: sync_res.strip, selected_indices: [] } })
|
|
235
|
+
return sync_res
|
|
236
|
+
end
|
|
217
237
|
rescue StandardError => e
|
|
218
238
|
Log.warn(:turn, "question_handler_failed", echo: "[ask_user_question] sync handler failed: #{e.message}", error: e.class.name)
|
|
219
239
|
end
|
|
@@ -221,9 +241,12 @@ module Samagotchi
|
|
|
221
241
|
# CV (no cross-thread answerer exists for synchronous UIs). Clear pending
|
|
222
242
|
# and return an error so the model can fallback to plain text. Generic
|
|
223
243
|
# observers will discard the stale question_requested via staleness check.
|
|
224
|
-
@lock.synchronize
|
|
244
|
+
reason = @lock.synchronize do
|
|
245
|
+
@pending = nil
|
|
246
|
+
@cancel_reason
|
|
247
|
+
end
|
|
225
248
|
clear_saved_question
|
|
226
|
-
return { error: "no answer", detail: "handler failed to capture selection", id: id }
|
|
249
|
+
return { error: "no answer", detail: "handler failed to capture selection", id: id, reason: reason }.compact
|
|
227
250
|
end
|
|
228
251
|
|
|
229
252
|
# Block until answered/cancelled (cross-thread path: WEB/Bridge/background worker)
|
|
@@ -356,6 +379,7 @@ module Samagotchi
|
|
|
356
379
|
next if @standing
|
|
357
380
|
|
|
358
381
|
pending[:status] = "cancelled"
|
|
382
|
+
@cancel_reason = reason.to_s
|
|
359
383
|
@cv.broadcast
|
|
360
384
|
pending[:id]
|
|
361
385
|
end
|
|
@@ -403,6 +427,7 @@ module Samagotchi
|
|
|
403
427
|
@lock.synchronize do
|
|
404
428
|
@pending = pending
|
|
405
429
|
@answer = nil
|
|
430
|
+
@cancel_reason = nil
|
|
406
431
|
@standing = standing&.merge(id: pending[:id])
|
|
407
432
|
end
|
|
408
433
|
if session
|
|
@@ -4,6 +4,7 @@ require_relative "client_id"
|
|
|
4
4
|
require_relative "session"
|
|
5
5
|
require_relative "session_inbox"
|
|
6
6
|
require_relative "session_manager"
|
|
7
|
+
require_relative "session_chain"
|
|
7
8
|
require_relative "context_quote"
|
|
8
9
|
require_relative "reply_wait"
|
|
9
10
|
require_relative "parent_report"
|
|
@@ -34,6 +35,7 @@ module Samagotchi
|
|
|
34
35
|
USAGE = <<~TEXT
|
|
35
36
|
Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
|
|
36
37
|
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
|
|
38
|
+
chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
|
|
37
39
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
38
40
|
chi send --wait [--timeout S] [--format json] ID
|
|
39
41
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
@@ -52,6 +54,14 @@ module Samagotchi
|
|
|
52
54
|
web does, and print its id
|
|
53
55
|
--dir DIR (--new) its folder, the project it belongs to; default
|
|
54
56
|
the current one
|
|
57
|
+
--continues ID
|
|
58
|
+
(--new) start the next link of that session's chain:
|
|
59
|
+
in its folder, on its model and LLM context, with a
|
|
60
|
+
note carrying its recap; it is archived. last:ID
|
|
61
|
+
names the chain's latest link. Without -m the new
|
|
62
|
+
session waits idle. Refused for a session continued
|
|
63
|
+
already (naming the next link) or with delegates
|
|
64
|
+
still open
|
|
55
65
|
--model M (--new) its model; default the configured one
|
|
56
66
|
--llm-context LAYERS, --llm-context-apply RULE, --llm-context-budget N
|
|
57
67
|
(--new) its own LLM context strategy (none, stale,forget),
|
|
@@ -83,6 +93,7 @@ module Samagotchi
|
|
|
83
93
|
f.value "-m", "--message"
|
|
84
94
|
f.switch "--new"
|
|
85
95
|
f.value "--dir"
|
|
96
|
+
f.value "--continues"
|
|
86
97
|
f.value "--model"
|
|
87
98
|
f.value "--llm-context", key: :llm_strategy
|
|
88
99
|
f.value "--llm-context-apply", key: :llm_apply
|
|
@@ -152,10 +163,14 @@ module Samagotchi
|
|
|
152
163
|
# Before the wait-only case, which would drop the images.
|
|
153
164
|
return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
|
|
154
165
|
return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
|
|
155
|
-
|
|
166
|
+
|
|
167
|
+
# The next link of a chain may start idle, waiting for its first message.
|
|
168
|
+
idle_link = prompt.nil? && options[:continues]
|
|
169
|
+
return usage_error("--wait needs a message to wait for: pass -m TEXT") if idle_link && options[:wait]
|
|
170
|
+
return usage_error("no message: pass -m TEXT or pipe it in") unless prompt || idle_link
|
|
156
171
|
|
|
157
172
|
begin
|
|
158
|
-
prompt
|
|
173
|
+
prompt &&= SessionInbox.checked_text(prompt, noun: "message")
|
|
159
174
|
rescue SessionInbox::NoteRejected => e
|
|
160
175
|
@stderr.puts("chi send: #{e.message}")
|
|
161
176
|
return 1
|
|
@@ -205,7 +220,7 @@ module Samagotchi
|
|
|
205
220
|
return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
|
|
206
221
|
return new_options(options) if options[:new]
|
|
207
222
|
|
|
208
|
-
%i[dir model].each { |key| return usage_error("--#{key} needs --new") if options[key] }
|
|
223
|
+
%i[dir model continues].each { |key| return usage_error("--#{key} needs --new") if options[key] }
|
|
209
224
|
LLM_CONTEXT_FLAGS.each { |key, flag| return usage_error("#{flag} needs --new") if options[key] }
|
|
210
225
|
return usage_error("give session ids") if options[:ids].empty?
|
|
211
226
|
|
|
@@ -220,6 +235,15 @@ module Samagotchi
|
|
|
220
235
|
def new_options(options)
|
|
221
236
|
return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
|
|
222
237
|
|
|
238
|
+
if options[:continues]
|
|
239
|
+
taken = [("--dir" if options[:dir]), ("--model" if options[:model])] +
|
|
240
|
+
LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] }
|
|
241
|
+
unless taken.compact.empty?
|
|
242
|
+
return usage_error("--continues takes the previous session's folder, model and LLM context; " \
|
|
243
|
+
"leave out #{taken.compact.join(", ")}")
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
223
247
|
words = LLM_CONTEXT_FIELDS.filter_map { |key, field| [field, options[key]] if options[key] }.to_h
|
|
224
248
|
begin
|
|
225
249
|
options[:llm_context] = LLMContextOverride.update(nil, words) unless words.empty?
|
|
@@ -253,7 +277,10 @@ module Samagotchi
|
|
|
253
277
|
# @return [Integer] the exit status
|
|
254
278
|
def run_new(prompt, options)
|
|
255
279
|
images = !@images.empty?
|
|
256
|
-
|
|
280
|
+
model, typed = continued_model(options)
|
|
281
|
+
return 1 if model == false
|
|
282
|
+
|
|
283
|
+
if images && (refusal = vision_refusal(model, typed: typed))
|
|
257
284
|
error_line("chi send: refused: #{refusal}")
|
|
258
285
|
return 1
|
|
259
286
|
end
|
|
@@ -264,9 +291,16 @@ module Samagotchi
|
|
|
264
291
|
through_worker = images || command_like?(prompt)
|
|
265
292
|
begin
|
|
266
293
|
start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
|
|
267
|
-
session =
|
|
268
|
-
|
|
269
|
-
|
|
294
|
+
session = if options[:continues]
|
|
295
|
+
SessionManager.continue_session(options[:continues], **start, state_dir: @state_dir)
|
|
296
|
+
else
|
|
297
|
+
SessionManager.spawn_session(**start, working_directory: options[:dir],
|
|
298
|
+
model_name: options[:model], llm_context: options[:llm_context],
|
|
299
|
+
state_dir: @state_dir)
|
|
300
|
+
end
|
|
301
|
+
rescue SessionManager::ContinueRefused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
|
|
302
|
+
error_line("chi send: refused: #{e.message}")
|
|
303
|
+
return 1
|
|
270
304
|
rescue StandardError => e
|
|
271
305
|
error_line("chi send: could not start a session: #{e.message}")
|
|
272
306
|
return 1
|
|
@@ -275,7 +309,7 @@ module Samagotchi
|
|
|
275
309
|
if through_worker
|
|
276
310
|
return 1 unless deliver_new(session, prompt)
|
|
277
311
|
else
|
|
278
|
-
@info.puts("#{session.id} started")
|
|
312
|
+
@info.puts("#{session.id} started#{continued_words(session)}")
|
|
279
313
|
end
|
|
280
314
|
@session_id = session.id
|
|
281
315
|
return 0 unless options[:wait]
|
|
@@ -285,6 +319,22 @@ module Samagotchi
|
|
|
285
319
|
timeout: options[:timeout])
|
|
286
320
|
end
|
|
287
321
|
|
|
322
|
+
# --continues: the model the new link will run (the previous link's),
|
|
323
|
+
# for the images check; [nil, nil] without --continues.
|
|
324
|
+
# @return [Array, false] [model_name, model_typed], or false after the
|
|
325
|
+
# error line (no such session)
|
|
326
|
+
def continued_model(options)
|
|
327
|
+
return [options[:model], nil] unless options[:continues]
|
|
328
|
+
|
|
329
|
+
previous = Session.load(SessionChain.resolve(options[:continues], state_dir: @state_dir), state_dir: @state_dir)
|
|
330
|
+
[previous.model_name, previous.model_typed]
|
|
331
|
+
rescue ArgumentError => e
|
|
332
|
+
error_line("chi send: #{e.message}")
|
|
333
|
+
false
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def continued_words(session) = session.continues ? " (continues #{session.continues[0, 8]})" : ""
|
|
337
|
+
|
|
288
338
|
# One existing session, then its next reply. The cursor and baseline
|
|
289
339
|
# are taken before the message goes in, so neither an older reply nor a
|
|
290
340
|
# turn that ends before the first look is mistaken for the answer. A
|
|
@@ -371,7 +421,7 @@ module Samagotchi
|
|
|
371
421
|
end
|
|
372
422
|
|
|
373
423
|
@sent_command = command_ack?(result[:ack])
|
|
374
|
-
@info.puts("#{session.id} started#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
|
|
424
|
+
@info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
|
|
375
425
|
true
|
|
376
426
|
end
|
|
377
427
|
|
|
@@ -17,6 +17,15 @@ module Samagotchi
|
|
|
17
17
|
name.is_a?(String) && !name.strip.empty? ? name : nil
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# The host whose hosts.<name>.models.<id>.served names +served+ for
|
|
21
|
+
# +target+'s model (a gateway's round-robin targets): not a mismatch to
|
|
22
|
+
# warn about. nil when none does.
|
|
23
|
+
# @param target [HostRegistry::ModelTarget]
|
|
24
|
+
# @return [String, nil]
|
|
25
|
+
def expected_by(target, served)
|
|
26
|
+
target.entry.name if target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.serves?(served)
|
|
27
|
+
end
|
|
28
|
+
|
|
20
29
|
# The separators a provider uses when it decorates a model name: a tag
|
|
21
30
|
# (`qwen3:latest`), a date or quant suffix (`gpt-4o-2024-08-06`), an
|
|
22
31
|
# owner (`@org/name`) or a path (`meta-llama/Llama-3`). Anything else
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -72,6 +72,11 @@ module Samagotchi
|
|
|
72
72
|
# Started by the `delegate` tool (a fork has a parent_id too, but isn't
|
|
73
73
|
# one): see #delegate?.
|
|
74
74
|
attr_writer :delegate
|
|
75
|
+
# The session this one continues (SessionManager.continue_session): the
|
|
76
|
+
# previous link of a chain, "the next day of the same routine", else
|
|
77
|
+
# nil. Not a parent: archiving one link leaves the next alone. The chain
|
|
78
|
+
# itself is derived (SessionChain), never stored.
|
|
79
|
+
attr_accessor :continues
|
|
75
80
|
# A `chi scratch` session: deleted when its REPL ends, and by the next
|
|
76
81
|
# sweep (or `chi sessions clean`) when the process died first.
|
|
77
82
|
attr_accessor :scratch
|
|
@@ -130,7 +135,8 @@ module Samagotchi
|
|
|
130
135
|
first_preview: "", test_run: false, pending_question: nil,
|
|
131
136
|
used_memory_names: [], project_root: nil,
|
|
132
137
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
133
|
-
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: []
|
|
138
|
+
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
139
|
+
continues: nil)
|
|
134
140
|
@id = id
|
|
135
141
|
@metadata_version = metadata_version
|
|
136
142
|
@mode = mode
|
|
@@ -154,6 +160,7 @@ module Samagotchi
|
|
|
154
160
|
@scratch = !!scratch
|
|
155
161
|
@last_turn = last_turn
|
|
156
162
|
@delegate = !!delegate
|
|
163
|
+
@continues = continues&.to_s
|
|
157
164
|
self.llm_context = llm_context
|
|
158
165
|
@archived = false
|
|
159
166
|
end
|
|
@@ -205,7 +212,7 @@ module Samagotchi
|
|
|
205
212
|
# seed); [] by default
|
|
206
213
|
def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
|
|
207
214
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
|
|
208
|
-
scratch: false, model_typed: nil, delegate: false, llm_context: nil)
|
|
215
|
+
scratch: false, model_typed: nil, delegate: false, llm_context: nil, continues: nil)
|
|
209
216
|
now = Time.now.iso8601(3)
|
|
210
217
|
resolved_test = if test_run.nil?
|
|
211
218
|
test_session_env?
|
|
@@ -230,7 +237,8 @@ module Samagotchi
|
|
|
230
237
|
parent_id: parent_id,
|
|
231
238
|
scratch: scratch,
|
|
232
239
|
delegate: delegate,
|
|
233
|
-
llm_context: llm_context
|
|
240
|
+
llm_context: llm_context,
|
|
241
|
+
continues: continues
|
|
234
242
|
)
|
|
235
243
|
end
|
|
236
244
|
|
|
@@ -287,7 +295,8 @@ module Samagotchi
|
|
|
287
295
|
"scratch" => false,
|
|
288
296
|
"last_turn" => nil,
|
|
289
297
|
"delegate" => false,
|
|
290
|
-
"llm_context" => nil
|
|
298
|
+
"llm_context" => nil,
|
|
299
|
+
"continues" => nil
|
|
291
300
|
}.freeze
|
|
292
301
|
|
|
293
302
|
# A session from a parsed session file. +messages+ false leaves the
|