samagotchi 0.11.0 → 0.13.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -1
  3. data/README.md +20 -7
  4. data/bin/chi +10 -4
  5. data/docs/architecture.md +8 -6
  6. data/docs/cli.md +59 -11
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +34 -8
  9. data/docs/guardrails.md +73 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +4 -4
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge.rb +15 -5
  17. data/lib/samagotchi/bridge_client.rb +8 -3
  18. data/lib/samagotchi/bundle_command.rb +18 -10
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/cli/command.rb +3 -2
  24. data/lib/samagotchi/cli/exit.rb +23 -0
  25. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  26. data/lib/samagotchi/config.rb +50 -18
  27. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  28. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  29. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  30. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  31. data/lib/samagotchi/desktop_command.rb +5 -3
  32. data/lib/samagotchi/engine.rb +480 -491
  33. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  34. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  35. data/lib/samagotchi/host_registry.rb +26 -3
  36. data/lib/samagotchi/idle_reminders.rb +13 -36
  37. data/lib/samagotchi/kernel_loop.rb +82 -120
  38. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  39. data/lib/samagotchi/llm/model_result.rb +14 -20
  40. data/lib/samagotchi/llm/native_backend.rb +3 -21
  41. data/lib/samagotchi/llm/openai_chat.rb +10 -2
  42. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  43. data/lib/samagotchi/llm/usage.rb +2 -57
  44. data/lib/samagotchi/memory_bundle/installer.rb +13 -1
  45. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  46. data/lib/samagotchi/memory_paths.rb +8 -0
  47. data/lib/samagotchi/model_catalog.rb +101 -0
  48. data/lib/samagotchi/model_profile.rb +31 -17
  49. data/lib/samagotchi/models_command.rb +112 -0
  50. data/lib/samagotchi/parent_report.rb +213 -0
  51. data/lib/samagotchi/plugin/api.rb +15 -2
  52. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  53. data/lib/samagotchi/question_desk.rb +29 -2
  54. data/lib/samagotchi/recap_setup.rb +15 -5
  55. data/lib/samagotchi/reminder_queue.rb +73 -0
  56. data/lib/samagotchi/reminder_store.rb +3 -3
  57. data/lib/samagotchi/reply_wait.rb +5 -1
  58. data/lib/samagotchi/self_report.rb +8 -1
  59. data/lib/samagotchi/send_command.rb +51 -76
  60. data/lib/samagotchi/session.rb +15 -0
  61. data/lib/samagotchi/session_commands.rb +5 -5
  62. data/lib/samagotchi/session_manager.rb +36 -13
  63. data/lib/samagotchi/sessions_command.rb +13 -3
  64. data/lib/samagotchi/system_prompt.rb +10 -2
  65. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  66. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  67. data/lib/samagotchi/terminal_ui.rb +23 -29
  68. data/lib/samagotchi/thinking.rb +1 -1
  69. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  70. data/lib/samagotchi/tool_call_parser.rb +5 -6
  71. data/lib/samagotchi/tool_runner.rb +4 -6
  72. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  73. data/lib/samagotchi/tools/builtins.rb +15 -3
  74. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  75. data/lib/samagotchi/tools/execute.rb +8 -8
  76. data/lib/samagotchi/tools/task_create.rb +3 -2
  77. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  78. data/lib/samagotchi/turn_flow.rb +4 -4
  79. data/lib/samagotchi/turn_state.rb +126 -0
  80. data/lib/samagotchi/used_memories.rb +88 -0
  81. data/lib/samagotchi/version.rb +1 -1
  82. data/lib/samagotchi/vision_support.rb +4 -4
  83. data/lib/samagotchi/web/app.rb +17 -35
  84. data/lib/samagotchi/web/message_parts.rb +2 -1
  85. data/lib/samagotchi/web/server.rb +2 -1
  86. data/lib/samagotchi/web/session_hub.rb +0 -4
  87. data/lib/samagotchi/web/session_summary.rb +8 -16
  88. data/lib/samagotchi/worker.rb +1 -1
  89. metadata +14 -1
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../reply_wait"
5
+ require_relative "../parent_report"
6
+ require_relative "exit"
7
+
8
+ module Samagotchi
9
+ module CLI
10
+ # The parent-agent side shared by `chi send --wait` and `chi answer`:
11
+ # --format json's one object whatever the end, the session id resolved
12
+ # for it, and the wait for what comes next, reported the ParentReport
13
+ # way (Ctrl-C included). The including class (a CLI::Command) defines
14
+ # #parse (options, or an exit status) and #run_parsed(options), sets
15
+ # @stdout, @stderr and @state_dir, and may override the constants
16
+ # (specs stub them on the class).
17
+ module ParentWait
18
+ POLL_INTERVAL = ReplyWait::POLL_INTERVAL
19
+ # No live worker this long while waiting: it died before it could mark
20
+ # the session (a worker takes well under a second to start).
21
+ WORKER_GONE_AFTER = 5
22
+ FORMATS = %w[text json].freeze
23
+
24
+ # @return [Integer] the exit status
25
+ def run
26
+ options = parse
27
+ return options if options.is_a?(Integer)
28
+
29
+ @json = options[:format] == "json"
30
+ # With --format json stdout is one JSON object, whatever the end: a
31
+ # failure before the wait is reported from stderr's last line.
32
+ @stderr = ParentReport::LastLine.new(@stderr) if @json
33
+ status = run_parsed(options)
34
+ if @json && !@reported && status == 1
35
+ detail = @stderr.last.to_s.delete_prefix("#{command_name}: ")
36
+ @stdout.puts(ParentReport.error_line(detail, session_id: @session_id))
37
+ @stdout.flush
38
+ end
39
+ status
40
+ end
41
+
42
+ private
43
+
44
+ # The full id of +given+ (an id or a prefix), kept for the JSON
45
+ # error line; nil after the error line.
46
+ # @return [String, nil]
47
+ def resolve(given)
48
+ id = Session.resolve_id(given, state_dir: @state_dir)
49
+ Session.load(id, state_dir: @state_dir)
50
+ @session_id = id
51
+ rescue ArgumentError => e
52
+ error_line("#{command_name}: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
53
+ nil
54
+ end
55
+
56
+ # Wait for the session's next reply past +cursor+ (ReplyWait) and
57
+ # report it. Ctrl-C leaves the turn running (130).
58
+ # @param owner_grace [Numeric, nil] seconds with no live worker before
59
+ # it counts as gone; nil waits for whatever wakes one
60
+ # @return [Integer] the exit status
61
+ def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER)
62
+ result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
63
+ owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL)
64
+ result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
65
+ @reported = true
66
+ ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
67
+ json: @json, timeout: timeout)
68
+ rescue Interrupt
69
+ if @json
70
+ @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id))
71
+ @stdout.flush
72
+ @reported = true
73
+ end
74
+ error_line("#{command_name}: still running: chi --attach #{id}")
75
+ Exit::INTERRUPTED
76
+ rescue ArgumentError
77
+ error_line("#{command_name}: the session is gone (deleted while waiting)")
78
+ Exit::FAILED
79
+ end
80
+ end
81
+ end
82
+ end
@@ -104,10 +104,14 @@ module Samagotchi
104
104
  Entry.new(key: "image.max_bytes", yaml_path: %w[image max_bytes], type: :integer, default: 3_750_000, expose: %i[env config]),
