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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: known-names
3
- version: 0.1.3
3
+ version: 0.1.4
4
4
  scope: system
5
5
  description: Catches a near-miss of a protected name (home folder, login, git name, repo name, names from config) in a tool call and rejects it with the right spelling; mode ask / correct optional
6
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  known_names.md: sha256:d5348b95b6c655017b541910b36e384220da280818160201f6ec9a50e1e4287c
9
9
  hooks:
10
10
  known_names.rb:
11
- sha256: sha256:443777b7d6208ff7b2b668a4510db853826a507bd44ea23ea2428fa4b2df5686
11
+ sha256: sha256:c0845c9b6d04ec884c4641441d8a0ba3482283f4ee3e00c8b3bf99efb0c635b1
12
12
  event: before_tool_call
13
13
  on_error: log
14
14
  priority: 50
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: skills
3
- version: 0.1.4
3
+ version: 0.1.5
4
4
  scope: system
5
5
  description: "Skills (skill_<name> memories, the steps of a task done together): /skill save, list, show and diff; older versions kept with a short diff line on every update; a nudge when a followed skill's step fails"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:cc5b3bcfd571e17074776f48c8d28184475f2a4becd06d70edbacd7381e1f306
10
- requires_chi: ">= 0.9.0"
9
+ sha256: sha256:9bd637f8fa933b03434e4ccdcbf2547aa011993b8044f2739ee7d50f995d2882
10
+ requires_chi: ">= 0.11.0"
@@ -173,10 +173,10 @@ class Plugin
173
173
  nil
174
174
  end
175
175
 
176
+ # A failed step is a call whose status (core's, from the full output) is
177
+ # error: a denied call is blocked, a stopped task_wait stopped.
176
178
  def step_failed(event, ctx)
177
- tool = event[:tool].to_s
178
- output = event[:output].to_s
179
- return if NOT_FAILURES.include?(tool) || guardrail_deny?(tool, output) || !failure?(tool, output)
179
+ return if NOT_FAILURES.include?(event[:tool].to_s) || event[:status] != "error"
180
180
 
181
181
  @failed = true
182
182
  return if @nudged || @read.any? { |name| @written.include?(name) }
@@ -184,26 +184,6 @@ class Plugin
184
184
  @nudged = ctx.steer(format(NUDGE, @read.join(", ")))
185
185
  end
186
186
 
