samagotchi 0.2.0 → 0.3.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 +116 -1
- data/README.md +40 -4
- data/bin/chi +86 -19
- data/docs/cli.md +108 -5
- data/docs/configuration.md +229 -45
- data/docs/desktop.md +6 -0
- data/docs/hooks.md +126 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +18 -8
- data/docs/sessions.md +30 -4
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +72 -13
- data/lib/samagotchi/config.rb +196 -36
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
- data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +233 -36
- data/lib/samagotchi/guardrails/approval.rb +9 -0
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/kernel_loop.rb +47 -15
- data/lib/samagotchi/llm/chat_loop.rb +59 -20
- data/lib/samagotchi/llm/errors.rb +21 -3
- data/lib/samagotchi/llm/http.rb +42 -13
- data/lib/samagotchi/llm/openai_chat.rb +12 -4
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +1 -0
- data/lib/samagotchi/send_command.rb +153 -7
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +11 -2
- data/lib/samagotchi/session_manager.rb +114 -9
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +77 -4
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -6
- data/lib/samagotchi/web/app.rb +88 -4
- data/lib/samagotchi/web/public/activity.js +10 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +437 -88
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +10 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +21 -6
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +38 -2
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +46 -0
- data/lib/samagotchi/web/public/turn_view.js +47 -7
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +11 -0
- metadata +20 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
require_relative "model_profile"
|
|
6
|
+
require_relative "bootstrap/probe"
|
|
7
|
+
require_relative "bootstrap/config_writer"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
# `chi bootstrap [TARGET]`: the first setup. It names a model server, works
|
|
11
|
+
# out what it is (llama.cpp's native API or an OpenAI-compatible one),
|
|
12
|
+
# picks the model, sends one test request and writes config.yml: a fresh
|
|
13
|
+
# file, or a hosts: entry added to an existing one. Prompts only on a
|
|
14
|
+
# terminal; a script gets the list and exit 2 instead.
|
|
15
|
+
class BootstrapCommand
|
|
16
|
+
USAGE = <<~TEXT
|
|
17
|
+
Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--no-test] [--dry-run]
|
|
18
|
+
Finds the model server at TARGET and writes config.yml for it.
|
|
19
|
+
chi bootstrap 192.168.1.29:8081 llama.cpp on the LAN
|
|
20
|
+
chi bootstrap localhost:11434 Ollama
|
|
21
|
+
chi bootstrap https://openrouter.ai/api/v1 --key-env OPENROUTER_API_KEY
|
|
22
|
+
chi bootstrap look on this machine's usual ports
|
|
23
|
+
TARGET host[:port] (port 8080 when none), or a URL; a URL's
|
|
24
|
+
path is the OpenAI API base
|
|
25
|
+
--name NAME the hosts entry's name (default: local, lan, or the
|
|
26
|
+
domain's name)
|
|
27
|
+
--model ID the model, when the server has several
|
|
28
|
+
--key-env VAR the environment variable holding the API key (the
|
|
29
|
+
key itself is never written)
|
|
30
|
+
--no-test skip the test request
|
|
31
|
+
--dry-run show what would be written; write nothing
|
|
32
|
+
With no config.yml it writes one; an existing one gets a hosts:
|
|
33
|
+
entry added (after a backup), and its other lines stay as they are.
|
|
34
|
+
TEXT
|
|
35
|
+
|
|
36
|
+
PICK_SHOWN = 20
|
|
37
|
+
# A test request slower than this says it is waiting (a model loading).
|
|
38
|
+
LOADING_AFTER = 3
|
|
39
|
+
|
|
40
|
+
# @param argv [Array<String>] the arguments after "bootstrap"
|
|
41
|
+
# @param probe [Bootstrap::Probe, nil] (specs)
|
|
42
|
+
def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, env: ENV, probe: nil, config_path: nil,
|
|
43
|
+
platform: RbConfig::CONFIG["host_os"])
|
|
44
|
+
@argv = argv.dup
|
|
45
|
+
@stdin = stdin
|
|
46
|
+
@stdout = stdout
|
|
47
|
+
@stderr = stderr
|
|
48
|
+
@env = env
|
|
49
|
+
@probe = probe || Bootstrap::Probe.new(env: env)
|
|
50
|
+
@config_path = config_path || ConfigFile.global_path(env: env)
|
|
51
|
+
@platform = platform
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Integer] 0 written (or already there), 1 failed (a failed
|
|
55
|
+
# test still writes the config), 2 usage or a choice to make
|
|
56
|
+
def run
|
|
57
|
+
options = parse or return 2
|
|
58
|
+
return 0 if options[:help]
|
|
59
|
+
|
|
60
|
+
key_env = options[:key_env]
|
|
61
|
+
return fail!("#{key_env} is not set; export it first (the API key; chi only writes its name)") if key_env && unset?(key_env)
|
|
62
|
+
|
|
63
|
+
result = find(options, key_env) or return @exit
|
|
64
|
+
unlocked = with_key(result, key_env) or return @exit
|
|
65
|
+
result, key_env = unlocked
|
|
66
|
+
describe(result)
|
|
67
|
+
model = pick_model(result, options[:model]) or return @exit
|
|
68
|
+
show_model(result, model, key_env)
|
|
69
|
+
tested = options[:no_test] || test(result, model, key_env)
|
|
70
|
+
written = write(result, model, key_env, options) or return @exit
|
|
71
|
+
written.zero? && tested ? 0 : 1
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def parse
|
|
77
|
+
options = {}
|
|
78
|
+
until @argv.empty?
|
|
79
|
+
arg = @argv.shift
|
|
80
|
+
case arg
|
|
81
|
+
when "-h", "--help", "help"
|
|
82
|
+
@stdout.puts(USAGE)
|
|
83
|
+
return { help: true }
|
|
84
|
+
when "--name", "--model", "--key-env"
|
|
85
|
+
options[arg.delete_prefix("--").tr("-", "_").to_sym] = @argv.shift or return usage_error("#{arg} needs a value")
|
|
86
|
+
when /\A--(name|model|key-env)=(.+)\z/ then options[Regexp.last_match(1).tr("-", "_").to_sym] = Regexp.last_match(2)
|
|
87
|
+
when "--no-test" then options[:no_test] = true
|
|
88
|
+
when "--dry-run" then options[:dry_run] = true
|
|
89
|
+
when /\A-/ then return usage_error("unknown option #{arg}")
|
|
90
|
+
else
|
|
91
|
+
return usage_error("one TARGET only (got #{options[:target]} and #{arg})") if options[:target]
|
|
92
|
+
|
|
93
|
+
options[:target] = arg
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
if options[:key_env] && !options[:key_env].match?(ConfigFile::ENV_NAME_RE)
|
|
97
|
+
return usage_error("--key-env takes the variable's name (e.g. OPENROUTER_API_KEY), not the key")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
options
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The server's Probe::Result, or nil with @exit set.
|
|
104
|
+
def find(options, key_env)
|
|
105
|
+
return scan unless options[:target]
|
|
106
|
+
|
|
107
|
+
candidates = begin
|
|
108
|
+
Bootstrap::Probe.candidates(options[:target])
|
|
109
|
+
rescue ArgumentError => e
|
|
110
|
+
usage_error(e.message)
|
|
111
|
+
@exit = 2
|
|
112
|
+
return nil
|
|
113
|
+
end
|
|
114
|
+
result = @probe.classify_target(candidates, key_env: key_env)
|
|
115
|
+
case result.kind
|
|
116
|
+
when :unreachable
|
|
117
|
+
fail!("can't reach #{result.candidate.label} (#{result.reason})")
|
|
118
|
+
when :unknown
|
|
119
|
+
detail = result.status ? "HTTP #{result.status}" : result.reason
|
|
120
|
+
fail!("reached #{result.candidate.label} but it answers neither llama.cpp /props nor /v1/models (#{detail})")
|
|
121
|
+
else result
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def scan
|
|
126
|
+
found = @probe.scan_local
|
|
127
|
+
ports = Bootstrap::Probe::LOCAL_PORTS.keys.join(", ")
|
|
128
|
+
return fail!("no model server found on localhost (#{ports}); pass one: chi bootstrap HOST[:PORT]") if found.empty?
|
|
129
|
+
return found.first if found.size == 1
|
|
130
|
+
|
|
131
|
+
labels = found.map { |r| "#{r.candidate.label} (#{Bootstrap::Probe::LOCAL_PORTS[r.candidate.port]}, #{models_count(r)})" }
|
|
132
|
+
unless tty?
|
|
133
|
+
@stderr.puts("chi bootstrap: model servers on this machine:")
|
|
134
|
+
labels.each { |label| @stderr.puts(" #{label}") }
|
|
135
|
+
@stderr.puts("pick one: chi bootstrap localhost:PORT")
|
|
136
|
+
@exit = 2
|
|
137
|
+
return nil
|
|
138
|
+
end
|
|
139
|
+
picked = pick(labels, "server") or return fail!("nothing picked")
|
|
140
|
+
found[labels.index(picked)]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def models_count(result)
|
|
144
|
+
n = result.models.size
|
|
145
|
+
n == 1 ? "1 model" : "#{n} models"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# [result, key_env] once the server lets us in, or nil with @exit set.
|
|
149
|
+
def with_key(result, key_env)
|
|
150
|
+
return [result, key_env] unless result.kind == :needs_key
|
|
151
|
+
|
|
152
|
+
host = result.candidate.label
|
|
153
|
+
return fail!("#{host} refused the key in #{key_env} (HTTP #{result.status})") if key_env
|
|
154
|
+
|
|
155
|
+
unless tty?
|
|
156
|
+
@stderr.puts("chi bootstrap: #{host} wants an API key (HTTP #{result.status}); " \
|
|
157
|
+
"pass the variable that holds it: --key-env VAR")
|
|
158
|
+
@exit = 2
|
|
159
|
+
return nil
|
|
160
|
+
end
|
|
161
|
+
@stdout.print("#{host} wants an API key. API key environment variable (e.g. OPENROUTER_API_KEY): ")
|
|
162
|
+
key_env = @stdin.gets.to_s.strip
|
|
163
|
+
return fail!("no variable given") if key_env.empty?
|
|
164
|
+
return fail!("#{key_env} isn't a variable name") unless key_env.match?(ConfigFile::ENV_NAME_RE)
|
|
165
|
+
return fail!("#{key_env} is not set; export it and run chi bootstrap again") if unset?(key_env)
|
|
166
|
+
|
|
167
|
+
result = @probe.classify(result.candidate, key_env: key_env)
|
|
168
|
+
return fail!("#{host} refused the key in #{key_env} (HTTP #{result.status})") if result.kind == :needs_key
|
|
169
|
+
return fail!("can't reach #{host} (#{result.reason || "HTTP #{result.status}"})") unless result.reached?
|
|
170
|
+
|
|
171
|
+
[result, key_env]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def describe(result)
|
|
175
|
+
if result.native?
|
|
176
|
+
build = result.props["build_info"].to_s
|
|
177
|
+
@stdout.puts("found: llama.cpp at #{result.candidate.root}#{build.empty? ? "" : " (build #{build})"}")
|
|
178
|
+
else
|
|
179
|
+
@stdout.puts("found: an OpenAI-compatible API at #{result.candidate.base}")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def pick_model(result, wanted)
|
|
184
|
+
ids = result.models.map(&:id).reject(&:empty?)
|
|
185
|
+
if wanted
|
|
186
|
+
return wanted if ids.empty?
|
|
187
|
+
|
|
188
|
+
match = ids.find { |id| id == wanted } || ids.find { |id| id.casecmp?(wanted) }
|
|
189
|
+
return match if match
|
|
190
|
+
|
|
191
|
+
@stderr.puts("chi bootstrap: #{result.candidate.label} has no model #{wanted}; it lists:")
|
|
192
|
+
ids.each { |id| @stderr.puts(" #{id}") }
|
|
193
|
+
@exit = 1
|
|
194
|
+
return nil
|
|
195
|
+
end
|
|
196
|
+
return fail!("#{result.candidate.label} lists no models; pass one: --model ID") if ids.empty?
|
|
197
|
+
return ids.first if ids.size == 1
|
|
198
|
+
return pick(ids, "model") || fail!("nothing picked") if tty?
|
|
199
|
+
|
|
200
|
+
@stderr.puts("chi bootstrap: #{result.candidate.label} has #{ids.size} models:")
|
|
201
|
+
ids.each { |id| @stderr.puts(" #{id}") }
|
|
202
|
+
@stderr.puts("pick one: --model ID")
|
|
203
|
+
@exit = 2
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def show_model(result, model, key_env)
|
|
208
|
+
@stdout.puts("model: #{model}")
|
|
209
|
+
return unless result.native?
|
|
210
|
+
|
|
211
|
+
props = @probe.model_props(result.candidate, model, key_env: key_env) || result.props
|
|
212
|
+
n_ctx = props.dig("default_generation_settings", "n_ctx")
|
|
213
|
+
@stdout.puts("context: #{n_ctx} tokens") if n_ctx.is_a?(Integer) && n_ctx.positive?
|
|
214
|
+
profile, evidence = ModelProfile.fingerprint(props)
|
|
215
|
+
@stdout.puts("profile: #{profile} (chat template: #{evidence})") if profile
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def test(result, model, key_env)
|
|
219
|
+
done = Queue.new
|
|
220
|
+
waiting = Thread.new do
|
|
221
|
+
@stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
|
|
222
|
+
end
|
|
223
|
+
seconds = @probe.test_turn(result.candidate, model, key_env: key_env)
|
|
224
|
+
@stdout.puts(format("test: answered in %.1f s", seconds))
|
|
225
|
+
true
|
|
226
|
+
rescue StandardError => e
|
|
227
|
+
@stdout.puts("test: failed: #{e.message}")
|
|
228
|
+
false
|
|
229
|
+
ensure
|
|
230
|
+
done&.push(true)
|
|
231
|
+
waiting&.join
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# 0 written, 1 written but not as planned, nil with @exit on a refusal.
|
|
235
|
+
def write(result, model, key_env, options)
|
|
236
|
+
writer = Bootstrap::ConfigWriter.new(path: @config_path, env: @env)
|
|
237
|
+
fields = host_fields(result.candidate, result.native?, key_env)
|
|
238
|
+
ids = result.models.map(&:id)
|
|
239
|
+
name = begin
|
|
240
|
+
writer.host_name(Bootstrap::ConfigWriter.derived_name(result.candidate.host), requested: options[:name], model_ids: ids)
|
|
241
|
+
rescue Bootstrap::ConfigWriter::Error => e
|
|
242
|
+
usage_error(e.message)
|
|
243
|
+
@exit = 2
|
|
244
|
+
return nil
|
|
245
|
+
end
|
|
246
|
+
outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
|
|
247
|
+
report(outcome, model)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The hosts entry: host/port for a plain http server (api: openai when it
|
|
251
|
+
# isn't llama.cpp), url for https or a URL with its own path.
|
|
252
|
+
def host_fields(candidate, native, key_env)
|
|
253
|
+
fields = if candidate.url || candidate.scheme == "https"
|
|
254
|
+
{ "url" => native ? candidate.root : candidate.base }
|
|
255
|
+
else
|
|
256
|
+
{ "host" => candidate.host, "port" => candidate.port }
|
|
257
|
+
end
|
|
258
|
+
fields["api"] = "openai" unless native
|
|
259
|
+
fields["api_key_env"] = key_env if key_env
|
|
260
|
+
fields
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def report(outcome, model)
|
|
264
|
+
path = outcome.path
|
|
265
|
+
case outcome.kind
|
|
266
|
+
when :dry_run
|
|
267
|
+
@stdout.puts("dry run: would write #{outcome.where} (#{path}):")
|
|
268
|
+
@stdout.puts(outcome.text.gsub(/^/, " "))
|
|
269
|
+
@stdout.puts("and print: #{outcome.model_hint}") if outcome.model_hint
|
|
270
|
+
0
|
|
271
|
+
when :new
|
|
272
|
+
@stdout.puts("config: #{path} (new)")
|
|
273
|
+
next_steps
|
|
274
|
+
0
|
|
275
|
+
when :appended
|
|
276
|
+
@stdout.puts("config: #{path} (hosts entry '#{outcome.name}' added; backup #{File.basename(outcome.backup)})")
|
|
277
|
+
@stdout.puts("default.model: #{outcome.default_model}") if outcome.default_model
|
|
278
|
+
@stdout.puts("add under default: in config.yml:\n#{outcome.model_hint}") if outcome.model_hint
|
|
279
|
+
@stdout.puts("use it: chi --model #{outcome.name}:#{model} (or /model in a session)") unless outcome.default_model
|
|
280
|
+
0
|
|
281
|
+
when :exists
|
|
282
|
+
@stdout.puts("config: #{path} already has this server as '#{outcome.existing}'; nothing written")
|
|
283
|
+
@stdout.puts("use it: chi --model #{outcome.existing}:#{model} (or /model in a session)")
|
|
284
|
+
0
|
|
285
|
+
when :snippet
|
|
286
|
+
@stdout.puts("config: #{path} uses YAML chi won't edit (flow style or anchors); add this yourself:")
|
|
287
|
+
@stdout.puts(outcome.text.gsub(/^/, " "))
|
|
288
|
+
@stdout.puts("use it: chi --model #{outcome.name}:#{model}")
|
|
289
|
+
0
|
|
290
|
+
else
|
|
291
|
+
@stderr.puts("chi bootstrap: the edited #{path} didn't read back as expected; " \
|
|
292
|
+
"it is back as it was (#{File.basename(outcome.backup)}). Add this yourself:")
|
|
293
|
+
@stderr.puts(outcome.text.gsub(/^/, " "))
|
|
294
|
+
1
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def next_steps
|
|
299
|
+
lines = [["chi", "a session"], ["chi web --open", "the web UI"], ["chi bundle list", "optional bundles (guardrails, …)"]]
|
|
300
|
+
lines << ["chi desktop install", "macOS \"Send to chi\" helper"] if @platform.to_s.include?("darwin")
|
|
301
|
+
lines.each_with_index do |(command, what), i|
|
|
302
|
+
@stdout.puts("#{i.zero? ? "next:" : " "} #{command.ljust(20)} # #{what}")
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# A numbered list (the first PICK_SHOWN; a typed word narrows it).
|
|
307
|
+
def pick(items, noun)
|
|
308
|
+
shown = items
|
|
309
|
+
loop do
|
|
310
|
+
shown.first(PICK_SHOWN).each_with_index { |item, i| @stdout.puts(format(" %2d) %s", i + 1, item)) }
|
|
311
|
+
more = shown.size - PICK_SHOWN
|
|
312
|
+
@stdout.puts(" … #{more} more: type part of a name to narrow the list") if more.positive?
|
|
313
|
+
@stdout.print("#{noun} (number#{items.size > PICK_SHOWN ? " or text" : ""}, empty to stop): ")
|
|
314
|
+
answer = @stdin.gets.to_s.strip
|
|
315
|
+
return nil if answer.empty?
|
|
316
|
+
|
|
317
|
+
number = Integer(answer, exception: false)
|
|
318
|
+
return shown[number - 1] if number && number.between?(1, [shown.size, PICK_SHOWN].min)
|
|
319
|
+
|
|
320
|
+
narrowed = items.select { |item| item.downcase.include?(answer.downcase) }
|
|
321
|
+
if narrowed.empty?
|
|
322
|
+
@stdout.puts("no #{noun} matches '#{answer}'")
|
|
323
|
+
else
|
|
324
|
+
shown = narrowed
|
|
325
|
+
return shown.first if shown.size == 1
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def tty?
|
|
331
|
+
@stdin.respond_to?(:tty?) && @stdin.tty?
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def unset?(name) = @env[name].to_s.strip.empty?
|
|
335
|
+
|
|
336
|
+
def fail!(message)
|
|
337
|
+
@stderr.puts("chi bootstrap: #{message}")
|
|
338
|
+
@exit = 1
|
|
339
|
+
nil
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def usage_error(message)
|
|
343
|
+
@stderr.puts("chi bootstrap: #{message} (see chi bootstrap --help)")
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class Bridge
|
|
8
|
+
# The running turn's open card with actions (check-in's Nudge / Keep
|
|
9
|
+
# going / Stop) as a file in the session's folder, pending_card.json
|
|
10
|
+
# {id:, bundle:}, for `chi web`'s session hub: it watches files, not
|
|
11
|
+
# workers, and a tab behind notifies from its summary (notify.js), as
|
|
12
|
+
# for a pending question. A persistent observer, like CardStore.
|
|
13
|
+
#
|
|
14
|
+
# Written when such a card shows; removed when that card comes again
|
|
15
|
+
# without actions (resolved), when the turn ends, and when the Bridge
|
|
16
|
+
# starts or stops. A card with no actions (loop-guard's warn) never
|
|
17
|
+
# writes it, nor does a card between turns.
|
|
18
|
+
class PendingCard
|
|
19
|
+
FILE = "pending_card.json"
|
|
20
|
+
TURN_ENDS = %i[turn_completed turn_canceled turn_failed].freeze
|
|
21
|
+
|
|
22
|
+
# @param session_dir [String] the session's folder
|
|
23
|
+
# @return [Hash, nil] {id:, bundle:}, nil without a readable file
|
|
24
|
+
def self.read(session_dir)
|
|
25
|
+
data = JSON.parse(File.read(File.join(session_dir, FILE)))
|
|
26
|
+
return nil unless data.is_a?(Hash) && !data["id"].to_s.empty?
|
|
27
|
+
|
|
28
|
+
{ id: data["id"].to_s, bundle: data["bundle"].to_s }
|
|
29
|
+
rescue StandardError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(session_dir)
|
|
34
|
+
@path = File.join(session_dir, FILE)
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@id = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def call(event)
|
|
40
|
+
@mutex.synchronize { fold(event) }
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil # never break the running turn
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# No card is open (a start after a worker that died with one, a stop).
|
|
46
|
+
def clear
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@id = nil
|
|
49
|
+
FileUtils.rm_f(@path)
|
|
50
|
+
end
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def fold(event)
|
|
58
|
+
case event[:type]
|
|
59
|
+
when :card then card(event)
|
|
60
|
+
when *TURN_ENDS then remove
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def card(event)
|
|
65
|
+
asks = Array(event[:actions]).any?
|
|
66
|
+
if event[:in_turn] && asks
|
|
67
|
+
write(event[:id].to_s, event[:source].to_s)
|
|
68
|
+
elsif @id && event[:id].to_s == @id
|
|
69
|
+
remove
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def write(id, bundle)
|
|
74
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
75
|
+
temp = "#{@path}.tmp"
|
|
76
|
+
File.write(temp, JSON.generate({ "id" => id, "bundle" => bundle }))
|
|
77
|
+
File.rename(temp, @path)
|
|
78
|
+
@id = id
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def remove
|
|
82
|
+
return unless @id
|
|
83
|
+
|
|
84
|
+
@id = nil
|
|
85
|
+
FileUtils.rm_f(@path)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../kernel_loop"
|
|
4
|
+
require_relative "../steer"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
class Bridge
|
|
@@ -32,7 +33,7 @@ module Samagotchi
|
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
# @return [Hash, nil] a copy of the turn in progress: prompt, origin,
|
|
35
|
-
# continue, ordered parts (thinking / text / tool / input / reminder / hook_notice),
|
|
36
|
+
# continue, ordered parts (thinking / text / tool / input / steer / reminder / hook_notice),
|
|
36
37
|
# pending_question and the last event_seq folded in
|
|
37
38
|
def current_turn
|
|
38
39
|
@mutex.synchronize { @turn && Marshal.load(Marshal.dump(@turn)) }
|
|
@@ -67,6 +68,9 @@ module Samagotchi
|
|
|
67
68
|
when "input"
|
|
68
69
|
flush.call
|
|
69
70
|
messages << { role: "user", content: part[:text].to_s }
|
|
71
|
+
when "steer"
|
|
72
|
+
flush.call
|
|
73
|
+
messages << Steer.message(text: part[:text], source: part[:source])
|
|
70
74
|
end
|
|
71
75
|
end
|
|
72
76
|
flush.call
|
|
@@ -156,8 +160,15 @@ module Samagotchi
|
|
|
156
160
|
tool[:output_truncated] = capped || !!event[:output_truncated]
|
|
157
161
|
tool[:images] = event[:images] if event[:images]
|
|
158
162
|
when :pending_input_merged
|
|
159
|
-
|
|
160
|
-
|
|
163
|
+
# A steer-only merge (count 0) has no user part: the origins stay
|
|
164
|
+
# for the user lines' own merge.
|
|
165
|
+
unless event[:content].nil?
|
|
166
|
+
parts << { kind: "input", iteration: event[:iteration], text: event[:content].to_s.dup, origins: @merged_origins }
|
|
167
|
+
@merged_origins = []
|
|
168
|
+
end
|
|
169
|
+
Array(event[:steers]).each do |steer|
|
|
170
|
+
parts << { kind: "steer", iteration: event[:iteration], source: steer[:source].to_s, text: steer[:text].to_s.dup }
|
|
171
|
+
end
|
|
161
172
|
when :reminder_injected
|
|
162
173
|
parts << { kind: "reminder", reminders: Array(event[:reminders]).map(&:dup) }
|
|
163
174
|
when :hook_notice
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -10,6 +10,7 @@ require "time"
|
|
|
10
10
|
require_relative "bridge/bounded_queue"
|
|
11
11
|
require_relative "bridge/card_store"
|
|
12
12
|
require_relative "bridge/event_id"
|
|
13
|
+
require_relative "bridge/pending_card"
|
|
13
14
|
require_relative "bridge/ring_buffer"
|
|
14
15
|
require_relative "bridge/sse_writer"
|
|
15
16
|
require_relative "bridge/turn_accumulator"
|
|
@@ -92,6 +93,7 @@ module Samagotchi
|
|
|
92
93
|
@ring = RingBuffer.new(capacity: ring_capacity)
|
|
93
94
|
@accumulator = TurnAccumulator.new
|
|
94
95
|
@cards = CardStore.new
|
|
96
|
+
@pending_card = PendingCard.new(session_dir)
|
|
95
97
|
@heartbeat_interval = heartbeat_interval
|
|
96
98
|
@epoch = SecureRandom.hex(4)
|
|
97
99
|
|
|
@@ -151,6 +153,9 @@ module Samagotchi
|
|
|
151
153
|
# A plugin's ctx.messages mid-turn holds the turn so far (plan O1).
|
|
152
154
|
@engine.running_turn_messages = -> { @accumulator.current_messages } if @engine.respond_to?(:running_turn_messages=)
|
|
153
155
|
@cards_handle = @engine.subscribe(observer: @cards)
|
|
156
|
+
# One a worker that died left is not open.
|
|
157
|
+
@pending_card.clear
|
|
158
|
+
@pending_card_handle = @engine.subscribe(observer: @pending_card)
|
|
154
159
|
@accept_thread = Thread.new { accept_loop }
|
|
155
160
|
@accept_thread.report_on_exception = false
|
|
156
161
|
write_sidecar
|
|
@@ -175,6 +180,8 @@ module Samagotchi
|
|
|
175
180
|
@accumulator_handle&.unsubscribe
|
|
176
181
|
@engine.running_turn_messages = nil if @engine.respond_to?(:running_turn_messages=)
|
|
177
182
|
@cards_handle&.unsubscribe
|
|
183
|
+
@pending_card_handle&.unsubscribe
|
|
184
|
+
@pending_card&.clear
|
|
178
185
|
# A turn post killed between its enqueue and its reply looks failed to
|
|
179
186
|
# the web, which then queues the prompt again from the input file.
|
|
180
187
|
await_answers(REQUEST_GRACE_SECONDS)
|
|
@@ -597,6 +604,8 @@ module Samagotchi
|
|
|
597
604
|
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
598
605
|
|
|
599
606
|
command = { command_id: SecureRandom.uuid, client_id: fetched(parsed, "client_id"), line: line }
|
|
607
|
+
# A card's action: the UIs leave its line out (the card is the echo).
|
|
608
|
+
command[:card] = true if fetched(parsed, "card") == true
|
|
600
609
|
queued = @engine.synchronize_events do
|
|
601
610
|
next false if expired?("command_expired", deadline, sid: session_id, client_id: command[:client_id])
|
|
602
611
|
|
|
@@ -162,9 +162,13 @@ module Samagotchi
|
|
|
162
162
|
# !rollback, !cmd, /continue) for the worker to run. 202 = queued (body
|
|
163
163
|
# carries the command_id its :command_ran will name), 400 = not a
|
|
164
164
|
# command, 404 = a worker older than the route, 408 = too late.
|
|
165
|
+
# @param card [Boolean] the line is a card's action: its command_queued
|
|
166
|
+
# and command_ran carry card: true, and the UIs show no echo of it
|
|
165
167
|
# @return [Response]
|
|
166
|
-
def post_command(line:, client_id: nil)
|
|
167
|
-
|
|
168
|
+
def post_command(line:, client_id: nil, card: false)
|
|
169
|
+
payload = { line: line, client_id: client_id, deadline: deadline }
|
|
170
|
+
payload[:card] = true if card
|
|
171
|
+
post("command", payload, read_body: true)
|
|
168
172
|
end
|
|
169
173
|
|
|
170
174
|
# POST /session/:id/exit: ask the worker to exit now. 200 = it will
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: check-in
|
|
3
|
+
version: 0.1.1
|
|
4
|
+
scope: system
|
|
5
|
+
description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
|
|
6
|
+
trust_level: reviewed
|
|
7
|
+
plugin:
|
|
8
|
+
file: plugin.rb
|
|
9
|
+
sha256: sha256:b2ca10c949734ef3d31df9ff775cf136d7f86f185e875a4c16259e8f871bfb82
|
|
10
|
+
requires_chi: ">= 0.3.0"
|