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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +90 -1
- data/README.md +20 -7
- data/bin/chi +10 -4
- data/docs/architecture.md +8 -6
- data/docs/cli.md +59 -11
- data/docs/configuration.md +29 -8
- data/docs/desktop.md +34 -8
- data/docs/guardrails.md +73 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +4 -4
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge.rb +15 -5
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +50 -18
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +480 -491
- data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +26 -3
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +82 -120
- data/lib/samagotchi/llm/chat_loop.rb +17 -38
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +10 -2
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +13 -1
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +31 -17
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/self_report.rb +8 -1
- data/lib/samagotchi/send_command.rb +51 -76
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_commands.rb +5 -5
- data/lib/samagotchi/session_manager.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +10 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
- data/lib/samagotchi/terminal_ui.rb +23 -29
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tool_runner.rb +4 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +15 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +17 -35
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- 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
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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
|
-
|
|
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[
|
|
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
|
|
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
|
-
|
|
730
|
-
|
|
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
|
+
}
|