187
- # A denied call, by its first line: a legacy veto, a rule's, hook's or
188
- # core's deny, or the user's (declined, approval cancelled). Core's
189
- # wording (ToolRunner#denied, Verdict#deny_text).
190
- def guardrail_deny?(tool, output)
191
- output.match?(/\A\[#{Regexp.escape(tool)}\] Error: (?:blocked by guardrail: |denied by guardrail \(|[^\n]*? It needed approval \()/)
192
- end
193
-
194
- # A tool error ("[x] Error: …" raised, "[x]\nError: …" returned), or an
195
- # execute that exited non-zero; its "exit: N" line may be cut off by the
196
- # hook's output cap, and then an Error: line near the top counts.
197
- def failure?(tool, output)
198
- return true if output.match?(/\A\[#{Regexp.escape(tool)}\](?: |\n)Error:/)
199
- return false unless tool == "execute"
200
-
201
- exit_line = output.match(/^exit: (\d+)(?: \(no output\))?\s*\z/)
202
- return exit_line[1] != "0" if exit_line
203
-
204
- output.lines.first(20).any? { |line| line.match?(/\A\s*Error:/i) }
205
- end
206
-
207
187
  def after_turn(ctx)
208
188
  changed_elsewhere(ctx)
209
189
  return unless @nudge && @failed
@@ -56,7 +56,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
56
56
 
57
57
  **Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
58
58
 
59
- - `model_aliases:` map of alias → model id (`ConfigFile.resolve_model_alias`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid).
59
+ - `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
60
60
  - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
61
61
  - `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
62
62
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.10.1
3
+ version: 0.12.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
9
- config_modification_protocol.md: sha256:d8bdf062d5afce7430ead7f49439c1d1ed197a6261dc69a7c884f4b814266b12
9
+ config_modification_protocol.md: sha256:dddd083d6f419674e6e0cc39118ca9785583a557e4950af1a9b028e52e8a3e0c
10
10
  memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -90,6 +90,21 @@ module Samagotchi
90
90
  openai_compatible? ? payload.dig("choices", 0, "text").to_s : payload.fetch("content", "")
91
91
  end
92
92
 
93
+ # Why the stream stopped, from its last payload, as an OpenAI finish
94
+ # reason ("length" for the token cap or a full context, "stop"
95
+ # otherwise); nil for a payload that doesn't say. llama.cpp's
96
+ # /completion names it stop_type ("limit", "eos", "word"); the
97
+ # OpenAI-compatible servers send their own finish_reason.
98
+ def finish_reason_from(payload)
99
+ return nil unless payload.is_a?(Hash)
100
+ return payload.dig("choices", 0, "finish_reason") if openai_compatible?
101
+
102
+ case payload["stop_type"]
103
+ when "limit" then "length"
104
+ when "eos", "word" then "stop"
105
+ end
106
+ end
107
+
93
108
  # The request's `model` field for this transport (nil = omit the field):
94
109
  # - llama.cpp: forward the selector (SAMAGOTCHI_DEFAULT_MODEL) verbatim.
95
110
  # - mlx_lm.server: omit `model` entirely (use whatever was loaded via the
@@ -248,7 +263,12 @@ module Samagotchi
248
263
  content, payload = parsed_chunk
249
264
  shown.call unless content.to_s.empty?
250
265
  result << content
251
- on_chunk&.call(content: content, payload: payload)
266
+ next unless on_chunk
267
+
268
+ chunk = { content: content, payload: payload }
269
+ finish_reason = @transport.finish_reason_from(payload)
270
+ chunk[:finish_reason] = finish_reason if finish_reason
271
+ on_chunk.call(**chunk)
252
272
  end
253
273
  result
254
274
  rescue LLM::BadRequest => e
@@ -8,6 +8,7 @@ require "set"
8
8
  require_relative "atomic_file"
9
9
  require_relative "config_text_edit"
10
10
  require_relative "log"
11
+ require_relative "model_ref"
11
12
  require_relative "paths"
12
13
 
13
14
  module Samagotchi
@@ -436,7 +437,44 @@ module Samagotchi
436
437
  else
437
438
  [key_problem(key)].compact
438
439
  end
440
+ end + model_ref_problems(data)
441
+ end
442
+
443
+ # What a model ref in config.yml won't do as written: an alias whose
444
+ # target is another alias (aliases apply once, ModelRef), a ref that
445
+ # starts with "<host>/" (only ':' names a host).
446
+ def model_ref_problems(data)
447
+ hosts = data["hosts"].is_a?(Hash) ? data["hosts"] : {}
448
+ aliases = data["model_aliases"].is_a?(Hash) ? data["model_aliases"] : {}
449
+ default = data["default"]["model"] if data["default"].is_a?(Hash)
450
+ refs = [["default.model", default], *aliases.map { |name, target| ["model_aliases.#{name}", target] }]
451
+ slash = refs.filter_map { |where, ref| host_slash_problem(where, ref, hosts) }
452
+ names = aliases.keys.map { |k| k.to_s.strip.downcase }
453
+ chains = aliases.filter_map do |name, target|
454
+ _, inner = ModelRef.split(target, hosts: hosts)
455
+ next unless names.include?(inner.downcase)
456
+
457
+ "config: model_aliases.#{name} points to the alias '#{inner}'; aliases don't chain, so '#{inner}' is sent as written"
439
458
  end
459
+ slash + chains + hosts.keys.filter_map { |name| family_host_problem(name.to_s) }
460
+ end
461
+
462
+ # Model families whose ids carry a ':' tag on Ollama and friends
463
+ # (qwen3:8b, llama3.1:70b): a host named like one takes those refs.
464
+ MODEL_FAMILY_HOST_RE = /\A(?:qwen|llama|codellama|gemma|mistral|mixtral|deepseek|phi|granite|starcoder|smollm|olmo)[\d.]*\z/i
465
+
466
+ def family_host_problem(name)
467
+ return nil unless name.match?(MODEL_FAMILY_HOST_RE)
468
+
469
+ "config: hosts.#{name} is named like a model family: a ref such as '#{name}:8b' (an Ollama tag) " \
470
+ "goes to host #{name} as '8b'; rename the host if you use such ids"
471
+ end
472
+
473
+ def host_slash_problem(where, ref, hosts)
474
+ prefix, rest = ref.to_s.strip.split("/", 2)
475
+ return nil if rest.to_s.empty? || !hosts.keys.map { |k| k.to_s.downcase }.include?(prefix.downcase)
476
+
477
+ "config: #{where} '#{ref}' starts with the host '#{prefix}' and '/': only ':' names a host now (#{prefix}:#{rest})"
440
478
  end
441
479
 
442
480
  # Dotted keys config.yml may set: config-exposed entries (snake and kebab
@@ -831,28 +869,9 @@ module Samagotchi
831
869
  end
832
870
 
833
871
  # Parse a model string that may be qualified as "host_alias:model"
834
- # Returns [host_alias_or_nil, bare_model]
872
+ # Returns [host_alias_or_nil, bare_model] (ModelRef.split)
835
873
  def parse_host_qualified_model(raw, hosts: nil)
836
- value = raw.to_s.strip
837
- return [nil, value] if value.empty?
838
- # Try split on first ':' or '/' where prefix matches a known host
839
- hosts_map = hosts || {}
840
- # Normalize keys downcase
841
- lowered_keys = hosts_map.keys.map(&:downcase)
842
- # Check ':' split
843
- if value.include?(":")
844
- prefix, rest = value.split(":", 2)
845
- if lowered_keys.include?(prefix.strip.downcase) && !rest.strip.empty?
846
- return [prefix.strip.downcase, rest.strip]
847
- end
848
- end
849
- if value.include?("/")
850
- prefix, rest = value.split("/", 2)
851
- if lowered_keys.include?(prefix.strip.downcase) && !rest.strip.empty?
852
- return [prefix.strip.downcase, rest.strip]
853
- end
854
- end
855
- [nil, value]
874
+ ModelRef.split(raw, hosts: hosts)
856
875
  end
857
876
 
858
877
  # Hosted providers' names, which no local model family uses: as a
@@ -989,25 +1008,10 @@ module Samagotchi
989
1008
  nil
990
1009
  end
991
1010
 
992
- def resolve_model_alias(raw, env: ENV, path: global_path(env: env), hosts: nil)
993
- value = raw.to_s.strip
994
- return value if value.empty?
995
-
996
- aliases = model_aliases(env: env, path: path)
997
- # Host-qualified handling: "host:alias" -> "host:resolved"
998
- # This allows /model small-box:small where "small" is an alias.
999
- if value.include?(":") || value.include?("/")
1000
- hosts_map = hosts || hosts_config(env: env, path: path)
1001
- host, bare = parse_host_qualified_model(value, hosts: hosts_map)
1002
- if host && !bare.to_s.strip.empty?
1003
- resolved_bare = aliases.fetch(bare.downcase, bare)
1004
- # Preserve the separator the user used (: or /)
1005
- sep = value.downcase.include?("#{host}:") ? ":" : (value.downcase.include?("#{host}/") ? "/" : ":")
1006
- return "#{host}#{sep}#{resolved_bare}"
1007
- end
1008
- end
1009
-
1010
- aliases.fetch(value.downcase, value)
1011
+ # +raw+ parsed against config.yml's hosts (or +hosts+) and aliases.
1012
+ # @return [ModelRef]
1013
+ def model_ref(raw, env: ENV, path: global_path(env: env), hosts: nil)
1014
+ ModelRef.parse(raw, hosts: hosts || hosts_config(env: env, path: path), aliases: model_aliases(env: env, path: path))
1011
1015
  end
1012
1016
 
1013
1017
  # Words /model takes as arguments (clear/default/none/off reset the
@@ -5,7 +5,7 @@ require_relative "context_usage"
5
5
  require_relative "context_window"
6
6
 
7
7
  module Samagotchi
8
- # One turn's context tracking for KernelLoop#run: how full the window is
8
+ # One turn's context tracking for either loop: how full the window is
9
9
  # before each request (the server's last prompt count plus an estimate for
10
10
  # what the turn appended since, else a chars-per-token estimate), the
11
11
  # bucket that puts it in, and what comes of that:
@@ -95,18 +95,18 @@ module Samagotchi
95
95
 
96
96
  def enabled? = @enabled
97
97
 
98
- # Before a request: estimate +prompt+'s usage against +window+ (this
99
- # request's ContextWindow::Resolved) and update the status line's
100
- # value. A rise into a bucket that asks the model for a change leaves
101
- # a line for #take_guidance.
102
- # +image_tokens+: the images' estimate (their base64 is not in +prompt+).
98
+ # Before a request: estimate the usage of a prompt of +prompt_chars+
99
+ # against +window+ (this request's ContextWindow::Resolved) and update
100
+ # the status line's value. A rise into a bucket that asks the model for
101
+ # a change leaves a line for #take_guidance.
102
+ # +image_tokens+: the images' estimate (their base64 is not in the chars).
103
103
  #
104
104
  # @return [Hash, nil] the :context_status event's fields (without
105
105
  # :type/:iteration) when the emit gate fires, else nil
106
- def observe(prompt, iteration_index:, window:, image_tokens: 0)
106
+ def observe(prompt_chars, iteration_index:, window:, image_tokens: 0)
107
107
  return nil unless @enabled
108
108
 
109
- usage = estimate(prompt, server_usage: @server_usage, window: window, image_tokens: image_tokens, counted: @counted)
109
+ usage = estimate(prompt_chars, server_usage: @server_usage, window: window, image_tokens: image_tokens, counted: @counted)
110
110
  bucket = bucket_for(usage[:estimated_pct])
111
111
  # The status line's value, every iteration; the gate below decides
112
112
  # only the event and the model's guidance line.
@@ -135,7 +135,8 @@ module Samagotchi
135
135
  normalized
136
136
  end
137
137
 
138
- # After a generation: +usage+ is its own server counts (nil: none), for
138
+ # After a generation: +usage+ is its own server counts ({prompt_tokens:,
139
+ # total_tokens:, context_window_tokens:}; nil: none), for
139
140
  # +prompt_chars+/+image_tokens+ as sent. The next estimate adds only
140
141
  # what the turn appended since (answer, tool results), and the status
141
142
  # line's value counts the answer. Without counts the pre-generation
@@ -161,21 +162,21 @@ module Samagotchi
161
162
  # `window` is this request's ContextWindow::Resolved. A window the
162
163
  # stream payload reports itself still wins.
163
164
  # +counted+: the prompt the server's prompt_tokens counted ({chars:,
164
- # image_tokens:}); what +prompt+ has on top of it (the answer, tool
165
+ # image_tokens:}); what the prompt has on top of it (the answer, tool
165
166
  # results since) is added as an estimate, so the value doesn't read low
166
167
  # during a long tool loop. A prompt shorter than that one (trimmed) or
167
168
  # none known: the server's count alone.
168
- def estimate(prompt, window:, server_usage: nil, image_tokens: 0, counted: nil)
169
+ def estimate(prompt_chars, window:, server_usage: nil, image_tokens: 0, counted: nil)
169
170
  window_source = window.source
170
171
  window_source = :server if server_usage && server_usage[:context_window_tokens]
171
172
 
172
173
  if server_usage && server_usage[:prompt_tokens]
173
174
  window_tokens = server_usage[:context_window_tokens] || window.tokens
174
- used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt, image_tokens, counted)
175
+ used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt_chars, image_tokens, counted)
175
176
  source = "server"
176
177
  else
177
178
  window_tokens = window.tokens
178
- used_tokens = (prompt.length / @chars_per_token).ceil + image_tokens
179
+ used_tokens = (prompt_chars / @chars_per_token).ceil + image_tokens
179
180
  source = "estimate"
180
181
  end
181
182
 
@@ -191,11 +192,11 @@ module Samagotchi
191
192
 
192
193
  private
193
194
 
194
- # The estimate for what +prompt+ added since the +counted+ one.
195
- def appended_tokens(prompt, image_tokens, counted)
195
+ # The estimate for what the prompt added since the +counted+ one.
196
+ def appended_tokens(prompt_chars, image_tokens, counted)
196
197
  return 0 unless counted
197
198
 
198
- chars = prompt.length - counted[:chars]
199
+ chars = prompt_chars - counted[:chars]
199
200
  return 0 unless chars.positive?
200
201
 
201
202
  (chars / @chars_per_token).ceil + [image_tokens - counted[:image_tokens].to_i, 0].max
@@ -3,7 +3,8 @@
3
3
  // the clipboard (a screenshot too). No Dock icon (LSUIElement); it stays
4
4
  // running after the first launch. `ChiHelper --login on|off|status` (run
5
5
  // directly by chi desktop) manages the login item and exits;
6
- // `ChiHelper --kitty list|send` is the kitty targets' seam (Kitty.swift).
6
+ // `ChiHelper --kitty list|send` is the kitty targets' seam (Kitty.swift),
7
+ // `ChiHelper --model-pick` the model chooser's (Models.swift).
7
8
  import AppKit
8
9
  import ServiceManagement
9
10
 
@@ -101,6 +102,9 @@ struct ChiHelperMain {
101
102
  if args.count >= 2, args[1] == "--kitty" {
102
103
  exit(kittyCommand(Array(args.dropFirst(2))))
103
104
  }
105
+ if args.count >= 2, args[1] == "--model-pick" {
106
+ exit(modelPickCommand(Array(args.dropFirst(2))))
107
+ }
104
108
  let app = NSApplication.shared
105
109
  let delegate = AppDelegate()
106
110
  app.delegate = delegate
@@ -147,6 +147,25 @@ final class ChiRunner {
147
147
  }
148
148
  }
149
149
 
150
+ /// `chi models --format json`: every host's models and the aliases. Exit
151
+ /// 1 (no host listed) still prints the default and the warnings. 10 s:
152
+ /// the boot and chi's own 4 s cap on the hosts, with a margin.
153
+ func models(completion: @escaping (Result<ModelList, ChiError>) -> Void) {
154
+ run(["models", "--format", "json"], timeout: 10) { result in
155
+ switch result {
156
+ case .failure(let error): completion(.failure(error))
157
+ case .success(let r):
158
+ if r.timedOut { completion(.failure(.failed("chi models took over 10 s"))); return }
159
+ guard r.status == 0 || r.status == 1, let list = ModelList.decode(Data(r.stdout.utf8)) else {
160
+ let detail = r.stderr.isEmpty ? r.stdout : r.stderr
161
+ completion(.failure(.failed("chi models failed (exit \(r.status)): \(detail.prefix(300))")))
162
+ return
163
+ }
164
+ completion(.success(list))
165
+ }
166
+ }
167
+ }
168
+
150
169
  private func list(_ flags: [String], completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
151
170
  // Every project's sessions: the helper isn't in any one project (an
152
171
  // older chi ignores the flag).
@@ -0,0 +1,238 @@
1
+ // The new session's model: `chi models --format json` (every host's
2
+ // listing and the aliases, the names `--model` takes) and the chooser's pure
3
+ // rules: its rows, search ranking, the remembered pick and the recent picks.
4
+ // The helper never builds or rewrites a name: what a row says is what
5
+ // `chi send --new --model` gets. `ChiHelper --model-pick` is the build
6
+ // spec's seam (stdin: the payload).
7
+ import Foundation
8
+
9
+ /// `chi models --format json`; printed on exit 1 too (no host listed: the
10
+ /// default alone).
11
+ struct ModelList: Decodable, Equatable {
12
+ struct Model: Decodable, Equatable {
13
+ let name: String
14
+ let host: String
15
+ let id: String
16
+ /// An alias of the same name takes it elsewhere: not offered.
17
+ let shadowedBy: String?
18
+ }
19
+
20
+ struct Alias: Decodable, Equatable {
21
+ let name: String
22
+ let ref: String
23
+ let host: String?
24
+ }
25
+
26
+ let `default`: String?
27
+ let defaultTyped: String?
28
+ let defaultHost: String?
29
+ let models: [Model]
30
+ let aliases: [Alias]?
31
+ let warnings: [String]?
32
+
33
+ static func decode(_ data: Data) -> ModelList? {
34
+ let decoder = JSONDecoder()
35
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
36
+ return try? decoder.decode(ModelList.self, from: data)
37
+ }
38
+
39
+ /// "default (small → box:gemma)" style: the default as the row shows it.
40
+ var defaultLabel: String? {
41
+ guard let name = `default` else { return nil }
42
+ if let typed = defaultTyped, !typed.isEmpty { return "\(typed) → \(name)" }
43
+ return name
44
+ }
45
+ }
46
+
47
+ /// One line of the chooser. +name+ is what `--model` gets (the default row's
48
+ /// is the default's name, for show: picking it sends no `--model`).
49
+ struct ModelRow: Equatable {
50
+ enum Kind: String { case standard = "default", listed, alias, typed }
51
+
52
+ let kind: Kind
53
+ let name: String
54
+ /// The host that listed it ("" when unknown: a recent pick before the list).
55
+ let host: String
56
+ /// What the search looks at besides the host: the id, an alias's name
57
+ /// and target, a typed name.
58
+ let id: String
59
+ let label: String
60
+ }
61
+
62
+ enum ModelPick {
63
+ static let pickKey = "newSessionModel"
64
+ static let recentKey = "recentModels"
65
+ static let maxRecent = 5
66
+ /// ⌘1…⌘9.
67
+ static let maxRows = 9
68
+
69
+ static func same(_ a: String, _ b: String) -> Bool { a.caseInsensitiveCompare(b) == .orderedSame }
70
+
71
+ /// Every name the list offers, default host first, an alias after its
72
+ /// host's ids; the default itself left out (it is the Default row).
73
+ static func rows(_ list: ModelList) -> [ModelRow] {
74
+ let defaultHost = list.defaultHost ?? ""
75
+ let listed = list.models.filter { $0.shadowedBy == nil }
76
+ var hosts: [String] = []
77
+ for host in listed.map(\.host) + (list.aliases ?? []).compactMap(\.host) where !hosts.contains(host) {
78
+ hosts.append(host)
79
+ }
80
+ hosts = hosts.filter { $0 == defaultHost } + hosts.filter { $0 != defaultHost }
81
+ var rows: [ModelRow] = []
82
+ for host in hosts {
83
+ for model in listed where model.host == host {
84
+ rows.append(ModelRow(kind: .listed, name: model.name, host: host, id: model.id,
85
+ label: host == defaultHost ? model.id : "\(host) · \(model.id)"))
86
+ }
87
+ for alias in list.aliases ?? [] where alias.host == host {
88
+ rows.append(ModelRow(kind: .alias, name: alias.name, host: host, id: "\(alias.name) \(alias.ref)",
89
+ label: "\(alias.name) → \(alias.ref)"))
90
+ }
91
+ }
92
+ for alias in list.aliases ?? [] where alias.host == nil {
93
+ rows.append(ModelRow(kind: .alias, name: alias.name, host: "", id: "\(alias.name) \(alias.ref)",
94
+ label: "\(alias.name) → \(alias.ref)"))
95
+ }
96
+ guard let standard = list.default else { return rows }
97
+ return rows.filter { !same($0.name, standard) }
98
+ }
99
+
100
+ /// The chooser's rows, at most maxRows: Default first; with no query
101
+ /// the recent picks still offered (as they are while the list loads),
102
+ /// then the list; with one, the ranked matches and, when no row is named
103
+ /// exactly that, the typed name ("not listed").
104
+ static func chooserRows(list: ModelList?, fallbackDefault: String?, recents: [String], query: String) -> [ModelRow] {
105
+ let standardName = list?.default ?? fallbackDefault ?? ""
106
+ let standard = ModelRow(kind: .standard, name: standardName, host: list?.defaultHost ?? "", id: standardName,
107
+ label: standardName.isEmpty ? "Default" : "Default (\(list?.defaultLabel ?? standardName))")
108
+ let all = list.map(rows) ?? []
109
+ let text = query.trimmingCharacters(in: .whitespacesAndNewlines)
110
+ var out: [ModelRow] = [standard]
111
+ if text.isEmpty {
112
+ for name in recents where !(list != nil && same(name, standardName)) {
113
+ if let row = all.first(where: { same($0.name, name) }) {
114
+ out.append(row)
115
+ } else if list == nil {
116
+ out.append(ModelRow(kind: .listed, name: name, host: "", id: name, label: name))
117
+ }
118
+ }
119
+ for row in all where !out.contains(where: { same($0.name, row.name) }) { out.append(row) }
120
+ return Array(out.prefix(maxRows))
121
+ }
122
+ let exact = all.contains { same($0.name, text) } || same(text, standardName)
123
+ let room = maxRows - 1 - (exact ? 0 : 1)
124
+ out += match(all, query: text).prefix(room)
125
+ if !exact {
126
+ out.append(ModelRow(kind: .typed, name: text, host: "", id: text, label: "\(text) — not listed"))
127
+ }
128
+ return out
129
+ }
130
+
131
+ /// Rows matching every word of +query+ on the host or the id, ranked
132
+ /// like the web's picker: substring-only rows first (at a segment start
133
+ /// cheaper), then subsequences of 3+ characters by their gaps, then the
134
+ /// shorter id, then list order.
135
+ static func match(_ rows: [ModelRow], query: String) -> [ModelRow] {
136
+ let words = query.lowercased().split(whereSeparator: \.isWhitespace).map(String.init)
137
+ guard !words.isEmpty else { return [] }
138
+ var found: [(row: ModelRow, index: Int, sub: Bool, cost: Int)] = []
139
+ for (index, row) in rows.enumerated() {
140
+ let host = Array(row.host.lowercased()), id = Array(row.id.lowercased())
141
+ var sub = true, cost = 0, ok = true
142
+ for word in words {
143
+ let w = Array(word)
144
+ let onId = matchWord(w, id), onHost = matchWord(w, host)
145
+ guard let pick = better(onHost, than: onId) ? onHost : onId else { ok = false; break }
146
+ sub = sub && pick.sub
147
+ cost += pick.cost
148
+ }
149
+ if ok { found.append((row, index, sub, cost)) }
150
+ }
151
+ found.sort { a, b in
152
+ if a.sub != b.sub { return a.sub }
153
+ if a.cost != b.cost { return a.cost < b.cost }
154
+ if a.row.id.count != b.row.id.count { return a.row.id.count < b.row.id.count }
155
+ return a.index < b.index
156
+ }
157
+ return found.map(\.row)
158
+ }
159
+
160
+ private static let separators = Set("/-:._ ")
161
+
162
+ private static func matchWord(_ word: [Character], _ text: [Character]) -> (sub: Bool, cost: Int)? {
163
+ guard !word.isEmpty, word.count <= text.count else { return nil }
164
+ var best: (sub: Bool, cost: Int)?
165
+ for i in 0...(text.count - word.count) where Array(text[i..<(i + word.count)]) == word {
166
+ let cost = i == 0 || separators.contains(text[i - 1]) ? 0 : 2
167
+ if best == nil || cost < best!.cost { best = (true, cost) }
168
+ if cost == 0 { break }
169
+ }
170
+ if best != nil || word.count < 3 { return best }
171
+ guard var at = text.firstIndex(of: word[0]) else { return nil }
172
+ var gaps = 0
173
+ for k in 1..<word.count {
174
+ guard let i = text[(at + 1)...].firstIndex(of: word[k]) else { return nil }
175
+ gaps += i - at - 1
176
+ at = i
177
+ }
178
+ return (false, 10 + gaps)
179
+ }
180
+
181
+ private static func better(_ a: (sub: Bool, cost: Int)?, than b: (sub: Bool, cost: Int)?) -> Bool {
182
+ guard let a else { return false }
183
+ guard let b else { return true }
184
+ return (a.sub && !b.sub) || (a.sub == b.sub && a.cost < b.cost)
185
+ }
186
+
187
+ /// The remembered pick for this open: kept while the list loads, and
188
+ /// once it is in, only when offered (as the list spells it) or when no
189
+ /// host listed anything (no way to tell); else the default, with a note.
190
+ static func keep(_ stored: String?, in list: ModelList?) -> (pick: String?, note: String?) {
191
+ guard let stored = stored?.trimmingCharacters(in: .whitespacesAndNewlines), !stored.isEmpty else { return (nil, nil) }
192
+ guard let list else { return (stored, nil) }
193
+ if let standard = list.default, same(standard, stored) { return (nil, nil) }
194
+ if let row = rows(list).first(where: { same($0.name, stored) }) { return (row.name, nil) }
195
+ if list.models.isEmpty { return (stored, nil) }
196
+ return (nil, "\(stored) isn't listed any more; using the default")
197
+ }
198
+
199
+ /// The recent picks after +name+: it first, no other copy (any case).
200
+ static func pushRecent(_ list: [String], _ name: String) -> [String] {
201
+ Array(([name] + list.filter { !same($0, name) }).prefix(maxRecent))
202
+ }
203
+ }
204
+
205
+ /// `ChiHelper --model-pick [--query Q] [--recent NAME]... [--stored NAME]
206
+ /// [--push NAME]`: the payload on stdin, the chooser as JSON on stdout
207
+ /// (rows, the kept pick and its note, the recents after --push). Exit 1
208
+ /// when stdin isn't a payload.
209
+ func modelPickCommand(_ args: [String]) -> Int32 {
210
+ var args = args
211
+ var query = "", recents: [String] = [], stored: String?, push: String?
212
+ while !args.isEmpty {
213
+ let arg = args.removeFirst()
214
+ switch arg {
215
+ case "--query" where !args.isEmpty: query = args.removeFirst()
216
+ case "--recent" where !args.isEmpty: recents.append(args.removeFirst())
217
+ case "--stored" where !args.isEmpty: stored = args.removeFirst()
218
+ case "--push" where !args.isEmpty: push = args.removeFirst()
219
+ default:
220
+ FileHandle.standardError.write("usage: ChiHelper --model-pick [--query Q] [--recent NAME]... [--stored NAME] [--push NAME]\n".data(using: .utf8)!)
221
+ return 2
222
+ }
223
+ }
224
+ guard let list = ModelList.decode(FileHandle.standardInput.readDataToEndOfFile()) else {
225
+ FileHandle.standardError.write("not a chi models payload\n".data(using: .utf8)!)
226
+ return 1
227
+ }
228
+ let rows = ModelPick.chooserRows(list: list, fallbackDefault: nil, recents: recents, query: query)
229
+ let kept = ModelPick.keep(stored, in: list)
230
+ var out: [String: Any] = [
231
+ "rows": rows.map { ["kind": $0.kind.rawValue, "name": $0.name, "label": $0.label] },
232
+ "pick": kept.pick ?? NSNull(), "note": kept.note ?? NSNull(), "warnings": list.warnings ?? [],
233
+ ]
234
+ if let push { out["recent"] = ModelPick.pushRecent(recents, push) }
235
+ let data = try! JSONSerialization.data(withJSONObject: out, options: [.sortedKeys])
236
+ print(String(decoding: data, as: UTF8.self))
237
+ return 0
238
+ }