samagotchi 0.10.1 → 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 +94 -1
- data/bin/chi +5 -2
- data/docs/architecture.md +23 -4
- data/docs/cli.md +49 -8
- data/docs/configuration.md +44 -11
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +3 -2
- data/docs/internals/context-telemetry.md +4 -2
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
- data/lib/samagotchi/bridge.rb +4 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +21 -1
- data/lib/samagotchi/config.rb +44 -40
- data/lib/samagotchi/context_status.rb +17 -16
- 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/empty_answer_retry.rb +67 -8
- data/lib/samagotchi/engine.rb +506 -499
- data/lib/samagotchi/host_registry.rb +76 -92
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +352 -407
- data/lib/samagotchi/llm/chat_loop.rb +95 -125
- 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 +23 -4
- data/lib/samagotchi/model_ref.rb +60 -0
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/prompt_history.rb +91 -0
- data/lib/samagotchi/recap_setup.rb +15 -3
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/self_report.rb +19 -7
- data/lib/samagotchi/send_command.rb +9 -9
- data/lib/samagotchi/session.rb +9 -2
- data/lib/samagotchi/session_commands.rb +15 -17
- data/lib/samagotchi/session_manager.rb +22 -9
- data/lib/samagotchi/sessions_command.rb +57 -51
- data/lib/samagotchi/steer.rb +23 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
- data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
- data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
- data/lib/samagotchi/terminal_ui.rb +16 -35
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/tool_response.rb +67 -0
- data/lib/samagotchi/tool_runner.rb +10 -7
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +4 -3
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +8 -6
- 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 +42 -35
- data/lib/samagotchi/web/public/app.js +29 -2
- data/lib/samagotchi/web/public/data.js +9 -1
- data/lib/samagotchi/web/public/prompt_history.js +63 -0
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +6 -6
- data/lib/samagotchi/worker.rb +3 -2
- metadata +13 -1
|
@@ -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
|
|
@@ -129,15 +129,23 @@ module Samagotchi
|
|
|
129
129
|
|
|
130
130
|
# Raises UnknownHost when +model_name+ (or the alias it names) is
|
|
131
131
|
# qualified with a host that isn't configured, instead of sending the
|
|
132
|
-
# whole ref to the default host as a model id
|
|
133
|
-
#
|
|
132
|
+
# whole ref to the default host as a model id, and when an alias after
|
|
133
|
+
# a host prefix names another host ("openrouter:tiny" with tiny:
|
|
134
|
+
# box:…). Where a model comes in (an Engine starting or switching, a
|
|
135
|
+
# spawned session) calls it.
|
|
134
136
|
# @param hosts [Hash, nil] the hosts a prefix may name (a HostRegistry's
|
|
135
137
|
# entries); config.yml's by default
|
|
136
138
|
# @return [String] +model_name+
|
|
137
139
|
def self.check_host!(model_name, env: ENV, hosts: nil)
|
|
138
140
|
require_relative "config"
|
|
139
141
|
hosts ||= Samagotchi::ConfigFile.hosts_config(env: env)
|
|
140
|
-
|
|
142
|
+
parsed = Samagotchi::ConfigFile.model_ref(model_name, env: env, hosts: hosts)
|
|
143
|
+
if (other = parsed.host_conflict)
|
|
144
|
+
raise UnknownHost, "alias '#{parsed.alias_name}' names host '#{other}', not '#{parsed.host_name}'; " \
|
|
145
|
+
"use #{parsed.alias_name} or #{other}:#{parsed.alias_name}"
|
|
146
|
+
end
|
|
147
|
+
ref = parsed.ref
|
|
148
|
+
warn_host_slash(ref, hosts) unless parsed.host_name
|
|
141
149
|
host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
|
|
142
150
|
return model_name unless host
|
|
143
151
|
|
|
@@ -147,6 +155,17 @@ module Samagotchi
|
|
|
147
155
|
raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(', ')}"
|
|
148
156
|
end
|
|
149
157
|
|
|
158
|
+
# "box/x" named host box until '/' stopped naming a host: a saved
|
|
159
|
+
# session or a hand-written ref says so once, then goes to the default host.
|
|
160
|
+
def self.warn_host_slash(ref, hosts)
|
|
161
|
+
prefix, rest = ref.to_s.split("/", 2)
|
|
162
|
+
return if rest.to_s.empty? || !hosts.keys.map { |k| k.to_s.downcase }.include?(prefix.downcase)
|
|
163
|
+
|
|
164
|
+
Samagotchi::ConfigFile.warn_once("Warning: model '#{ref}' starts with the host '#{prefix}' and '/': only ':' names a host now, " \
|
|
165
|
+
"so it goes to the default host as written; use #{prefix}:#{rest} (/model #{prefix}:#{rest})")
|
|
166
|
+
end
|
|
167
|
+
private_class_method :warn_host_slash
|
|
168
|
+
|
|
150
169
|
# One line for the user: where to set the model.
|
|
151
170
|
def self.missing_model_message
|
|
152
171
|
path = begin
|
|
@@ -305,7 +324,7 @@ module Samagotchi
|
|
|
305
324
|
return false if client.nil? || !client.respond_to?(:server_props)
|
|
306
325
|
return false if entry&.chat?
|
|
307
326
|
|
|
308
|
-
|
|
327
|
+
client.transport.name == :llama_cpp
|
|
309
328
|
end
|
|
310
329
|
private_class_method :probe?
|
|
311
330
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A model ref as written (--model, /model, default.model, an alias's
|
|
5
|
+
# target): an optional host prefix and a model id or alias. Pure: it is
|
|
6
|
+
# given the configured hosts and aliases and asks nothing else. Routing a
|
|
7
|
+
# name without a host to one (the model index after /models) is
|
|
8
|
+
# HostRegistry's.
|
|
9
|
+
#
|
|
10
|
+
# Aliases apply once (an alias naming another alias sends that name as
|
|
11
|
+
# written), also after a host prefix ("box:small"). An alias whose target
|
|
12
|
+
# names another host than the prefix is a conflict (#host_conflict),
|
|
13
|
+
# refused where a model comes in (ModelProfile.check_host!).
|
|
14
|
+
#
|
|
15
|
+
# typed: the ref as given, stripped.
|
|
16
|
+
# host_name: the host the ref names (downcased), its own or its alias's;
|
|
17
|
+
# nil when it names none.
|
|
18
|
+
# id: the model id the host is asked for.
|
|
19
|
+
# alias_name: the alias applied (as typed), else nil.
|
|
20
|
+
# alias_host: the host the alias's target names, else nil.
|
|
21
|
+
ModelRef = Data.define(:typed, :host_name, :id, :alias_name, :alias_host) do
|
|
22
|
+
# Splits "host:model" when the prefix is a configured host and something
|
|
23
|
+
# follows it. Only ':' names a host: "openai/gpt-4o" is an id (OpenRouter's).
|
|
24
|
+
# @param hosts [Hash] configured hosts by name (any value)
|
|
25
|
+
# @return [Array(String, String)] [host_name or nil, the rest]
|
|
26
|
+
def self.split(raw, hosts:)
|
|
27
|
+
value = raw.to_s.strip
|
|
28
|
+
return [nil, value] if value.empty?
|
|
29
|
+
|
|
30
|
+
prefix, rest = value.split(":", 2)
|
|
31
|
+
return [nil, value] if rest.nil? || rest.strip.empty?
|
|
32
|
+
return [nil, value] unless (hosts || {}).keys.map { |k| k.to_s.downcase }.include?(prefix.strip.downcase)
|
|
33
|
+
|
|
34
|
+
[prefix.strip.downcase, rest.strip]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param aliases [Hash{String => String}] alias (downcased) => target
|
|
38
|
+
# @return [ModelRef]
|
|
39
|
+
def self.parse(raw, hosts:, aliases:)
|
|
40
|
+
value = raw.to_s.strip
|
|
41
|
+
host, rest = split(value, hosts: hosts)
|
|
42
|
+
name = host ? rest : value
|
|
43
|
+
target = aliases[name.downcase]
|
|
44
|
+
return new(typed: value, host_name: host, id: name, alias_name: nil, alias_host: nil) unless target
|
|
45
|
+
|
|
46
|
+
alias_host, alias_id = split(target, hosts: hosts)
|
|
47
|
+
new(typed: value, host_name: host || alias_host, id: alias_host ? alias_id : target, alias_name: name,
|
|
48
|
+
alias_host: alias_host)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The resolved ref a session stores: "host:id", or the id when no host
|
|
52
|
+
# is named (routed when it runs).
|
|
53
|
+
def ref = host_name ? "#{host_name}:#{id}" : id
|
|
54
|
+
|
|
55
|
+
# The host the alias names when the ref's own prefix names another, else nil.
|
|
56
|
+
def host_conflict
|
|
57
|
+
alias_host if alias_host && alias_host != host_name
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
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,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
require_relative "atomic_file"
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "paths"
|
|
9
|
+
require_relative "session_commands"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
# The prompts typed at a chi prompt, kept for ↑ across launches: one
|
|
13
|
+
# global file (config history.file, else
|
|
14
|
+
# $XDG_STATE_HOME/samagotchi/history.json), a JSON array oldest first. An
|
|
15
|
+
# older file with one prompt per line still reads. The TUI and the web
|
|
16
|
+
# server both append, so appends hold <file>.lock and replace the file
|
|
17
|
+
# atomically (mode 0600: the web serves it to LAN token holders).
|
|
18
|
+
module PromptHistory
|
|
19
|
+
FILE = "history.json"
|
|
20
|
+
STATE_DIR = "samagotchi"
|
|
21
|
+
LIMIT = 100
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# @return [String] the history file
|
|
26
|
+
def path
|
|
27
|
+
explicit = Config.get("history.file").to_s.strip
|
|
28
|
+
return explicit unless explicit.empty?
|
|
29
|
+
|
|
30
|
+
File.join(Paths.state_home, STATE_DIR, FILE)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Array<String>] the saved entries, oldest first; [] when the
|
|
34
|
+
# file is missing or unreadable
|
|
35
|
+
def entries
|
|
36
|
+
file = path
|
|
37
|
+
return [] unless File.file?(file)
|
|
38
|
+
|
|
39
|
+
raw = File.read(file)
|
|
40
|
+
normalize(JSON.parse(raw))
|
|
41
|
+
rescue JSON::ParserError
|
|
42
|
+
normalize(raw.to_s.lines.map(&:chomp))
|
|
43
|
+
rescue StandardError
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Add +line+ as the newest entry, keeping the last LIMIT. The re-read
|
|
48
|
+
# happens under the lock, so two writers never drop each other's line.
|
|
49
|
+
# The rename replaces a symlinked history.file with a regular file.
|
|
50
|
+
# @param line [String]
|
|
51
|
+
def append(line)
|
|
52
|
+
file = path
|
|
53
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
54
|
+
File.open("#{file}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
|
|
55
|
+
lock.flock(File::LOCK_EX)
|
|
56
|
+
list = entries << line
|
|
57
|
+
AtomicFile.write(file, JSON.pretty_generate(list.last(LIMIT)) + "\n", perm: 0o600)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# What changes when anyone writes the file: its mtime and size.
|
|
62
|
+
# @return [Array, nil] nil without a file
|
|
63
|
+
def signature
|
|
64
|
+
stat = File.stat(path)
|
|
65
|
+
[stat.mtime, stat.size]
|
|
66
|
+
rescue SystemCallError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The entries +now+ has after the ones +seen+ earlier: the longest end
|
|
71
|
+
# of +seen+ that +now+ starts with lines the two up (appends trim the
|
|
72
|
+
# front); when nothing lines up, all of +now+.
|
|
73
|
+
# @return [Array<String>]
|
|
74
|
+
def new_tail(seen, now)
|
|
75
|
+
(0..seen.size).each do |drop|
|
|
76
|
+
kept = seen.size - drop
|
|
77
|
+
return now.drop(kept) if kept <= now.size && now.first(kept) == seen.last(kept)
|
|
78
|
+
end
|
|
79
|
+
now
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A `!command` worth recalling; `!rollback` isn't one.
|
|
83
|
+
def shell_line?(line)
|
|
84
|
+
line.to_s.start_with?(SessionCommands::SHELL_BANG_PREFIX) && line.to_s.strip != SessionCommands::ROLLBACK_COMMAND
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalize(list)
|
|
88
|
+
Array(list).map { |entry| entry.to_s.gsub(/\r\n?/, "\n").strip }.reject(&:empty?)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -46,6 +46,11 @@ module Samagotchi
|
|
|
46
46
|
if base_url.nil? && host_ref.nil? && model.nil?
|
|
47
47
|
target = -> { session_target.call }
|
|
48
48
|
else
|
|
49
|
+
# The model as any model ref (ModelRef): its alias applied once, and
|
|
50
|
+
# its host (own or the alias's) picks the recap host when host_ref
|
|
51
|
+
# doesn't.
|
|
52
|
+
parsed = model && host_registry.model_ref(model)
|
|
53
|
+
host_ref ||= parsed.host_name if parsed && base_url.nil?
|
|
49
54
|
# If host_ref given, derive base_url (the host's OpenAI base) and its
|
|
50
55
|
# API key variable from the host_registry entry
|
|
51
56
|
api_key_env = nil
|
|
@@ -54,13 +59,20 @@ module Samagotchi
|
|
|
54
59
|
if entry
|
|
55
60
|
base_url = entry.openai_base_url
|
|
56
61
|
api_key_env = entry.api_key_env
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
named = parsed&.host_conflict || parsed&.host_name
|
|
63
|
+
if named && named != entry.name
|
|
64
|
+
Log.warn(:recap, "model_host_mismatch",
|
|
65
|
+
echo: "Warning: recap model '#{model}' names host '#{named}', not recap.host_ref '#{host_ref}'; recap disabled.",
|
|
66
|
+
model: model, host_ref: host_ref)
|
|
67
|
+
return nil
|
|
68
|
+
end
|
|
69
|
+
model = parsed.id if parsed
|
|
60
70
|
else
|
|
61
71
|
Log.warn(:recap, "host_ref_unknown", echo: "Warning: recap host_ref '#{host_ref}' not found in hosts:; recap disabled.", host_ref: host_ref)
|
|
62
72
|
return nil
|
|
63
73
|
end
|
|
74
|
+
elsif parsed && !parsed.host_name
|
|
75
|
+
model = parsed.id
|
|
64
76
|
end
|
|
65
77
|
|
|
66
78
|
if base_url.to_s.strip.empty? || model.to_s.strip.empty?
|
|
@@ -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,10 +111,17 @@ 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)
|
|
117
|
-
entry
|
|
124
|
+
entry = HostRegistry.new(env: env).resolve(model).entry
|
|
118
125
|
return "-" unless entry
|
|
119
126
|
|
|
120
127
|
entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
|
|
@@ -125,12 +132,14 @@ module Samagotchi
|
|
|
125
132
|
# GET with the probe's short timeouts; a remote host isn't asked.
|
|
126
133
|
def served_model_for(model, env)
|
|
127
134
|
registry = HostRegistry.new(env: env)
|
|
128
|
-
|
|
135
|
+
target = registry.resolve(model)
|
|
136
|
+
entry = target.entry
|
|
137
|
+
bare = target.bare_model
|
|
129
138
|
return "-" unless entry
|
|
130
139
|
return "reported per turn (remote host)" if entry.remote?
|
|
131
140
|
|
|
132
141
|
client = registry.client_for(entry)
|
|
133
|
-
props = client.server_props(model: bare)
|
|
142
|
+
props = client.server_props(model: bare)
|
|
134
143
|
return "reported per turn (the server has no /props)" if props.nil?
|
|
135
144
|
return "unknown (the server didn't answer; is it running?)" if props.status == :network_error
|
|
136
145
|
|
|
@@ -146,7 +155,9 @@ module Samagotchi
|
|
|
146
155
|
# llama.cpp host's chat template decides at runtime.
|
|
147
156
|
def profile_for(model, env)
|
|
148
157
|
registry = HostRegistry.new(env: env)
|
|
149
|
-
|
|
158
|
+
target = registry.resolve(model)
|
|
159
|
+
entry = target.entry
|
|
160
|
+
bare = target.bare_model
|
|
150
161
|
return "name-based (chat API: only strips thoughts)" if entry&.chat?
|
|
151
162
|
|
|
152
163
|
names = registry.lookup_names(model)
|
|
@@ -177,7 +188,9 @@ module Samagotchi
|
|
|
177
188
|
end
|
|
178
189
|
|
|
179
190
|
def host_for(model, env)
|
|
180
|
-
|
|
191
|
+
target = HostRegistry.new(env: env).resolve(model)
|
|
192
|
+
entry = target.entry
|
|
193
|
+
bare = target.bare_model
|
|
181
194
|
return "(no hosts configured)" unless entry
|
|
182
195
|
|
|
183
196
|
suffix = bare && bare != model ? " as #{bare}" : ""
|
|
@@ -188,8 +201,7 @@ module Samagotchi
|
|
|
188
201
|
# The variable the host's API key comes from and whether it is set; the
|
|
189
202
|
# key itself is never shown.
|
|
190
203
|
def api_key_for(model, env)
|
|
191
|
-
|
|
192
|
-
name = entry&.api_key_env
|
|
204
|
+
name = HostRegistry.new(env: env).resolve(model).entry&.api_key_env
|
|
193
205
|
return "-" unless name
|
|
194
206
|
|
|
195
207
|
"#{name} (#{env[name].to_s.empty? ? "unset" : "set"})"
|
|
@@ -80,22 +80,22 @@ module Samagotchi
|
|
|
80
80
|
# takes images, decided as its worker decides before a turn with images
|
|
81
81
|
# (Engine#turn_vision: VisionSupport, with models.<name>.vision under
|
|
82
82
|
# the name as given (maybe an alias), the alias's target, the part
|
|
83
|
-
# after a host prefix and the bare name
|
|
83
|
+
# after a host prefix and the bare name). A native
|
|
84
84
|
# host whose /props doesn't answer is unknown here, not a no: the
|
|
85
85
|
# worker asks again when the turn runs.
|
|
86
|
+
# @param typed [String, nil] the name the model was typed as (a session's model_typed)
|
|
86
87
|
# @return [VisionSupport::Answer] value nil when unknown
|
|
87
|
-
def self.vision_answer(model_name, registry: nil)
|
|
88
|
+
def self.vision_answer(model_name, registry: nil, typed: nil)
|
|
88
89
|
registry ||= HostRegistry.new
|
|
89
90
|
name = ModelProfile.required_model_name(model_name)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
names = registry.lookup_names(name, target: target)
|
|
91
|
+
target = registry.resolve(name)
|
|
92
|
+
names = registry.lookup_names(typed.to_s.strip.empty? ? name : typed, resolved: name, target: target)
|
|
93
93
|
entry = target.entry
|
|
94
94
|
return VisionSupport.for(target, adapter: registry.adapter_for(entry), names: names) if entry.chat?
|
|
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
|
|
@@ -385,7 +385,7 @@ module Samagotchi
|
|
|
385
385
|
def deliver(id, prompt)
|
|
386
386
|
short = id[0, 8]
|
|
387
387
|
session = Session.load(id, state_dir: @state_dir)
|
|
388
|
-
if !@images.empty? && (refusal = vision_refusal(session.model_name))
|
|
388
|
+
if !@images.empty? && (refusal = vision_refusal(session.model_name, typed: session.model_typed))
|
|
389
389
|
@info.puts("#{short} refused: #{refusal}")
|
|
390
390
|
return false
|
|
391
391
|
end
|
|
@@ -415,9 +415,9 @@ module Samagotchi
|
|
|
415
415
|
|
|
416
416
|
# What to say when +model_name+ is known not to take images, or nil
|
|
417
417
|
# (it does, or it is unknown: sent as before). Asked once per model.
|
|
418
|
-
def vision_refusal(model_name)
|
|
418
|
+
def vision_refusal(model_name, typed: nil)
|
|
419
419
|
@vision ||= {}
|
|
420
|
-
answer = @vision[model_name.to_s] ||= self.class.vision_answer(model_name)
|
|
420
|
+
answer = @vision[[model_name.to_s, typed.to_s]] ||= self.class.vision_answer(model_name, typed: typed)
|
|
421
421
|
return nil unless answer.no?
|
|
422
422
|
|
|
423
423
|
name = model_name.to_s.strip
|