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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +25 -1
  6. data/docs/desktop.md +24 -2
  7. data/lib/samagotchi/bridge.rb +4 -4
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  10. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  11. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  12. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  13. data/lib/samagotchi/engine.rb +445 -482
  14. data/lib/samagotchi/host_registry.rb +26 -2
  15. data/lib/samagotchi/idle_reminders.rb +13 -36
  16. data/lib/samagotchi/kernel_loop.rb +82 -120
  17. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  18. data/lib/samagotchi/llm/model_result.rb +14 -20
  19. data/lib/samagotchi/llm/native_backend.rb +3 -21
  20. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  21. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  22. data/lib/samagotchi/llm/usage.rb +2 -57
  23. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  24. data/lib/samagotchi/model_catalog.rb +101 -0
  25. data/lib/samagotchi/model_profile.rb +1 -1
  26. data/lib/samagotchi/models_command.rb +112 -0
  27. data/lib/samagotchi/reminder_queue.rb +73 -0
  28. data/lib/samagotchi/reminder_store.rb +3 -3
  29. data/lib/samagotchi/self_report.rb +8 -1
  30. data/lib/samagotchi/send_command.rb +1 -1
  31. data/lib/samagotchi/session_commands.rb +5 -5
  32. data/lib/samagotchi/session_manager.rb +2 -6
  33. data/lib/samagotchi/terminal_ui.rb +8 -28
  34. data/lib/samagotchi/thinking.rb +1 -1
  35. data/lib/samagotchi/tool_runner.rb +4 -6
  36. data/lib/samagotchi/tools/builtins.rb +1 -1
  37. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  38. data/lib/samagotchi/turn_flow.rb +4 -4
  39. data/lib/samagotchi/turn_state.rb +126 -0
  40. data/lib/samagotchi/used_memories.rb +88 -0
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/vision_support.rb +4 -4
  43. data/lib/samagotchi/web/app.rb +14 -33
  44. data/lib/samagotchi/web/session_hub.rb +0 -4
  45. data/lib/samagotchi/web/session_summary.rb +6 -6
  46. data/lib/samagotchi/worker.rb +1 -1
  47. 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. The loop lives INSIDE the backend (Option A), so raw provider tool
7
- # calls are never surfaced — `tool_calls` is always `nil` downstream.
8
- #
9
- # It deliberately mirrors the read surface Engine#run_turn and the renderers
10
- # (TerminalUI / Bridge) currently consume off KernelLoop::Result, so wiring
11
- # the seam in does not require changes to those consumers:
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, :tool_calls, :provider, :usage, :metadata,
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:, tool_calls: nil, provider: nil, usage: nil, metadata: nil,
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: the text shown is a placeholder,
48
- # the conversation holds no reply.
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
- !!@empty_answer
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) with its result wrapped in a ModelResult, so
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
- usage = UsageCollector.new
22
- kernel_result = @kernel.run(
20
+ @kernel.run(
23
21
  messages,
24
22
  max_iterations: max_iterations,
25
- on_stream_event: lambda { |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.respond_to?(:remote?) && entry.remote?, **options)
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
@@ -4,8 +4,8 @@ require_relative "../token_usage"
4
4
 
5
5
  module Samagotchi
6
6
  module LLM
7
- # A turn's token counts, never nil: what the server reported (:server),
8
- # else a chars/4 estimate (:estimate), else zeros (:none).
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.respond_to?(:trust_level) ? manifest.trust_level : nil,
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
- !client.respond_to?(:transport) || client.transport.name == :llama_cpp
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 → 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,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) if client.respond_to?(:server_props)
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.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
@@ -404,7 +404,7 @@ module Samagotchi
404
404
 
405
405
  def persist_session_model
406
406
  session = @engine.session
407
- return unless session.respond_to?(:model_name=)
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.respond_to?(:served_model) ? @engine.served_model : nil
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.respond_to?(:sampling_summary) ? @engine.sampling_summary : nil
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.respond_to?(:thinking_summary) ? @engine.thinking_summary : nil
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.respond_to?(:chat_model?) && @engine.chat_model?
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) if manager.respond_to?(:resume_session)
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
- if owner.nil? && manager.respond_to?(:session_owner) && manager.respond_to?(:resume_session)
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