samagotchi 0.79.0 → 0.81.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +7 -5
  4. data/docs/architecture.md +9 -1
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +10 -4
  7. data/docs/guardrails.md +26 -3
  8. data/docs/internals/anthropic.md +3 -1
  9. data/docs/internals/bundles.md +4 -0
  10. data/docs/internals/engine.md +13 -3
  11. data/docs/plugins.md +149 -3
  12. data/docs/releasing.md +2 -2
  13. data/docs/sessions.md +9 -1
  14. data/docs/testing.md +51 -2
  15. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  16. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  17. data/lib/samagotchi/bootstrap_command.rb +93 -16
  18. data/lib/samagotchi/bridge.rb +1 -1
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  25. data/lib/samagotchi/continue_offer.rb +5 -5
  26. data/lib/samagotchi/engine.rb +68 -39
  27. data/lib/samagotchi/guardrails/approval.rb +6 -4
  28. data/lib/samagotchi/guardrails/data_readers.rb +214 -0
  29. data/lib/samagotchi/guardrails/gate.rb +7 -3
  30. data/lib/samagotchi/guardrails/rules.rb +4 -2
  31. data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
  32. data/lib/samagotchi/guardrails/targets.rb +14 -0
  33. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  34. data/lib/samagotchi/hooks/registry.rb +44 -3
  35. data/lib/samagotchi/host_config.rb +8 -8
  36. data/lib/samagotchi/host_registry.rb +12 -4
  37. data/lib/samagotchi/idle_client.rb +41 -11
  38. data/lib/samagotchi/idle_recap.rb +6 -1
  39. data/lib/samagotchi/idle_target.rb +12 -4
  40. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  41. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
  42. data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
  43. data/lib/samagotchi/llm/http.rb +12 -7
  44. data/lib/samagotchi/llm/usage.rb +9 -3
  45. data/lib/samagotchi/model_price.rb +16 -0
  46. data/lib/samagotchi/plugin/card.rb +44 -0
  47. data/lib/samagotchi/plugin/context.rb +61 -9
  48. data/lib/samagotchi/plugin/http.rb +154 -0
  49. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  50. data/lib/samagotchi/plugin/loader.rb +29 -10
  51. data/lib/samagotchi/plugin/service.rb +24 -1
  52. data/lib/samagotchi/question_answer.rb +29 -0
  53. data/lib/samagotchi/question_desk.rb +20 -19
  54. data/lib/samagotchi/recap_setup.rb +2 -1
  55. data/lib/samagotchi/relay_verifier.rb +1 -1
  56. data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
  57. data/lib/samagotchi/session_metrics.rb +46 -12
  58. data/lib/samagotchi/session_observer.rb +3 -0
  59. data/lib/samagotchi/side_usage.rb +38 -0
  60. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  61. data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
  62. data/lib/samagotchi/testing/boundary.rb +156 -0
  63. data/lib/samagotchi/testing/bundle.rb +349 -0
  64. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  65. data/lib/samagotchi/testing/fake_http.rb +137 -0
  66. data/lib/samagotchi/testing/helpers.rb +56 -0
  67. data/lib/samagotchi/testing/host.rb +76 -0
  68. data/lib/samagotchi/testing/minitest.rb +31 -0
  69. data/lib/samagotchi/testing/recorder.rb +141 -0
  70. data/lib/samagotchi/testing/recording_api.rb +27 -0
  71. data/lib/samagotchi/testing/rspec.rb +31 -0
  72. data/lib/samagotchi/testing/sandbox.rb +165 -0
  73. data/lib/samagotchi/testing/sessions.rb +77 -0
  74. data/lib/samagotchi/testing/verdict.rb +28 -0
  75. data/lib/samagotchi/testing/waiting.rb +36 -0
  76. data/lib/samagotchi/testing.rb +26 -0
  77. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  78. data/lib/samagotchi/tools/tool_index.rb +3 -0
  79. data/lib/samagotchi/turn_state.rb +28 -1
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +4 -1
  82. data/lib/samagotchi/web/public/app.js +47 -23
  83. data/lib/samagotchi/web/public/ctx.js +32 -6
  84. data/lib/samagotchi/web/public/images.js +33 -0
  85. data/lib/samagotchi/web/public/index.html +71 -38
  86. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  87. data/lib/samagotchi/web/public/level_chip.js +11 -11
  88. data/lib/samagotchi/web/public/lists_view.js +20 -15
  89. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  90. data/lib/samagotchi/web/public/model_picker.js +1 -1
  91. data/lib/samagotchi/web/public/scope.js +3 -3
  92. data/lib/samagotchi/web/public/stream_handlers.js +7 -0
  93. data/lib/samagotchi/web/public/strip.js +35 -9
  94. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  95. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  96. data/lib/samagotchi/web/public/timing.js +6 -1
  97. data/lib/samagotchi/web/session_summary.rb +2 -0
  98. metadata +24 -1