105
105
  Entry.new(key: "image.max_per_request", yaml_path: %w[image max_per_request], type: :integer, default: 20, expose: %i[env config]),
106
106
 
107
- Entry.new(key: "guardrails.enabled", yaml_path: %w[guardrails enabled], type: :bool, default: true, expose: %i[env config]),
107
+ # The guardrails settings are config.yml only: a parent agent must not switch a child's guardrails off from its
108
+ # own environment (SAMAGOTCHI_GUARDRAILS_ENABLED=false). SessionManager.spawn_options unsets them for a worker too.
109
+ Entry.new(key: "guardrails.enabled", yaml_path: %w[guardrails enabled], type: :bool, default: true, expose: %i[config]),
108
110
  # Which models get the rules with `models: small` (Guardrails::ModelSize): auto (32B or less by the name's size, an MoE's
109
111
  # active size counting), globs on the bare model name or its key, "|"-separated (a YAML list works too), or "" ([]) for none.
110
- Entry.new(key: "guardrails.small_models", yaml_path: %w[guardrails small_models], type: :string, default: "auto", expose: %i[env config]),
112
+ Entry.new(key: "guardrails.small_models", yaml_path: %w[guardrails small_models], type: :string, default: "auto", expose: %i[config]),
113
+ # What `chi answer` lets a parent agent allow on an approval: off (deny only) or once ("Allow once", never a wider scope).
114
+ Entry.new(key: "guardrails.parent_approvals", yaml_path: %w[guardrails parent_approvals], type: :enum, default: "off", expose: %i[config], enum_values: %w[off once]),
111
115
 
112
116
  Entry.new(key: "log.file", yaml_path: %w[log file], type: :string, default: nil, expose: %i[env config cli]),
113
117
  Entry.new(key: "log.disable", yaml_path: %w[log disable], type: :bool, default: false, expose: %i[env config cli]),
