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
data/lib/samagotchi/llm/usage.rb
CHANGED
|
@@ -4,8 +4,8 @@ require_relative "../token_usage"
|
|
|
4
4
|
|
|
5
5
|
module Samagotchi
|
|
6
6
|
module LLM
|
|
7
|
-
# A
|
|
8
|
-
#
|
|
7
|
+
# A response's token counts, never nil: what the server reported
|
|
8
|
+
# (:server), else zeros (:none).
|
|
9
9
|
Usage = Data.define(:prompt_tokens, :completion_tokens, :source) do
|
|
10
10
|
# @return [Usage, nil] nil when the payload carries no counts
|
|
11
11
|
def self.from_payload(payload)
|
|
@@ -16,64 +16,9 @@ module Samagotchi
|
|
|
16
16
|
source: :server)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
# +extra_prompt_tokens+: what the prompt holds besides text (images).
|
|
20
|
-
def self.estimate(prompt_text:, completion_text:, extra_prompt_tokens: 0)
|
|
21
|
-
new(prompt_tokens: TokenUsage.estimate(prompt_text.to_s) + extra_prompt_tokens.to_i,
|
|
22
|
-
completion_tokens: TokenUsage.estimate(completion_text.to_s),
|
|
23
|
-
source: :estimate)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
19
|
def self.none = new(prompt_tokens: 0, completion_tokens: 0, source: :none)
|
|
27
20
|
|
|
28
21
|
def total_tokens = prompt_tokens + completion_tokens
|
|
29
22
|
end
|
|
30
|
-
|
|
31
|
-
# Builds a turn's Usage from its stream events, the way SessionMetrics
|
|
32
|
-
# counts them: server counts are cumulative per request, so each
|
|
33
|
-
# generation keeps its highest; the prompt grows across a turn's
|
|
34
|
-
# generations, so the last generation's prompt counts, and completions
|
|
35
|
-
# add up. Without server counts, the streamed text is estimated.
|
|
36
|
-
class UsageCollector
|
|
37
|
-
def initialize
|
|
38
|
-
@generations = []
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def observe(event)
|
|
42
|
-
case event[:type]
|
|
43
|
-
when :generation_started
|
|
44
|
-
@generations << { prompt: 0, completion: 0, server: false, text: +"" }
|
|
45
|
-
when :generation_chunk
|
|
46
|
-
@generations << { prompt: 0, completion: 0, server: false, text: +"" } if @generations.empty?
|
|
47
|
-
record(@generations.last, event)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# @param prompt_text [String, nil] what an estimate counts as the prompt
|
|
52
|
-
# @param extra_prompt_tokens [Integer] estimated non-text prompt tokens
|
|
53
|
-
def usage(prompt_text: nil, extra_prompt_tokens: 0)
|
|
54
|
-
server = @generations.select { |generation| generation[:server] }
|
|
55
|
-
unless server.empty?
|
|
56
|
-
return Usage.new(prompt_tokens: server.last[:prompt], completion_tokens: server.sum { |g| g[:completion] },
|
|
57
|
-
source: :server)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
text = @generations.sum("") { |generation| generation[:text] }
|
|
61
|
-
return Usage.none if text.empty?
|
|
62
|
-
|
|
63
|
-
Usage.estimate(prompt_text: prompt_text, completion_text: text, extra_prompt_tokens: extra_prompt_tokens)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
private
|
|
67
|
-
|
|
68
|
-
def record(generation, event)
|
|
69
|
-
counts = Usage.from_payload(event[:payload])
|
|
70
|
-
if counts
|
|
71
|
-
generation[:server] = true
|
|
72
|
-
generation[:prompt] = [generation[:prompt], counts.prompt_tokens].max
|
|
73
|
-
generation[:completion] = [generation[:completion], counts.completion_tokens].max
|
|
74
|
-
end
|
|
75
|
-
generation[:text] << event[:content].to_s
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
23
|
end
|
|
79
24
|
end
|
|
@@ -265,6 +265,7 @@ module Samagotchi
|
|
|
265
265
|
# ── Plugin: <file> into <bundle_dir>/plugin/ (docs/plugins.md) ────
|
|
266
266
|
# Like the rules, the bundle's plugin replaces an earlier one.
|
|
267
267
|
plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
|
|
268
|
+
warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
|
|
268
269
|
|
|
269
270
|
# Files the previous version shipped and this one doesn't.
|
|
270
271
|
if @upgrade && existing_provenance && existing_provenance[:files]
|
|
@@ -365,7 +366,7 @@ module Samagotchi
|
|
|
365
366
|
version: manifest.version,
|
|
366
367
|
source_path: @source,
|
|
367
368
|
hooks: hooks_for_provenance,
|
|
368
|
-
trust_level: manifest.
|
|
369
|
+
trust_level: manifest.trust_level,
|
|
369
370
|
source_commit: source_commit,
|
|
370
371
|
hooks_files: hooks_files_for_provenance,
|
|
371
372
|
guardrails_files: guardrail_files_for_provenance,
|
|
@@ -467,6 +468,17 @@ module Samagotchi
|
|
|
467
468
|
end
|
|
468
469
|
end
|
|
469
470
|
|
|
471
|
+
# The hook loader skips every hook of a bundle whose requires_chi this
|
|
472
|
+
# chi doesn't meet (Hooks::BundleLoader); say so at install, as
|
|
473
|
+
# install_plugin does for the plugin.
|
|
474
|
+
def warn_hooks_requires_chi(manifest, hook_files)
|
|
475
|
+
return if manifest.nil? || hook_files.empty?
|
|
476
|
+
|
|
477
|
+
if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
|
|
478
|
+
@warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
470
482
|
# Copy the manifest's plugin file into the bundle's plugin/ dir,
|
|
471
483
|
# warning when its sha256 differs from the declared one or this chi
|
|
472
484
|
# doesn't meet requires_chi (the Engine then won't load it).
|
|
@@ -43,7 +43,15 @@ module Samagotchi
|
|
|
43
43
|
}
|
|
44
44
|
end
|
|
45
45
|
{ provenance: data, scope: scope, target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
|
|
46
|
-
needs: needs_status(data) }
|
|
46
|
+
hooks_requires_failure: hooks_requires_failure(data), needs: needs_status(data) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Why none of the bundle's hooks load (this chi doesn't meet its
|
|
50
|
+
# requires_chi, Hooks::BundleLoader), or nil: it has no hooks or meets it.
|
|
51
|
+
def self.hooks_requires_failure(data)
|
|
52
|
+
return nil if (data[:hooks] || {}).empty?
|
|
53
|
+
|
|
54
|
+
Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION)
|
|
47
55
|
end
|
|
48
56
|
|
|
49
57
|
# The stored needs, each with found: from this process's PATH (the
|
|
@@ -51,6 +51,14 @@ module Samagotchi
|
|
|
51
51
|
File.basename(common) == ".git" ? File.dirname(common) : common
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# The top of the git work tree holding +cwd+: the directory with the
|
|
55
|
+
# nearest .git (a linked worktree's own checkout, unlike #project_root);
|
|
56
|
+
# nil outside any repository.
|
|
57
|
+
def work_tree_root(cwd = Dir.pwd)
|
|
58
|
+
dot_git = find_dot_git(cwd)
|
|
59
|
+
dot_git && File.dirname(dot_git)
|
|
60
|
+
end
|
|
61
|
+
|
|
54
62
|
# Is +cwd+ inside a git repository (a .git somewhere above it)?
|
|
55
63
|
def in_repo?(cwd = Dir.pwd)
|
|
56
64
|
!find_dot_git(cwd).nil?
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "host_registry"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# The hosts' model listings (HostRegistry#list_all_models) as rows a user
|
|
7
|
+
# picks from: GET /api/models and `chi models`. Every name question goes
|
|
8
|
+
# to ModelRef / HostRegistry#resolve; nothing here parses a ref.
|
|
9
|
+
module ModelCatalog
|
|
10
|
+
# host: the host that listed the id; ref: the name `--model` takes for
|
|
11
|
+
# it (see .spell).
|
|
12
|
+
Row = Data.define(:host, :id, :ref)
|
|
13
|
+
# rows: the default host's first, then the others by name; warnings:
|
|
14
|
+
# "host: error" per host that failed.
|
|
15
|
+
Listing = Data.define(:rows, :warnings)
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# @param results [Hash] list_all_models' answer
|
|
20
|
+
# @param registry [#default_entry]
|
|
21
|
+
# @return [Listing]
|
|
22
|
+
def listing(results, registry:)
|
|
23
|
+
default_host = registry.default_entry&.name
|
|
24
|
+
rows = []
|
|
25
|
+
warnings = []
|
|
26
|
+
results.keys.sort_by { |name| [name == default_host ? 0 : 1, name] }.each do |host|
|
|
27
|
+
data = results[host]
|
|
28
|
+
if data[:error]
|
|
29
|
+
warnings << "#{host}: #{data[:error]}"
|
|
30
|
+
next
|
|
31
|
+
end
|
|
32
|
+
Array(data[:models]).each do |info|
|
|
33
|
+
id = info.id.to_s
|
|
34
|
+
next if id.strip.empty? || id.end_with?(":batch")
|
|
35
|
+
|
|
36
|
+
rows << Row.new(host: host, id: id, ref: spell(host, id, default_host))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
Listing.new(rows: rows, warnings: warnings)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A listed id as a ref that routes back to its host: bare on the
|
|
43
|
+
# default host (a bare id goes there first), "host:id" elsewhere and for
|
|
44
|
+
# a default-host id with a ':' (bare, "qwen3:8b" would name a host qwen3).
|
|
45
|
+
# '/' doesn't name a host, so "openai/gpt-4o" stays bare.
|
|
46
|
+
def spell(host, id, default_host)
|
|
47
|
+
host == default_host && !id.include?(":") ? id : "#{host}:#{id}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# `chi models --format json`'s object. default_host: where bare names
|
|
51
|
+
# go first (its rows are the "default host" group). default: the default's listed
|
|
52
|
+
# row name when it resolves to one, else its resolved ref; default_typed:
|
|
53
|
+
# the alias it was typed as. A row whose name resolves elsewhere (an
|
|
54
|
+
# alias named like the id shadows it) carries shadowed_by.
|
|
55
|
+
# @param default_name [String, nil] default.model as configured
|
|
56
|
+
# @return [Hash]
|
|
57
|
+
def payload(results, registry:, default_name:)
|
|
58
|
+
listing = listing(results, registry: registry)
|
|
59
|
+
models = listing.rows.map do |row|
|
|
60
|
+
hash = { name: row.ref, host: row.host, id: row.id }
|
|
61
|
+
shadow = shadowed_by(row, registry)
|
|
62
|
+
shadow ? hash.merge(shadowed_by: shadow) : hash
|
|
63
|
+
end
|
|
64
|
+
default, typed = default_for(default_name, listing.rows, registry)
|
|
65
|
+
{ default: default, default_typed: typed, default_host: registry.default_entry&.name, models: models,
|
|
66
|
+
aliases: aliases(registry), warnings: listing.warnings }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The alias (or the ref) that takes +row+'s name elsewhere, or nil.
|
|
70
|
+
def shadowed_by(row, registry)
|
|
71
|
+
target = registry.resolve(row.ref)
|
|
72
|
+
return nil if target.entry.name == row.host && target.bare_model == row.id
|
|
73
|
+
|
|
74
|
+
registry.model_ref(row.ref).alias_name || "#{target.entry.name}:#{target.bare_model}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def default_for(name, rows, registry)
|
|
78
|
+
return [nil, nil] if name.to_s.strip.empty?
|
|
79
|
+
|
|
80
|
+
ref = registry.model_ref(name)
|
|
81
|
+
target = registry.resolve(name)
|
|
82
|
+
row = rows.find { |r| r.host == target.entry.name && r.id.casecmp?(target.bare_model) }
|
|
83
|
+
[row ? row.ref : ref.ref, ref.alias_name ? name.to_s.strip : nil]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# config.yml's aliases, each under the host its target goes to: the
|
|
87
|
+
# default host's first, then by host and name.
|
|
88
|
+
def aliases(registry)
|
|
89
|
+
default_host = registry.default_entry&.name
|
|
90
|
+
names = begin
|
|
91
|
+
ConfigFile.model_aliases
|
|
92
|
+
rescue StandardError
|
|
93
|
+
{}
|
|
94
|
+
end
|
|
95
|
+
rows = names.keys.map do |name|
|
|
96
|
+
{ name: name, ref: registry.model_ref(name).ref, host: registry.resolve(name).entry.name }
|
|
97
|
+
end
|
|
98
|
+
rows.sort_by { |row| [row[:host] == default_host ? 0 : 1, row[:host], row[:name]] }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -18,11 +18,18 @@ module Samagotchi
|
|
|
18
18
|
# so every surface that reports a missing model reports it the same way).
|
|
19
19
|
UnknownHost = Class.new(MissingModel)
|
|
20
20
|
|
|
21
|
+
# Gemma 4 thinks in a channel: `<|channel>thought` … `<channel|>`. The
|
|
22
|
+
# one source for the parser, the stream splitter, the literal guard and
|
|
23
|
+
# the web's saved-message reader.
|
|
24
|
+
GEMMA_THOUGHT_CHANNEL_OPEN = "<|channel>thought"
|
|
25
|
+
GEMMA_THOUGHT_CHANNEL_CLOSE = "<channel|>"
|
|
26
|
+
|
|
21
27
|
attr_reader :name, :turn_start, :turn_end,
|
|
22
28
|
:tool_call_open, :tool_call_close,
|
|
23
29
|
:tool_response_open, :tool_response_close,
|
|
24
30
|
:string_delim,
|
|
25
31
|
:thought_open, :thought_close,
|
|
32
|
+
:thought_channel_open, :thought_channel_close,
|
|
26
33
|
:system_prefix, :user_prefix, :assistant_prefix,
|
|
27
34
|
:model_prefix,
|
|
28
35
|
:stop_sequences, :tool_decl_format,
|
|
@@ -39,6 +46,9 @@ module Samagotchi
|
|
|
39
46
|
@string_delim = config[:string_delim]
|
|
40
47
|
@thought_open = config[:thought_open]
|
|
41
48
|
@thought_close = config[:thought_close]
|
|
49
|
+
# A thinking block besides thought_open's (Gemma's channel), or nil.
|
|
50
|
+
@thought_channel_open = config[:thought_channel_open]
|
|
51
|
+
@thought_channel_close = config[:thought_channel_close]
|
|
42
52
|
@system_prefix = config[:system_prefix]
|
|
43
53
|
@user_prefix = config[:user_prefix]
|
|
44
54
|
@assistant_prefix = config[:assistant_prefix]
|
|
@@ -68,6 +78,8 @@ module Samagotchi
|
|
|
68
78
|
string_delim: '<|"|>',
|
|
69
79
|
thought_open: "<|think|>",
|
|
70
80
|
thought_close: nil,
|
|
81
|
+
thought_channel_open: GEMMA_THOUGHT_CHANNEL_OPEN,
|
|
82
|
+
thought_channel_close: GEMMA_THOUGHT_CHANNEL_CLOSE,
|
|
71
83
|
system_prefix: "",
|
|
72
84
|
user_prefix: "",
|
|
73
85
|
assistant_prefix: "",
|
|
@@ -131,8 +143,9 @@ module Samagotchi
|
|
|
131
143
|
# qualified with a host that isn't configured, instead of sending the
|
|
132
144
|
# whole ref to the default host as a model id, and when an alias after
|
|
133
145
|
# a host prefix names another host ("openrouter:tiny" with tiny:
|
|
134
|
-
# box:…)
|
|
135
|
-
# spawned session)
|
|
146
|
+
# box:…), or with a host config.yml has with enabled: false. Where a
|
|
147
|
+
# model comes in (an Engine starting or switching, a spawned session)
|
|
148
|
+
# calls it.
|
|
136
149
|
# @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
|
|
137
150
|
# entries); config.yml's by default
|
|
138
151
|
# @return [String] +model_name+
|
|
@@ -145,6 +158,9 @@ module Samagotchi
|
|
|
145
158
|
"use #{parsed.alias_name} or #{other}:#{parsed.alias_name}"
|
|
146
159
|
end
|
|
147
160
|
ref = parsed.ref
|
|
161
|
+
if !parsed.host_name && (disabled = disabled_host_prefix(ref, hosts, env))
|
|
162
|
+
raise UnknownHost, "host '#{disabled}' is disabled (enabled: false in config.yml)"
|
|
163
|
+
end
|
|
148
164
|
warn_host_slash(ref, hosts) unless parsed.host_name
|
|
149
165
|
host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
|
|
150
166
|
return model_name unless host
|
|
@@ -155,6 +171,18 @@ module Samagotchi
|
|
|
155
171
|
raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(', ')}"
|
|
156
172
|
end
|
|
157
173
|
|
|
174
|
+
# The prefix of "box:x" when box is a host config.yml has with
|
|
175
|
+
# enabled: false (left out of +hosts+), else nil.
|
|
176
|
+
def self.disabled_host_prefix(ref, hosts, env)
|
|
177
|
+
prefix, rest = ref.to_s.split(":", 2)
|
|
178
|
+
return nil if rest.to_s.strip.empty?
|
|
179
|
+
|
|
180
|
+
prefix = prefix.strip.downcase
|
|
181
|
+
return nil if hosts.keys.any? { |k| k.to_s.downcase == prefix }
|
|
182
|
+
|
|
183
|
+
Samagotchi::ConfigFile.disabled_host_names(env: env).include?(prefix) ? prefix : nil
|
|
184
|
+
end
|
|
185
|
+
|
|
158
186
|
# "box/x" named host box until '/' stopped naming a host: a saved
|
|
159
187
|
# session or a hand-written ref says so once, then goes to the default host.
|
|
160
188
|
def self.warn_host_slash(ref, hosts)
|
|
@@ -193,10 +221,6 @@ module Samagotchi
|
|
|
193
221
|
DEFAULT_NAME
|
|
194
222
|
end
|
|
195
223
|
|
|
196
|
-
def self.from_env
|
|
197
|
-
from_model_name(required_model_name(nil))
|
|
198
|
-
end
|
|
199
|
-
|
|
200
224
|
# ── Resolution: which profile a model gets ───────────────────────────
|
|
201
225
|
|
|
202
226
|
NAMES = %w[qwen36 gemma4].freeze
|
|
@@ -324,20 +348,10 @@ module Samagotchi
|
|
|
324
348
|
return false if client.nil? || !client.respond_to?(:server_props)
|
|
325
349
|
return false if entry&.chat?
|
|
326
350
|
|
|
327
|
-
|
|
351
|
+
client.transport.name == :llama_cpp
|
|
328
352
|
end
|
|
329
353
|
private_class_method :probe?
|
|
330
354
|
|
|
331
|
-
# ── Thought channel support ──────────────────────────────────────────
|
|
332
|
-
|
|
333
|
-
def thought_channel_open
|
|
334
|
-
nil
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
def uses_channel_thoughts?
|
|
338
|
-
false
|
|
339
|
-
end
|
|
340
|
-
|
|
341
355
|
def uses_role_prefixes?
|
|
342
356
|
!system_prefix.empty? && !user_prefix.empty?
|
|
343
357
|
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "host_registry"
|
|
5
|
+
require_relative "model_catalog"
|
|
6
|
+
require_relative "model_profile"
|
|
7
|
+
require_relative "cli/command"
|
|
8
|
+
require_relative "cli/flags"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# `chi models`: the names `--model` (and `chi send --new --model`) takes,
|
|
12
|
+
# listed anew from every host in parallel, within a bounded wait; the
|
|
13
|
+
# desktop helper's model picker reads --format json. No cache: each run
|
|
14
|
+
# lists every host (the registry's TTLs live in one process only).
|
|
15
|
+
class ModelsCommand
|
|
16
|
+
include CLI::Command
|
|
17
|
+
|
|
18
|
+
DEFAULT_TIMEOUT = 4.0
|
|
19
|
+
FORMATS = %w[text json].freeze
|
|
20
|
+
|
|
21
|
+
USAGE = <<~TEXT
|
|
22
|
+
Usage: chi models [--format text|json] [--timeout S] [TEXT]
|
|
23
|
+
Lists the models every configured host offers, as the names
|
|
24
|
+
--model takes: the default first, a default-host id bare, another
|
|
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.
|
|
27
|
+
TEXT only the names containing it (any case)
|
|
28
|
+
--format json the default, the models (host, id, name), the aliases
|
|
29
|
+
and the warnings as one JSON object
|
|
30
|
+
--timeout S wait at most S seconds for the hosts; default 4
|
|
31
|
+
Exit 0 when any host listed its models, 1 when none did (the
|
|
32
|
+
default is still printed), 2 on a usage error.
|
|
33
|
+
TEXT
|
|
34
|
+
|
|
35
|
+
FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
|
|
36
|
+
f.value "--format"
|
|
37
|
+
f.value "--timeout"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param registry [HostRegistry, nil] specs inject one
|
|
41
|
+
# @param default_name [String, nil, :config] default.model; :config reads it
|
|
42
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, registry: nil, default_name: :config)
|
|
43
|
+
@argv = argv.dup
|
|
44
|
+
@stdout = stdout
|
|
45
|
+
@stderr = stderr
|
|
46
|
+
@registry = registry
|
|
47
|
+
@default_name = default_name
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Integer] 0 any host listed, 1 none did, 2 usage
|
|
51
|
+
def run
|
|
52
|
+
parsed = parse_flags(FLAGS, @argv, format: "text", timeout: DEFAULT_TIMEOUT.to_s)
|
|
53
|
+
return parsed if parsed.is_a?(Integer)
|
|
54
|
+
|
|
55
|
+
options = parsed.options
|
|
56
|
+
return usage_error("--format takes text or json") unless FORMATS.include?(options[:format])
|
|
57
|
+
|
|
58
|
+
timeout = Float(options[:timeout], exception: false)
|
|
59
|
+
return usage_error("--timeout takes a number of seconds above 0") unless timeout&.positive?
|
|
60
|
+
|
|
61
|
+
payload, listed = catalog(timeout)
|
|
62
|
+
payload = filtered(payload, parsed.args.join(" ").strip)
|
|
63
|
+
options[:format] == "json" ? print_json(payload) : print_text(payload)
|
|
64
|
+
listed ? 0 : 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def command_name = "chi models"
|
|
70
|
+
|
|
71
|
+
# @return [Array(Hash, Boolean)] the payload and whether any host listed
|
|
72
|
+
def catalog(timeout)
|
|
73
|
+
registry = @registry || HostRegistry.new
|
|
74
|
+
results = registry.list_all_models(force: true, wait: timeout)
|
|
75
|
+
listed = results.values.any? { |data| data[:error].nil? }
|
|
76
|
+
[ModelCatalog.payload(results, registry: registry, default_name: default_name), listed]
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
[{ default: default_name, default_typed: nil, default_host: nil, models: [], aliases: [], warnings: [e.message] }, false]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def default_name
|
|
82
|
+
return @default_name unless @default_name == :config
|
|
83
|
+
|
|
84
|
+
ModelProfile.required_model_name
|
|
85
|
+
rescue ArgumentError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def filtered(payload, text)
|
|
90
|
+
return payload if text.empty?
|
|
91
|
+
|
|
92
|
+
needle = text.downcase
|
|
93
|
+
payload.merge(models: payload[:models].select { |m| m[:name].downcase.include?(needle) },
|
|
94
|
+
aliases: payload[:aliases].select { |a| "#{a[:name]} #{a[:ref]}".downcase.include?(needle) },
|
|
95
|
+
filter: text)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def print_json(payload)
|
|
99
|
+
@stdout.puts(JSON.generate(payload.except(:filter)))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def print_text(payload)
|
|
103
|
+
default = payload[:default]
|
|
104
|
+
names = payload[:models].reject { |m| m[:shadowed_by] }.map { |m| m[:name] }
|
|
105
|
+
shown_default = default && (payload[:filter].nil? || default.downcase.include?(payload[:filter].downcase))
|
|
106
|
+
@stdout.puts(default) if shown_default
|
|
107
|
+
(names - [default]).each { |name| @stdout.puts(name) }
|
|
108
|
+
payload[:aliases].each { |a| @stdout.puts("#{a[:name]} -> #{a[:ref]}") }
|
|
109
|
+
payload[:warnings].each { |w| @stderr.puts("chi models: #{w}") }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "cli/exit"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# How a wait's end (ReplyWait::Result) reads to whoever runs chi as a
|
|
10
|
+
# sub-agent: `chi send --wait` and `chi answer`. One JSON object for
|
|
11
|
+
# --format json, or a line (a block for a question) for stderr, and the
|
|
12
|
+
# exit status. A question carries everything needed to answer it, so a
|
|
13
|
+
# parent agent can answer from the output alone. Kept apart from the
|
|
14
|
+
# commands so a later delegate_answer tool reports the same shape.
|
|
15
|
+
module ParentReport
|
|
16
|
+
# The approval facts a parent needs; the rest (preview, branch) is the
|
|
17
|
+
# card's.
|
|
18
|
+
APPROVAL_KEYS = %i[tool label command paths args cwd rule source reason scopes].freeze
|
|
19
|
+
|
|
20
|
+
# stderr that keeps its last line: with --format json a failure before
|
|
21
|
+
# any wait is reported from it (#error_line).
|
|
22
|
+
class LastLine < SimpleDelegator
|
|
23
|
+
attr_reader :last
|
|
24
|
+
|
|
25
|
+
def puts(*lines)
|
|
26
|
+
@last = lines.last.to_s.chomp unless lines.empty?
|
|
27
|
+
__getobj__.puts(*lines)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# @param result [ReplyWait::Result]
|
|
34
|
+
# @return [String] answered, question, failed, canceled, no_answer,
|
|
35
|
+
# error, worker_gone, stopped or running
|
|
36
|
+
def status(result)
|
|
37
|
+
case result.status
|
|
38
|
+
when :done then "answered"
|
|
39
|
+
when :waiting_for_answer then "question"
|
|
40
|
+
when :no_reply
|
|
41
|
+
case result.outcome
|
|
42
|
+
when "failed" then "failed"
|
|
43
|
+
when "canceled" then "canceled"
|
|
44
|
+
else "no_answer"
|
|
45
|
+
end
|
|
46
|
+
when :error then "error"
|
|
47
|
+
when :worker_gone then "worker_gone"
|
|
48
|
+
when :stopped then "stopped"
|
|
49
|
+
# ReplyWait's own :canceled is the caller giving up (its cancelled:),
|
|
50
|
+
# and :timeout the deadline: the turn goes on either way.
|
|
51
|
+
else "running"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Integer] 0 answered, 3 a question waits, 4 still running,
|
|
56
|
+
# 1 anything else
|
|
57
|
+
def exit_status(result)
|
|
58
|
+
case status(result)
|
|
59
|
+
when "answered" then CLI::Exit::OK
|
|
60
|
+
when "question" then CLI::Exit::QUESTION
|
|
61
|
+
when "running" then CLI::Exit::RUNNING
|
|
62
|
+
else CLI::Exit::FAILED
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param session_id [String]
|
|
67
|
+
# @param timeout [Numeric, nil] the wait's --timeout (for the running detail)
|
|
68
|
+
# @return [Hash] the --format json object
|
|
69
|
+
def json(result, session_id:, timeout: nil)
|
|
70
|
+
report = { status: status(result), session_id: session_id }
|
|
71
|
+
case report[:status]
|
|
72
|
+
when "answered" then report[:text] = result.text.to_s
|
|
73
|
+
when "question"
|
|
74
|
+
question = question(result.question)
|
|
75
|
+
report[:question] = question
|
|
76
|
+
report[:answer_with] = answer_with(session_id, question)
|
|
77
|
+
else report[:detail] = detail(result, session_id: session_id, timeout: timeout)
|
|
78
|
+
end
|
|
79
|
+
report
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [String] the JSON object as one line
|
|
83
|
+
def json_line(result, session_id:, timeout: nil)
|
|
84
|
+
JSON.generate(json(result, session_id: session_id, timeout: timeout))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Print a wait's end the way the commands do: the JSON object on
|
|
88
|
+
# stdout, or the reply on stdout and anything else on stderr (a
|
|
89
|
+
# question in full) after "chi send: ".
|
|
90
|
+
# @param command [String] "chi send", "chi answer"
|
|
91
|
+
# @return [Integer] the exit status
|
|
92
|
+
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil)
|
|
93
|
+
if json
|
|
94
|
+
stdout.puts(json_line(result, session_id: session_id, timeout: timeout))
|
|
95
|
+
elsif result.status == :done
|
|
96
|
+
stdout.puts(result.text)
|
|
97
|
+
else
|
|
98
|
+
stdout.flush
|
|
99
|
+
text = if result.status == :waiting_for_answer
|
|
100
|
+
question_text(result.question, session_id: session_id)
|
|
101
|
+
else
|
|
102
|
+
"#{detail(result, session_id: session_id, timeout: timeout)}\n"
|
|
103
|
+
end
|
|
104
|
+
stderr.print("#{command}: #{text}")
|
|
105
|
+
end
|
|
106
|
+
stdout.flush
|
|
107
|
+
exit_status(result)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A failure before any wait (no such session, a busy one, a message
|
|
111
|
+
# that didn't go in) as the JSON object.
|
|
112
|
+
# @param session_id [String, nil]
|
|
113
|
+
def error_line(detail, session_id: nil)
|
|
114
|
+
JSON.generate({ status: "error", session_id: session_id, detail: detail.to_s })
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A pending question (Session#pending_question) as a parent sees it.
|
|
118
|
+
# kind is "question" for the model's own (the desk stores none),
|
|
119
|
+
# "hook" or "approval".
|
|
120
|
+
# @return [Hash]
|
|
121
|
+
def question(pending)
|
|
122
|
+
pending ||= {}
|
|
123
|
+
approval = pending[:approval].is_a?(Hash) ? symbolize(pending[:approval]) : nil
|
|
124
|
+
{
|
|
125
|
+
id: pending[:id].to_s,
|
|
126
|
+
kind: (pending[:kind] || "question").to_s,
|
|
127
|
+
header: blank(pending[:header]),
|
|
128
|
+
text: pending[:question].to_s,
|
|
129
|
+
options: Array(pending[:options]).map(&:to_s),
|
|
130
|
+
multi_select: !!pending[:multi_select],
|
|
131
|
+
allow_freeform: !!pending[:allow_freeform],
|
|
132
|
+
approval: approval&.slice(*APPROVAL_KEYS)
|
|
133
|
+
}.compact
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The command that answers +question+; N is the parent's to fill in.
|
|
137
|
+
# An approval's is a deny: allowing it is the user's
|
|
138
|
+
# (Guardrails::ParentApprovals).
|
|
139
|
+
def answer_with(session_id, question)
|
|
140
|
+
return "chi answer #{session_id} --question #{question[:id]} --option Deny --text WHY" if approval?(question)
|
|
141
|
+
|
|
142
|
+
"chi answer #{session_id} --question #{question[:id]} --option N"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# What a parent does with an approval.
|
|
146
|
+
DENY_AND_TELL = "deny it, and tell your user"
|
|
147
|
+
|
|
148
|
+
def approval?(question)
|
|
149
|
+
question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "approval"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# What a wait that ended without an answer says, one line.
|
|
153
|
+
def detail(result, session_id:, timeout: nil)
|
|
154
|
+
attach = "chi --attach #{session_id}"
|
|
155
|
+
case result.status
|
|
156
|
+
when :waiting_for_answer
|
|
157
|
+
if approval?(result.question)
|
|
158
|
+
"waiting for an approval: #{first_line(result.question[:question])}; #{DENY_AND_TELL}"
|
|
159
|
+
else
|
|
160
|
+
"waiting for an answer: #{first_line(result.question&.dig(:question))}; open it: #{attach} or the web"
|
|
161
|
+
end
|
|
162
|
+
when :no_reply then "#{no_reply_line(result)}; #{attach} shows it"
|
|
163
|
+
when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
|
|
164
|
+
when :worker_gone then "the worker is gone; #{attach} shows what happened"
|
|
165
|
+
when :stopped then "the session was stopped (chi sessions stop)"
|
|
166
|
+
else timeout ? "still running after #{format("%g", timeout)} s: #{attach}" : "still running: #{attach}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The whole question for stderr: header, text, numbered options, what
|
|
171
|
+
# else it takes, and the commands that answer it.
|
|
172
|
+
# @return [String] lines, newline-terminated
|
|
173
|
+
def question_text(pending, session_id:)
|
|
174
|
+
q = question(pending)
|
|
175
|
+
lines = ["waiting for an answer (#{q[:kind]}): #{q[:header] || first_line(q[:text])}"]
|
|
176
|
+
text_lines = q[:text].strip.lines.map(&:rstrip)
|
|
177
|
+
text_lines = text_lines.drop(1) unless q[:header]
|
|
178
|
+
lines.concat(text_lines.map { |line| " #{line}" })
|
|
179
|
+
q[:options].each_with_index { |option, i| lines << " #{i + 1}. #{option}" }
|
|
180
|
+
if approval?(q)
|
|
181
|
+
lines << " allowing it is up to your user: #{DENY_AND_TELL}"
|
|
182
|
+
lines << " deny: #{answer_with(session_id, q)}"
|
|
183
|
+
else
|
|
184
|
+
lines << " more than one allowed: repeat --option" if q[:multi_select]
|
|
185
|
+
lines << " free text allowed: --text" if q[:allow_freeform]
|
|
186
|
+
lines << " answer: #{answer_with(session_id, q)}"
|
|
187
|
+
lines << " or open it: chi --attach #{session_id} or the web"
|
|
188
|
+
end
|
|
189
|
+
lines.map { |line| "#{line}\n" }.join
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def no_reply_line(result)
|
|
193
|
+
case result.outcome
|
|
194
|
+
when "failed" then result.text.to_s.strip.empty? ? "the turn failed" : "the turn failed: #{result.text.strip}"
|
|
195
|
+
when "canceled" then "the turn was canceled"
|
|
196
|
+
when "completed" then "the turn ended with no visible answer"
|
|
197
|
+
else "the turn ended without a reply (canceled, failed or empty)"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def first_line(text)
|
|
202
|
+
text.to_s.strip.lines.first.to_s.strip
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def blank(value)
|
|
206
|
+
value.to_s.strip.empty? ? nil : value.to_s
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def symbolize(hash)
|
|
210
|
+
hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|