@@ -14,9 +14,10 @@ require_relative "redacted_inspect"
14
14
 
15
15
  module Samagotchi
16
16
  # `chi bootstrap [TARGET]`: the first setup. It names a model server, works
17
- # out what it is (llama.cpp's native API or an OpenAI-compatible one),
18
- # picks the model, sends one test request and writes config.yml: a fresh
19
- # file, or a hosts: entry added to an existing one. Then it installs the
17
+ # out what it is (llama.cpp's native API, an OpenAI-compatible one or
18
+ # Anthropic's Messages API, Bootstrap::Probe), picks the model, sends one
19
+ # test request and writes config.yml: a fresh file, or a hosts: entry
20
+ # added to an existing one. Then it installs the
20
21
  # system bundle and the core profile, and on a terminal asks about dev.
21
22
  # Prompts only on a terminal; a script gets the list and exit 2 instead.
22
23
  class BootstrapCommand
@@ -24,11 +25,14 @@ module Samagotchi
24
25
  include RedactedInspect
25
26
 
26
27
  USAGE = <<~TEXT
27
- Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--no-test] [--dry-run]
28
+ Usage: chi bootstrap [TARGET] [--name NAME] [--model ID] [--key-env VAR] [--api API] [--dialect NAME]
29
+ [--no-test] [--dry-run]
28
30
  Finds the model server at TARGET and writes config.yml for it.
29
31
  chi bootstrap 192.168.1.29:8081 llama.cpp on the LAN
30
32
  chi bootstrap localhost:11434 Ollama
31
33
  chi bootstrap https://openrouter.ai/api/v1 --key-env OPENROUTER_API_KEY
34
+ chi bootstrap https://api.anthropic.com --key-env ANTHROPIC_API_KEY
35
+ chi bootstrap https://api.fireworks.ai/inference/v1 --key-env FIREWORKS_API_KEY --api anthropic
32
36
  chi bootstrap look on this machine's usual ports
33
37
  TARGET host[:port] (port 8080 when none), or a URL; a URL's
34
38
  path is the OpenAI API base
@@ -37,6 +41,12 @@ module Samagotchi
37
41
  --model ID the model, when the server has several
38
42
  --key-env VAR the environment variable holding the API key (the
39
43
  key itself is never written)
