samagotchi 0.11.0 → 0.12.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 +29 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +4 -4
- data/docs/cli.md +25 -1
- data/docs/desktop.md +24 -2
- data/lib/samagotchi/bridge.rb +4 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- 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/engine.rb +445 -482
- data/lib/samagotchi/host_registry.rb +26 -2
- 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 +1 -1
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +1 -1
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/self_report.rb +8 -1
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_commands.rb +5 -5
- data/lib/samagotchi/session_manager.rb +2 -6
- data/lib/samagotchi/terminal_ui.rb +8 -28
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +4 -6
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- 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 +14 -33
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +6 -6
- data/lib/samagotchi/worker.rb +1 -1
- metadata +8 -1
|
@@ -3,29 +3,20 @@
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
module LLM
|
|
5
5
|
# Normalized result returned by every ModelBackend downstream of the agentic
|
|
6
|
-
# loop
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# .conversation, .canceled?, .cancellation_reason, .output, .exhausted?
|
|
13
|
-
# plus the native loop's .tool_activity, .context_status and
|
|
14
|
-
# .pending_tool_calls, which the interactive renderer needs.
|
|
6
|
+
# loop (the loop lives inside the backend). What Engine#run_turn and the
|
|
7
|
+
# renderers (TerminalUI / Bridge) read: .output (= .text, and .to_s, which
|
|
8
|
+
# is how the :turn_completed `result:` reaches JSON), .conversation,
|
|
9
|
+
# .canceled?, .cancellation_reason, .exhausted?, .resumable?,
|
|
10
|
+
# .empty_answer?, plus the native loop's .tool_activity, .context_status
|
|
11
|
+
# and .pending_tool_calls, which the interactive renderer needs.
|
|
15
12
|
class ModelResult
|
|
16
|
-
attr_reader :text, :
|
|
17
|
-
:conversation, :canceled, :cancellation_reason, :exhausted,
|
|
13
|
+
attr_reader :text, :conversation, :canceled, :cancellation_reason, :exhausted,
|
|
18
14
|
:tool_activity, :context_status, :pending_tool_calls
|
|
19
15
|
|
|
20
|
-
def initialize(text:,
|
|
21
|
-
conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
|
|
16
|
+
def initialize(text:, conversation: nil, canceled: false, cancellation_reason: nil, exhausted: false,
|
|
22
17
|
tool_activity: [], context_status: nil, pending_tool_calls: false, empty_answer: false)
|
|
23
18
|
@empty_answer = empty_answer
|
|
24
19
|
@text = text
|
|
25
|
-
@tool_calls = tool_calls
|
|
26
|
-
@provider = provider
|
|
27
|
-
@usage = usage
|
|
28
|
-
@metadata = metadata
|
|
29
20
|
@conversation = conversation
|
|
30
21
|
@canceled = canceled
|
|
31
22
|
@cancellation_reason = cancellation_reason
|
|
@@ -44,10 +35,13 @@ module Samagotchi
|
|
|
44
35
|
!!canceled
|
|
45
36
|
end
|
|
46
37
|
|
|
47
|
-
# The turn ended with nothing visible:
|
|
48
|
-
#
|
|
38
|
+
# The turn ended with nothing visible: no text, or the chat loop's
|
|
39
|
+
# placeholder (empty_answer:). A cancelled turn or one that can be
|
|
40
|
+
# continued is not an empty answer.
|
|
49
41
|
def empty_answer?
|
|
50
|
-
|
|
42
|
+
return false if canceled? || resumable?
|
|
43
|
+
|
|
44
|
+
!!@empty_answer || text.to_s.strip.empty?
|
|
51
45
|
end
|
|
52
46
|
|
|
53
47
|
def exhausted?
|
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "backend"
|
|
4
4
|
require_relative "model_result"
|
|
5
|
-
require_relative "usage"
|
|
6
5
|
|
|
7
6
|
module Samagotchi
|
|
8
7
|
module LLM
|
|
9
8
|
# The raw-prompt loop as a backend: KernelLoop#run (llama.cpp /completion,
|
|
10
|
-
# Gemma/Qwen prompt formats)
|
|
9
|
+
# Gemma/Qwen prompt formats), which returns a ModelResult itself, so
|
|
11
10
|
# Engine and the TUI call every backend the same way.
|
|
12
11
|
class NativeBackend < ModelBackend
|
|
13
12
|
def initialize(kernel:)
|
|
@@ -18,32 +17,15 @@ module Samagotchi
|
|
|
18
17
|
|
|
19
18
|
def complete(messages:, max_iterations: 100, on_stream_event: nil, cancel_controller: nil,
|
|
20
19
|
model_name: nil, max_tool_output_chars: nil, pending_input: nil)
|
|
21
|
-
|
|
22
|
-
kernel_result = @kernel.run(
|
|
20
|
+
@kernel.run(
|
|
23
21
|
messages,
|
|
24
22
|
max_iterations: max_iterations,
|
|
25
|
-
on_stream_event:
|
|
26
|
-
usage.observe(event)
|
|
27
|
-
on_stream_event&.call(event)
|
|
28
|
-
},
|
|
23
|
+
on_stream_event: ->(event) { on_stream_event&.call(event) },
|
|
29
24
|
cancel_controller: cancel_controller,
|
|
30
25
|
model_name: model_name,
|
|
31
26
|
max_tool_output_chars: max_tool_output_chars,
|
|
32
27
|
pending_input: pending_input
|
|
33
28
|
)
|
|
34
|
-
ModelResult.new(
|
|
35
|
-
text: kernel_result.output.to_s,
|
|
36
|
-
tool_calls: nil,
|
|
37
|
-
provider: :native,
|
|
38
|
-
usage: usage.usage(prompt_text: Array(messages).sum("") { |message| message[:content].to_s }),
|
|
39
|
-
conversation: kernel_result.conversation,
|
|
40
|
-
canceled: kernel_result.canceled?,
|
|
41
|
-
cancellation_reason: kernel_result.cancellation_reason,
|
|
42
|
-
exhausted: kernel_result.exhausted,
|
|
43
|
-
tool_activity: kernel_result.tool_activity,
|
|
44
|
-
context_status: kernel_result.context_status,
|
|
45
|
-
pending_tool_calls: kernel_result.pending_tool_calls
|
|
46
|
-
)
|
|
47
29
|
end
|
|
48
30
|
end
|
|
49
31
|
end
|
|
@@ -56,7 +56,7 @@ module Samagotchi
|
|
|
56
56
|
# @param entry [HostRegistry::HostEntry]
|
|
57
57
|
def self.for(entry, **options)
|
|
58
58
|
new(base_url: entry.openai_base_url, host_name: entry.name, api_key_env: entry.api_key_env,
|
|
59
|
-
remote: entry.
|
|
59
|
+
remote: entry.remote?, **options)
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
# Tool arguments as a Hash; "" is {}, invalid JSON stays a String.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../thinking"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module LLM
|
|
7
|
+
# A turn's settings on the kernel, read by both loops (KernelLoop,
|
|
8
|
+
# ChatLoop) and ToolRunner: the turn's VisionContext (nil sends no
|
|
9
|
+
# images, placeholders instead), its request parameters
|
|
10
|
+
# (SamplingSettings.for; empty sends none), its thinking level
|
|
11
|
+
# (Thinking.resolve) and the bare model id the turn runs on (every
|
|
12
|
+
# debug dump is tagged with it). The Engine sets them once per turn,
|
|
13
|
+
# in Engine#generate, after the model's host is synced.
|
|
14
|
+
TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name) do
|
|
15
|
+
# No vision, no sampling, the default thinking, no model name.
|
|
16
|
+
def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
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
|
|
@@ -365,7 +365,7 @@ module Samagotchi
|
|
|
365
365
|
version: manifest.version,
|
|
366
366
|
source_path: @source,
|
|
367
367
|
hooks: hooks_for_provenance,
|
|
368
|
-
trust_level: manifest.
|
|
368
|
+
trust_level: manifest.trust_level,
|
|
369
369
|
source_commit: source_commit,
|
|
370
370
|
hooks_files: hooks_files_for_provenance,
|
|
371
371
|
guardrails_files: guardrail_files_for_provenance,
|
|
@@ -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
|
|
@@ -324,7 +324,7 @@ module Samagotchi
|
|
|
324
324
|
return false if client.nil? || !client.respond_to?(:server_props)
|
|
325
325
|
return false if entry&.chat?
|
|
326
326
|
|
|
327
|
-
|
|
327
|
+
client.transport.name == :llama_cpp
|
|
328
328
|
end
|
|
329
329
|
private_class_method :probe?
|
|
330
330
|
|
|
@@ -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,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
|
|
@@ -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
|
|
|
@@ -95,7 +95,7 @@ module Samagotchi
|
|
|
95
95
|
|
|
96
96
|
profile = ModelProfile.resolve(names: names, entry: entry, client: target.client, bare_model: target.bare_model).profile
|
|
97
97
|
answer = VisionSupport.for(target, profile: profile, names: names)
|
|
98
|
-
props = target.client.
|
|
98
|
+
props = target.client.server_props(model: target.bare_model)
|
|
99
99
|
return UNKNOWN_VISION if answer.no? && !props&.answered? && !configured_no?(answer)
|
|
100
100
|
|
|
101
101
|
answer
|
|
@@ -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
|
|
@@ -765,7 +765,7 @@ module Samagotchi
|
|
|
765
765
|
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], state_dir: nil, manager: self, bridge: nil)
|
|
766
766
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
767
767
|
bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
|
|
768
|
-
manager.resume_session(session_id, state_dir: state_dir)
|
|
768
|
+
manager.resume_session(session_id, state_dir: state_dir)
|
|
769
769
|
if (client = bridge.call)
|
|
770
770
|
begin
|
|
771
771
|
options = { prompt: prompt, client_id: client_id }
|
|
@@ -802,9 +802,7 @@ module Samagotchi
|
|
|
802
802
|
end
|
|
803
803
|
# A worker that idle-exited since the resume never reads it either:
|
|
804
804
|
# wake a new one. (The exiting worker also looks for input it left.)
|
|
805
|
-
|
|
806
|
-
manager.resume_session(session_id, state_dir: state_dir)
|
|
807
|
-
end
|
|
805
|
+
manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
|
|
808
806
|
{ status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
|
|
809
807
|
end
|
|
810
808
|
|
|
@@ -814,8 +812,6 @@ module Samagotchi
|
|
|
814
812
|
end
|
|
815
813
|
|
|
816
814
|
private_class_method def self.delivery_owner(manager, session_id, state_dir)
|
|
817
|
-
return nil unless manager.respond_to?(:session_owner)
|
|
818
|
-
|
|
819
815
|
manager.session_owner(session_id, state_dir: state_dir)
|
|
820
816
|
rescue StandardError
|
|
821
817
|
nil
|