@@ -156,6 +160,8 @@ module Samagotchi
156
160
  Entry.new(key: "execute.truncate_at_bytes", yaml_path: %w[execute truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
157
161
  Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
158
162
  Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
163
+ # Seconds one execute command may run before it is stopped (Tools::Execute::TIMEOUT_SEC).
164
+ Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 30, expose: %i[env config cli]),
159
165
 
160
166
  Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
161
167
  Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
@@ -695,18 +701,7 @@ module Samagotchi
695
701
 
696
702
  def hosts_config(env: ENV, path: global_path(env: env))
697
703
  data = read_yaml(env: env, path: path)
698
- raw_hosts = data[HOSTS_KEY] if data.is_a?(Hash)
699
-
700
- # ENV override: SAMAGOTCHI_HOSTS_JSON (used to propagate to workers)
701
- env_json = env["SAMAGOTCHI_HOSTS_JSON"].to_s.strip
702
- unless env_json.empty?
703
- begin
704
- parsed_env = JSON.parse(env_json)
705
- raw_hosts = parsed_env if parsed_env.is_a?(Hash)
706
- rescue StandardError
707
- nil
708
- end
709
- end
704
+ raw_hosts = hosts_source(data, env)
710
705
 
711
706
  normalized = {}
712
707
  if raw_hosts.is_a?(Hash)
@@ -726,10 +721,8 @@ module Samagotchi
726
721
  port = raw_cfg["port"] || raw_cfg[:port]
727
722
  transport = raw_cfg["transport"] || raw_cfg[:transport]
728
723
  api = raw_cfg["api"] || raw_cfg[:api]
729
- enabled = raw_cfg.key?("enabled") ? raw_cfg["enabled"] : (raw_cfg.key?(:enabled) ? raw_cfg[:enabled] : true)
730
- if enabled == false || enabled.to_s.strip.downcase == "false"
731
- next
732
- end
724
+ next if host_disabled?(raw_cfg)
725
+
733
726
  url = (raw_cfg["url"] || raw_cfg[:url]).to_s.strip
734
727
  api_key_env = (raw_cfg["api_key_env"] || raw_cfg[:api_key_env]).to_s.strip
735
728
  # Kept as written; ModelProfile.resolve warns about an unknown one.
@@ -822,6 +815,42 @@ module Samagotchi
822
815
  {}
823
816
  end
824
817
 
818
+ # The names (lowercased) of the hosts entries with enabled: false, which
819
+ # hosts_config leaves out. Read from the same source: config.yml's
820
+ # hosts:, or SAMAGOTCHI_HOSTS_JSON in a worker.
821
+ # @return [Array<String>]
822
+ def disabled_host_names(env: ENV, path: global_path(env: env))
823
+ raw = hosts_source(read_yaml(env: env, path: path), env)
824
+ return [] unless raw.is_a?(Hash)
825
+
826
+ raw.filter_map { |name, cfg| name.to_s.strip.downcase if cfg.is_a?(Hash) && host_disabled?(cfg) }
827
+ rescue StandardError
828
+ []
829
+ end
830
+
831
+ # config.yml's hosts: as written, or the SAMAGOTCHI_HOSTS_JSON override
832
+ # a worker inherits from its parent.
833
+ def hosts_source(data, env)
834
+ raw = data[HOSTS_KEY] if data.is_a?(Hash)
835
+ env_json = env["SAMAGOTCHI_HOSTS_JSON"].to_s.strip
836
+ return raw if env_json.empty?
837
+
838
+ parsed = begin
839
+ JSON.parse(env_json)
840
+ rescue StandardError
841
+ nil
842
+ end
843
+ parsed.is_a?(Hash) ? parsed : raw
844
+ end
845
+ private_class_method :hosts_source
846
+
847
+ # enabled: false (or "false", any case) in a hosts entry.
848
+ def host_disabled?(cfg)
849
+ enabled = cfg.key?("enabled") ? cfg["enabled"] : (cfg.key?(:enabled) ? cfg[:enabled] : true)
850
+ enabled == false || enabled.to_s.strip.downcase == "false"
851
+ end
852
+ private_class_method :host_disabled?
853
+
825
854
  # Resolve the `recap:` section through the Config registry so scalar
826
855
  # recap settings share the single precedence path (CLI > ENV > file >
827
856
  # default). Returns false when explicitly disabled (file `recap: false`
@@ -908,6 +937,9 @@ module Samagotchi
908
937
  "profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
909
938
  "vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s).compact
910
939
  end
940
+ # Disabled hosts travel as just that, so a worker refuses "box:x"
941
+ # the way its parent does instead of sending it to the default host.
942
+ disabled_host_names(env: env, path: path).grep(HOST_NAME_RE).each { |name| simple[name] ||= { "enabled" => false } }
911
943
  JSON.generate(simple)
912
944
  rescue StandardError
913
945
  nil
@@ -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
+ }