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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +40 -4
  4. data/bin/chi +86 -19
  5. data/docs/cli.md +108 -5
  6. data/docs/configuration.md +229 -45
  7. data/docs/desktop.md +6 -0
  8. data/docs/hooks.md +126 -5
  9. data/docs/plugins.md +68 -2
  10. data/docs/releasing.md +18 -8
  11. data/docs/sessions.md +30 -4
  12. data/lib/samagotchi/answer_display.rb +95 -0
  13. data/lib/samagotchi/archive_store.rb +90 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  16. data/lib/samagotchi/bootstrap_command.rb +347 -0
  17. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
  19. data/lib/samagotchi/bridge.rb +9 -0
  20. data/lib/samagotchi/bridge_client.rb +6 -2
  21. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  22. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  25. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  29. data/lib/samagotchi/client.rb +72 -13
  30. data/lib/samagotchi/config.rb +196 -36
  31. data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
  32. data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
  33. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  34. data/lib/samagotchi/engine.rb +233 -36
  35. data/lib/samagotchi/guardrails/approval.rb +9 -0
  36. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  37. data/lib/samagotchi/guardrails.rb +1 -0
  38. data/lib/samagotchi/hooks/registry.rb +24 -5
  39. data/lib/samagotchi/host_registry.rb +4 -3
  40. data/lib/samagotchi/idle_recap.rb +5 -1
  41. data/lib/samagotchi/kernel_loop.rb +47 -15
  42. data/lib/samagotchi/llm/chat_loop.rb +59 -20
  43. data/lib/samagotchi/llm/errors.rb +21 -3
  44. data/lib/samagotchi/llm/http.rb +42 -13
  45. data/lib/samagotchi/llm/openai_chat.rb +12 -4
  46. data/lib/samagotchi/log_subscriber.rb +18 -3
  47. data/lib/samagotchi/model_profile.rb +1 -1
  48. data/lib/samagotchi/plugin/context.rb +22 -1
  49. data/lib/samagotchi/plugin/sessions.rb +3 -1
  50. data/lib/samagotchi/reply_wait.rb +126 -0
  51. data/lib/samagotchi/sampling_settings.rb +58 -0
  52. data/lib/samagotchi/self_report.rb +1 -0
  53. data/lib/samagotchi/send_command.rb +153 -7
  54. data/lib/samagotchi/session.rb +52 -11
  55. data/lib/samagotchi/session_archive_command.rb +107 -0
  56. data/lib/samagotchi/session_commands.rb +11 -2
  57. data/lib/samagotchi/session_manager.rb +114 -9
  58. data/lib/samagotchi/session_metrics.rb +222 -106
  59. data/lib/samagotchi/steer.rb +72 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
  62. data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
  63. data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
  64. data/lib/samagotchi/terminal_ui.rb +77 -4
  65. data/lib/samagotchi/tool_activity.rb +3 -1
  66. data/lib/samagotchi/tools/builtins.rb +15 -4
  67. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  68. data/lib/samagotchi/tools/execute.rb +52 -14
  69. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  70. data/lib/samagotchi/tools/task_wait.rb +27 -3
  71. data/lib/samagotchi/turn_note.rb +60 -6
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +2 -6
  74. data/lib/samagotchi/web/app.rb +88 -4
  75. data/lib/samagotchi/web/public/activity.js +10 -1
  76. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  77. data/lib/samagotchi/web/public/annotations.js +13 -0
  78. data/lib/samagotchi/web/public/app.js +437 -88
  79. data/lib/samagotchi/web/public/card.js +5 -3
  80. data/lib/samagotchi/web/public/chat_view.js +10 -1
  81. data/lib/samagotchi/web/public/copy.js +20 -4
  82. data/lib/samagotchi/web/public/ctx.js +15 -0
  83. data/lib/samagotchi/web/public/data.js +21 -6
  84. data/lib/samagotchi/web/public/format.js +9 -0
  85. data/lib/samagotchi/web/public/index.html +38 -2
  86. data/lib/samagotchi/web/public/notify.js +175 -0
  87. data/lib/samagotchi/web/public/question_card.js +2 -1
  88. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  89. data/lib/samagotchi/web/public/timing.js +39 -14
  90. data/lib/samagotchi/web/public/turn_events.js +46 -0
  91. data/lib/samagotchi/web/public/turn_view.js +47 -7
  92. data/lib/samagotchi/web/server.rb +8 -4
  93. data/lib/samagotchi/web/session_hub.rb +2 -1
  94. data/lib/samagotchi/web/session_summary.rb +24 -1
  95. data/lib/samagotchi/worker.rb +11 -0
  96. 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
- parts << { kind: "input", iteration: event[:iteration], text: event[:content].to_s.dup, origins: @merged_origins }
160
- @merged_origins = []
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
@@ -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
- post("command", { line: line, client_id: client_id, deadline: deadline }, read_body: true)
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"