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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +23 -4
  5. data/docs/cli.md +49 -8
  6. data/docs/configuration.md +44 -11
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/context-telemetry.md +4 -2
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  13. data/lib/samagotchi/bridge.rb +4 -4
  14. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  15. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/client.rb +21 -1
  21. data/lib/samagotchi/config.rb +44 -40
  22. data/lib/samagotchi/context_status.rb +17 -16
  23. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  25. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  27. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  28. data/lib/samagotchi/engine.rb +506 -499
  29. data/lib/samagotchi/host_registry.rb +76 -92
  30. data/lib/samagotchi/idle_reminders.rb +13 -36
  31. data/lib/samagotchi/kernel_loop.rb +352 -407
  32. data/lib/samagotchi/llm/chat_loop.rb +95 -125
  33. data/lib/samagotchi/llm/model_result.rb +14 -20
  34. data/lib/samagotchi/llm/native_backend.rb +3 -21
  35. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  36. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  37. data/lib/samagotchi/llm/usage.rb +2 -57
  38. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  39. data/lib/samagotchi/model_catalog.rb +101 -0
  40. data/lib/samagotchi/model_profile.rb +23 -4
  41. data/lib/samagotchi/model_ref.rb +60 -0
  42. data/lib/samagotchi/models_command.rb +112 -0
  43. data/lib/samagotchi/prompt_history.rb +91 -0
  44. data/lib/samagotchi/recap_setup.rb +15 -3
  45. data/lib/samagotchi/reminder_queue.rb +73 -0
  46. data/lib/samagotchi/reminder_store.rb +3 -3
  47. data/lib/samagotchi/self_report.rb +19 -7
  48. data/lib/samagotchi/send_command.rb +9 -9
  49. data/lib/samagotchi/session.rb +9 -2
  50. data/lib/samagotchi/session_commands.rb +15 -17
  51. data/lib/samagotchi/session_manager.rb +22 -9
  52. data/lib/samagotchi/sessions_command.rb +57 -51
  53. data/lib/samagotchi/steer.rb +23 -0
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  56. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  57. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  58. data/lib/samagotchi/terminal_ui.rb +16 -35
  59. data/lib/samagotchi/thinking.rb +1 -1
  60. data/lib/samagotchi/tool_response.rb +67 -0
  61. data/lib/samagotchi/tool_runner.rb +10 -7
  62. data/lib/samagotchi/tools/builtins.rb +1 -1
  63. data/lib/samagotchi/tools/delegate.rb +4 -3
  64. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  65. data/lib/samagotchi/tools/memory.rb +8 -6
  66. data/lib/samagotchi/turn_flow.rb +4 -4
  67. data/lib/samagotchi/turn_state.rb +126 -0
  68. data/lib/samagotchi/used_memories.rb +88 -0
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/vision_support.rb +4 -4
  71. data/lib/samagotchi/web/app.rb +42 -35
  72. data/lib/samagotchi/web/public/app.js +29 -2
  73. data/lib/samagotchi/web/public/data.js +9 -1
  74. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  75. data/lib/samagotchi/web/session_hub.rb +0 -4
  76. data/lib/samagotchi/web/session_summary.rb +6 -6
  77. data/lib/samagotchi/worker.rb +3 -2
  78. 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. Where a model comes in
133
- # (an Engine starting or switching, a spawned session) calls it.
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
- ref = Samagotchi::ConfigFile.resolve_model_alias(model_name, env: env, hosts: hosts)
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
- !client.respond_to?(:transport) || client.transport.name == :llama_cpp
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
- # If model is host-qualified, extract bare model for recap client
58
- _, bare = host_registry.parse_qualified_model(model) if model
59
- model = bare if bare && !bare.empty?
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 → sets @due_reminder_name on Engine
19
- # 3. Engine#run_turn reads @due_reminder_name, injects [SYSTEM:] message
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
- # Called by Engine#collect_due_reminders (synchronously, at run_turn start).
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, = HostRegistry.new(env: env).host_for_model(model)
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
- entry, bare = registry.host_for_model(model)
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) if client.respond_to?(:server_props)
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
- entry, bare = registry.host_for_model(model)
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
- entry, bare = HostRegistry.new(env: env).host_for_model(model)
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
- entry, = HostRegistry.new(env: env).host_for_model(model)
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, as after /model). A native
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
- aliased = ConfigFile.resolve_model_alias(name)
91
- target = registry.resolve(aliased)
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.respond_to?(:server_props) ? target.client.server_props(model: target.bare_model) : nil
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