44
+ --api API openai or anthropic (Anthropic's Messages API) instead
45
+ of detecting it; detection takes anthropic only when
46
+ the OpenAI model list is refused
47
+ --dialect NAME the Messages API's dialect: standard or fireworks
48
+ (implies --api anthropic; default: fireworks when the
49
+ host or the model ids say so)
40
50
  --no-test skip the test request
41
51
  --dry-run show what would be written; write nothing
42
52
  With no config.yml it writes one; an existing one gets a hosts:
@@ -50,10 +60,13 @@ module Samagotchi
50
60
  f.value "--name"
51
61
  f.value "--model"
52
62
  f.value "--key-env"
63
+ f.value "--api"
64
+ f.value "--dialect"
53
65
  f.switch "--no-test"
54
66
  f.switch "--dry-run"
55
67
  end
56
68
 
69
+ APIS = %i[openai anthropic].freeze
57
70
  PICK_SHOWN = 20
58
71
  # A test request slower than this says it is waiting (a model loading).
59
72
  LOADING_AFTER = 3
@@ -92,6 +105,8 @@ module Samagotchi
92
105
  return @exit
93
106
  end
94
107
 
108
+ @api = options[:api]
109
+ @dialect = options[:dialect]
95
110
  result = find(options, key_env) or return @exit
96
111
  unlocked = with_key(result, key_env) or return @exit
97
112
  result, key_env = unlocked
@@ -124,7 +139,25 @@ module Samagotchi
124
139
  return usage_error("--key-env takes the variable's name (e.g. OPENROUTER_API_KEY), not the key")
125
140
  end
126
141
 
127
- options
142
+ parse_api(options)
143
+ end
144
+
145
+ # --api as a Symbol, --dialect downcased (implying --api anthropic).
146
+ def parse_api(options)
147
+ if options[:api]
148
+ api = options[:api].strip.downcase.to_sym
149
+ return usage_error("--api must be #{APIS.join(" or ")} (got #{options[:api]})") unless APIS.include?(api)
150
+
151
+ options[:api] = api
152
+ end
153
+ return options unless options[:dialect]
154
+
155
+ dialect = options[:dialect].strip.downcase
156
+ names = LLM::Dialect::BY_NAME.keys
157
+ return usage_error("--dialect must be #{names.join(" or ")} (got #{options[:dialect]})") unless names.include?(dialect)
158
+ return usage_error("--dialect is for --api anthropic, not --api openai") if options[:api] == :openai
159
+
160
+ options.merge(dialect: dialect, api: :anthropic)
128
161
  end
129
162
 
130
163
  # The server's Probe::Result, or nil with @exit set.
@@ -137,17 +170,26 @@ module Samagotchi
137
170
  @exit = usage_error(e.message)
138
171
  return nil
139
172
  end
140
- result = @probe.classify_target(candidates, key_env: key_env)
173
+ result = @probe.classify_target(candidates, key_env: key_env, api: @api, dialect: @dialect)
141
174
  case result.kind
142
175
  when :unreachable
143
176
  fail!("can't reach #{result.candidate.label} (#{result.reason})")
144
177
  when :unknown
145
178
  detail = result.status ? "HTTP #{result.status}" : result.reason
146
- fail!("reached #{result.candidate.label} but it answers neither llama.cpp /props nor /v1/models (#{detail})")
179
+ fail!("reached #{result.candidate.label} but it answers #{unknown_asked} (#{detail})")
147
180
  else result
148
181
  end
149
182
  end
150
183
 
184
+ # What an :unknown server didn't answer, by what --api let the probe ask.
185
+ def unknown_asked
186
+ case @api
187
+ when :anthropic then "no Anthropic /v1/models"
188
+ when :openai then "neither llama.cpp /props nor /v1/models"
189
+ else "neither llama.cpp /props, /v1/models nor the Anthropic /v1/models"
190
+ end
191
+ end
192
+
151
193
  def scan
152
194
  found = @probe.scan_local
153
195
  ports = Bootstrap::Probe::LOCAL_PORTS.keys.join(", ")
@@ -190,7 +232,7 @@ module Samagotchi
190
232
  return fail!("#{key_env} isn't a variable name") unless key_env.match?(ConfigFile::ENV_NAME_RE)
191
233
  return fail!("#{key_env} is not set; #{export_hint(key_env, "and run chi bootstrap again")}") if unset?(key_env)
192
234
 
193
- result = @probe.classify(result.candidate, key_env: key_env)
235
+ result = @probe.classify(result.candidate, key_env: key_env, api: @api, dialect: @dialect)
194
236
  return fail!("#{host} refused the key in #{key_env} (HTTP #{result.status})") if result.kind == :needs_key
195
237
  return fail!("can't reach #{host} (#{result.reason || "HTTP #{result.status}"})") unless result.reached?
196
238
 
@@ -201,8 +243,15 @@ module Samagotchi
201
243
  if result.native?
202
244
  build = result.props["build_info"].to_s
203
245
  @stdout.puts("found: llama.cpp at #{result.candidate.root}#{" (build #{build})" unless build.empty?}")
246
+ elsif result.anthropic?
247
+ dialect = " (dialect: #{result.dialect})" if result.dialect
248
+ @stdout.puts("found: an Anthropic Messages API at #{Bootstrap::Probe.api_root(result.candidate)}#{dialect}")
204
249
  else
205
250
  @stdout.puts("found: an OpenAI-compatible API at #{result.candidate.base}")
251
+ # By the host only: a proxy listing accounts/fireworks/ ids may not serve /v1/messages.
252
+ if @api.nil? && Bootstrap::Probe.fireworks_host?(result.candidate.host)
253
+ @stdout.puts("Fireworks also serves the Anthropic Messages API: --api anthropic")
254
+ end
206
255
  end
207
256
  end
208
257
 
@@ -248,17 +297,28 @@ module Samagotchi
248
297
  @stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
249
298
  end
250
299
  end
251
- seconds = @probe.test_turn(result.candidate, model, key_env: key_env)
300
+ seconds = @probe.test_turn(result.candidate, model, key_env: key_env, api: result.anthropic? ? :anthropic : :openai,
301
+ dialect: result.dialect, auth: result.auth)
252
302
  @stdout.puts(format("test: answered in %.1f s", seconds))
253
303
  true
254
304
  rescue StandardError => e
255
- @stdout.puts("test: failed: #{e.message}")
305
+ @stdout.puts("test: failed: #{e.message}#{messages_hint(result, e)}")
256
306
  false
257
307
  ensure
258
308
  done&.push(true)
259
309
  waiting&.join
260
310
  end
261
311
 
312
+ # A detected OpenAI server whose model list worked but whose
313
+ # /chat/completions is 404: maybe a Messages-only url (a gateway's
314
+ # Anthropic route that also lists the OpenAI way).
315
+ def messages_hint(result, error)
316
+ return "" unless @api.nil? && result.kind == :openai && error.is_a?(Bootstrap::Probe::TestFailed)
317
+ return "" unless error.status == 404 && error.path.to_s.end_with?("/chat/completions")
318
+
319
+ " (an Anthropic Messages API? try --api anthropic)"
320
+ end
321
+
262
322
  # The "loading the model?" hint fits a local server (a llama.cpp loading
263
323
  # weights); a remote provider's slow answer is the network, not a load.
264
324
  def loading_hint?(result)
@@ -268,7 +328,7 @@ module Samagotchi
268
328
  # 0 written, 1 written but not as planned, nil with @exit on a refusal.
269
329
  def write(result, model, key_env, options)
270
330
  writer = Bootstrap::ConfigWriter.new(path: @config_path, env: @env)
271
- fields = host_fields(result.candidate, result.native?, key_env)
331
+ fields = host_fields(result, key_env)
272
332
  ids = result.models.map(&:id)
273
333
  base = Bootstrap::ConfigWriter.derived_name(result.candidate.host)
274
334
  name, reason = begin
@@ -277,8 +337,8 @@ module Samagotchi
277
337
  @exit = usage_error(e.message)
278
338
  return nil
279
339
  end
280
- explain_name(base, name, reason)
281
340
  outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
341
+ explain_name(base, name, reason) unless outcome.kind == :exists
282
342
  code = report(outcome, model)
283
343
  return code unless INSTALLS_BUNDLES.include?(outcome.kind)
284
344
 
@@ -350,18 +410,35 @@ module Samagotchi
350
410
  end
351
411
 
352
412
  # The hosts entry: host/port for a plain http server (api: openai when it
353
- # isn't llama.cpp), url for https or a URL with its own path.
354
- def host_fields(candidate, native, key_env)
413
+ # isn't llama.cpp), url for https or a URL with its own path (an
414
+ # Anthropic one without /v1: the API root, as the hand-written claude
415
+ # host has it). Then api, dialect, auth (Bearer only when the dialect's
416
+ # own header isn't), api_key_env.
417
+ def host_fields(result, key_env)
418
+ candidate = result.candidate
355
419
  fields = if candidate.url || candidate.scheme == "https"
356
- { "url" => native ? candidate.root : candidate.base }
420
+ { "url" => url_field(result) }
357
421
  else
358
422
  { "host" => candidate.host, "port" => candidate.port }
359
423
  end
360
- fields["api"] = "openai" unless native
424
+ if result.anthropic?
425
+ fields["api"] = "anthropic"
426
+ fields["dialect"] = result.dialect if result.dialect
427
+ fields["auth"] = result.auth.to_s if result.auth && result.auth != LLM::Dialect.for(result.dialect).auth
428
+ elsif !result.native?
429
+ fields["api"] = "openai"
430
+ end
361
431
  fields["api_key_env"] = key_env if key_env
362
432
  fields
363
433
  end
364
434
 
435
+ def url_field(result)
436
+ return result.candidate.root if result.native?
437
+ return Bootstrap::Probe.api_root(result.candidate) if result.anthropic?
438
+
439
+ result.candidate.base
440
+ end
441
+
365
442
  def report(outcome, model)
366
443
  path = outcome.path
367
444
  case outcome.kind
@@ -658,7 +658,7 @@ module Samagotchi
658
658
 
659
659
  begin
660
660
  result = @engine.answer_question(id: qid, selected: selected, freeform: freeform, client_id: client_id)
661
- [{}, 200, { status: "answered", session_id: session_id, answer: result }]
661
+ [{}, 200, { status: "answered", session_id: session_id, answer: result.to_h }]
662
662
  rescue Engine::QuestionNotPending => e
663
663
  # Another client answered first, or the question was cancelled.
664
664
  [{}, 409, { error: "question_not_pending", detail: e.message }]
@@ -176,12 +176,12 @@ rules:
176
176
  # ── answering chi's own questions around chi answer ───────────────────
177
177
  # A parent agent answers a child's approval with chi answer, which holds it
178
178
  # to guardrails.parent_approvals. These shapes go around that: chi --attach
179
- # or chi -p with stdin from a pipe, a here-string or a file (a chi after 0.12 treats
180
- # those as a parent too, but an older child doesn't), and a POST to the
181
- # Bridge's or the web's answer route.
179
+ # or chi -p with stdin from a pipe, a here-string or a file other than
180
+ # /dev/null (a chi after 0.12 treats those as a parent too, but an older
181
+ # child doesn't), and a POST to the Bridge's or the web's answer route.
182
182
  - id: chi-answer-piped
183
183
  tool: shell
184
- command: '(\|[^;&|\n]*\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b|\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b[^;&|\n]*<)'
184
+ command: '(\|[^;&|\n]*\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b|\bchi\s([^;&|\n]*\s)?(--attach|--prompt|-p)\b[^;&|\n]*<(?!\s*/dev/null(?![\w./-])))'
185
185
  verdict: ask
186
186
  scopes: [once, session]
187
187
  reason: answers chi's questions from a pipe or a file, around chi answer
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.9.0
3
+ version: 0.9.1
4
4
  scope: system
5
5
  description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer, removing a memory, writing a memory that reaches the system prompt (identity, model notes) or a model overlay; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: slow-commands
3
- version: 0.2.2
3
+ version: 0.2.3
4
4
  scope: system
5
5
  description: "Remembers commands that ran past the execute limit in a project; the next identical execute is denied once a turn with advice to run it with task_create and task_wait; /slow-commands lists and forgets them"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:6bf5da2501a6404cda01c95975b005fce179f756c56222771c5ee857d7ba4b98
9
+ sha256: sha256:249615f1d8529ab020ea9872bb42b00463594f20a9ec44fecc4e0f58d34dce91
10
10
  # Execute::HANDED_OVER, read when the plugin loads (0.59.0); after_tool_call's
11
11
  # call:, elapsed_ms: and result: (0.57.0)
12
12
  requires_chi: ">= 0.59.0"
@@ -21,7 +21,8 @@
21
21
  # `env X=… cmd` is `X=… cmd`, an `env` with -C/-i/-u (or any other option)
22
22
  # stays as typed. The entry's `shown` is the last run as typed. Input that ends inside a quote, $(…)
23
23
  # or heredoc is never recorded, nor one with a $(…), backticks or a heredoc
24
- # cat/tee read as data: ShellLex makes those one placeholder, so different
24
+ # a data reader reads as data (ShellLex: cat, tee, git commit -F -, gh …
25
+ # --body-file -): ShellLex makes those one placeholder, so different
25
26
  # commands would share a key.
26
27
  #
27
28
  # after_tool_call records: timed out (killed at the limit), handed over as
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.79.0
3
+ version: 0.81.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:b5b3166c91f6f2af2563dd9db5f9959cda66747d907017acd0776fcd2fe29049
8
+ self_map.md: sha256:4ff5708e51f55089de47b107643ae0b74aee04cb96111226823a8d44fb4bc33d
9
9
  config_modification_protocol.md: sha256:66792f788cdbb8ee0252f2c387c96bd75266817bb75f721aaa181b602db97137
10
10
  memory_guide.md: sha256:c48eea744db69e4f3e9bb415e9a03a8c78bbcaa1205ad3b1fb157a079f92d22e
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -48,7 +48,7 @@
48
48
  approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
49
49
  - Bundles extend me: memories, `hooks/*.rb`, `guardrails/*.yml`, and a `plugin.rb` that adds slash
50
50
  commands (`anytime:` ones run mid-turn), model tools (they can return images I see), hooks,
51
- cards, side answers (`ask_model`),
51
+ cards, side answers (`ask_model`), HTTP calls (`ctx.http`),
52
52
  child sessions and services (e.g. MCP server processes). Shipped: `btw` (`/btw` side question),
53
53
  `mcp` (MCP server tools, searched with find_tools and called with call_tool; a screenshot comes as a picture; a toolkit's undeclared built-ins too, as chi/<tool>), `guardrails` (rules), `known-names` (typo guard), `source-links`
54
54
  (turns source refs like JIRA-123 in an answer into links in the web, and a one-line note), `loop-guard`
@@ -211,13 +211,13 @@ module Samagotchi
211
211
  def answered(answer, client_id:)
212
212
  # The /continue line takes the text on one line; the steer keeps its
213
213
  # line breaks.
214
- text = answer[:freeform].to_s.gsub(/\s+/, " ").strip
214
+ text = answer.freeform.to_s.gsub(/\s+/, " ").strip
215
215
  @continue_steer = nil
216
- if Array(answer[:selected]).include?(CONTINUE) && !text.empty?
217
- @continue_steer = { text: answer[:freeform].to_s.strip,
218
- source: answer[:by] == "parent_agent" ? "parent_agent" : "user" }
216
+ if answer.selected.include?(CONTINUE) && !text.empty?
217
+ @continue_steer = { text: answer.freeform.to_s.strip,
218
+ source: answer.parent_agent? ? "parent_agent" : "user" }
219
219
  end
220
- line = if Array(answer[:selected]).include?(CONTINUE) then "/continue yes"
220
+ line = if answer.selected.include?(CONTINUE) then "/continue yes"
221
221
  elsif text.empty? then "/continue no"
222
222
  else "/continue no, #{text}"
223
223
  end
@@ -27,6 +27,7 @@ require_relative "tools/tool_index"
27
27
  require_relative "peer_sources"
28
28
  require_relative "session_commands"
29
29
  require_relative "plugin/loader"
30
+ require_relative "plugin/card"
30
31
  require_relative "extension_load"
31
32
  require_relative "log"
32
33
  require_relative "log_subscriber"
@@ -551,6 +552,30 @@ module Samagotchi
551
552
  @session_observer.notify(type: :recap_ready, recap: recap, generation: generation, covered: covered)
552
553
  end
553
554
 
555
+ # Count one side request (a recap, a plugin's ctx.ask_model: IdleClient's
556
+ # on_usage) in the session's side line, apart from its turns' totals.
557
+ # Folded and announced as :side_usage under the event lock, so the side
558
+ # blocks reach the UIs in fold order (the lock order event lock →
559
+ # metrics mutex is the existing one); then, between turns, saved to
560
+ # analytics.json (a running turn's end or checkpoint saves it), unless
561
+ # the session is gone (no session file: a late recap thread must not
562
+ # recreate a deleted session's folder; the folder itself may not exist
563
+ # yet before a first turn). Safe from any thread: the recap's, a
564
+ # plugin's, the turn's.
565
+ # @param usage [SideUsage]
566
+ # @return [void]
567
+ def note_side_usage(usage)
568
+ synchronize_events do
569
+ block = @metrics.add_side(usage)
570
+ @session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
571
+ end
572
+ sid = @session&.id
573
+ return if sid.nil? || turn_running?
574
+ return unless File.exist?(Session.session_file(sid, state_dir: session_state_dir))
575
+
576
+ @metrics.persist(state_dir: session_state_dir)
577
+ end
578
+
554
579
  # @return [SessionMetrics] the per-session analytics collector
555
580
  attr_reader :metrics
556
581
 
@@ -784,9 +809,6 @@ module Samagotchi
784
809
 
785
810
  # ── Cards ───────────────────────────────────────────────────────────────
786
811
 
787
- CARD_LEVELS = %i[info warn].freeze
788
- MAX_CARD_ACTIONS = 6
789
-
790
812
  # Show a card in every UI (docs/plugins.md, Cards): {type: :card, id:,
791
813
  # source:, title:, body:, level:, actions: [{label:, command:}], in_turn:}.
792
814
  # Where it goes (held while the plugins load, an anytime command's, an
@@ -799,7 +821,7 @@ module Samagotchi
799
821
  # @return [String] the card's id
800
822
  # @raise [ArgumentError] a card without a title, or a bad action
801
823
  def show_card(source:, title:, body: "", actions: [], level: :info, id: nil)
802
- card = build_card(source: source, title: title, body: body, actions: actions, level: level, id: id)
824
+ card = Plugin::Card.build(source: source, title: title, body: body, actions: actions, level: level, id: id)
803
825
  @event_routes.route(card, **EventRoutes::CARD_MARKS)
804
826
  card[:id]
805
827
  end
@@ -963,32 +985,6 @@ module Samagotchi
963
985
  end
964
986
  private :announce_or_hold
965
987
 
966
- def build_card(source:, title:, body:, actions:, level:, id:)
967
- title = title.to_s.strip
968
- raise ArgumentError, "a card needs a title" if title.empty?
969
-
970
- level = level.to_s.to_sym
971
- raise ArgumentError, "card level must be one of #{CARD_LEVELS.join(", ")}" unless CARD_LEVELS.include?(level)
972
-
973
- actions = Array(actions)
974
- raise ArgumentError, "a card has at most #{MAX_CARD_ACTIONS} actions" if actions.size > MAX_CARD_ACTIONS
975
-
976
- actions = actions.map do |action|
977
- raise ArgumentError, "a card action is a Hash {label:, command:}" unless action.is_a?(Hash)
978
-
979
- action = action.transform_keys(&:to_sym)
980
- command = action[:command].to_s.strip
981
- raise ArgumentError, "a card action needs a command" if command.empty? || command.include?("\n")
982
-
983
- label = action[:label].to_s.strip
984
- { label: label.empty? ? command : label, command: command }
985
- end
986
- id = id.to_s.strip
987
- { type: :card, id: id.empty? ? SecureRandom.hex(4) : id, source: source.to_s, title: title, body: body.to_s,
988
- level: level, actions: actions }
989
- end
990
- private :build_card
991
-
992
988
  # ── Model switching ────────────────────────────────────────────────────────
993
989
 
994
990
  # @param typed [String, nil] the name the model was typed as when
@@ -1452,6 +1448,7 @@ module Samagotchi
1452
1448
  emit_event(turn.on_event, turn.tag(event))
1453
1449
  end
1454
1450
  turn.ended = true
1451
+ @turn_state.ended!
1455
1452
  save_quietly(turn.session) if save
1456
1453
  @metrics.persist(state_dir: session_state_dir)
1457
1454
  end
@@ -1582,7 +1579,8 @@ module Samagotchi
1582
1579
  # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
1583
1580
  # (as a Hash or as keywords)
1584
1581
  # @param watch [#call, nil] closes the question with the reason it returns
1585
- # @return [Hash, String] the answer, or {error:, …}
1582
+ # @return [QuestionAnswer, Hash, String] the answer, or {error:, …}, or a
1583
+ # sync handler's text
1586
1584
  def open_question(fields = nil, watch: nil, **more)
1587
1585
  @question_desk.open_question({ **fields.to_h, **more }, watch: watch)
1588
1586
  end
@@ -1601,7 +1599,7 @@ module Samagotchi
1601
1599
  end
1602
1600
 
1603
1601
  # Answer the pending question (called from UI thread; QuestionDesk#answer).
1604
- # @return [Hash] normalized answer
1602
+ # @return [QuestionAnswer]
1605
1603
  def answer_question(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
1606
1604
  @question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id,
1607
1605
  parent_agent: parent_agent)
@@ -2569,8 +2567,10 @@ module Samagotchi
2569
2567
  toolkit: -> { @toolkit_binding.name },
2570
2568
  declared_tools: -> { toolkit_explained.declared.map(&:name) },
2571
2569
  use_toolkit: ->(name) { use_toolkit(name) },
2572
- ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
2573
- ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
2570
+ cancel_controller: ->(handler: false) { plugin_cancel_controller(handler: handler) },
2571
+ ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
2572
+ ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
2573
+ model: model, source: source)
2574
2574
  },
2575
2575
  model_name: -> { effective_model_ref },
2576
2576
  model_key: -> { @model_key },
@@ -2585,6 +2585,15 @@ module Samagotchi
2585
2585
  )
2586
2586
  end
2587
2587
 
2588
+ # The controller a plugin's ctx.http call without cancel: stops with
2589
+ # (docs/plugins.md): an init task's own, else the running turn's for the
2590
+ # turn's own work until the turn has ended (TurnState#own_controller); nil
2591
+ # for an anytime command, a service or a thread of the plugin's.
2592
+ def plugin_cancel_controller(handler:)
2593
+ (task = current_init_task) ? task.cancel : @turn_state.own_controller(handler: handler)
2594
+ end
2595
+ private :plugin_cancel_controller
2596
+
2588
2597
  # A plugin's ctx.messages: the conversation without the system prompt
2589
2598
  # (the REPL's starts with it, a worker's new session doesn't), and,
2590
2599
  # while a turn runs, the turn so far from the Bridge (plan O1). Read
@@ -2601,23 +2610,43 @@ module Samagotchi
2601
2610
  private :plugin_messages
2602
2611
 
2603
2612
  # ctx.ask_model's request: the session's current model on its host, as
2604
- # a turn resolves them (a /model switch counts), through its own
2613
+ # a turn resolves them (a /model switch counts), or a configured +model+
2614
+ # (a host:id ref, an alias, a bare id one of a host's models), through its own
2605
2615
  # IdleClient, so it shares nothing with the turn's backend. The client
2606
2616
  # is kept while the target and timeout stay (what it learned, e.g. a
2607
2617
  # refused thinking-off, holds for the next request); the session's id
2608
- # goes with the request.
2618
+ # goes with the request. Its usage counts in the session's side line
2619
+ # (#note_side_usage) under +source+, the asking bundle's name.
2609
2620
  # @return [String] the answer
2610
- def ask_side_model(request, timeout:, max_tokens:, cancel_controller:)
2611
- target = session_model_recap_target
2621
+ def ask_side_model(request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil)
2622
+ target = model.nil? ? session_model_recap_target : configured_side_target(model)
2612
2623
  client = side_model_client(target, timeout)
2613
2624
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
2614
- answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id)
2625
+ answer = client.ask(request, max_tokens: max_tokens, cancel_controller: cancel_controller, session_id: @session&.id,
2626
+ kind: source.to_s.empty? ? "ask" : source.to_s, on_usage: method(:note_side_usage))
2615
2627
  Log.info(:plugins, "ask_model", model: target.label, answer_model: answer.model, chars: answer.text.length,
2616
2628
  ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round)
2617
2629
  answer.text
2618
2630
  end
2619
2631
  private :ask_side_model
2620
2632
 
2633
+ # The target of ctx.ask_model(model:): +model+ as a turn resolves it,
2634
+ # once it is known to name something configured. IdleTarget.of_model
2635
+ # never raises (an unknown host falls to the default host, an unknown id
2636
+ # to the default entry), so a typo would silently ask another model.
2637
+ # @raise [ArgumentError] +model+ names no configured host and is no
2638
+ # host's model (cached, declared or saved lists; nothing is asked)
2639
+ def configured_side_target(model)
2640
+ ref = @host_registry.model_ref(model)
2641
+ unless ref.host_name || ref.alias_name || @host_registry.hosts_with_model(ref.id).any?
2642
+ raise ArgumentError, "ask_model: unknown model #{model.to_s.inspect} (use a configured host:id, an alias, " \
2643
+ "or a model id one of your hosts lists)"
2644
+ end
2645
+
2646
+ IdleTarget.of_model(@host_registry, model)
2647
+ end
2648
+ private :configured_side_target
2649
+
2621
2650
  # The IdleClient for +target+ at +timeout+: the last one when both are
2622
2651
  # the same, else a new one.
2623
2652
  def side_model_client(target, timeout)
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
 
5
+ require_relative "../question_answer"
6
+
5
7
  module Samagotchi
6
8
  module Guardrails
7
9
  # An ask as a question for the user (Engine#open_question), and the
@@ -67,22 +69,22 @@ module Samagotchi
67
69
  end
68
70
 
69
71
  # Settle +verdict+ from open_question's result.
70
- # @param answer [Hash, String] {selected_indices:, freeform:} or {error:}
72
+ # @param answer [QuestionAnswer, Hash, String] the answer, or {error:} (anything else is a cancel)
71
73
  # @param scopes [Array<String>] the offered scopes, in option order
72
74
  # @return [Verdict]
73
75
  def settle(verdict, answer, scopes)
74
- unless answer.is_a?(Hash) && !answer[:error]
76
+ unless answer.is_a?(QuestionAnswer)
75
77
  return verdict.settle!(:deny, decided_by: "user", note: "The approval was cancelled.")
76
78
  end
77
79
 
78
- index = Array(answer[:selected_indices]).first
80
+ index = answer.selected_indices.first
79
81
  if index && index < scopes.size
80
82
  verdict.settle!(:allow, decided_by: "user")
81
83
  verdict.scope = scopes[index]
82
84
  return verdict
83
85
  end
84
86
 
85
- freeform = answer[:freeform].to_s.strip
87
+ freeform = answer.freeform.to_s.strip
86
88
  note = freeform.empty? ? "The user declined this call." : "The user declined this call: #{freeform.inspect}."
87
89
  verdict.settle!(:deny, decided_by: "user", note: note)
88
90
  end