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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -1
- data/README.md +7 -5
- data/docs/architecture.md +9 -1
- data/docs/cli.md +32 -7
- data/docs/configuration.md +10 -4
- data/docs/guardrails.md +26 -3
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +4 -0
- data/docs/internals/engine.md +13 -3
- data/docs/plugins.md +149 -3
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +9 -1
- data/docs/testing.md +51 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
- data/lib/samagotchi/bootstrap/probe.rb +153 -31
- data/lib/samagotchi/bootstrap_command.rb +93 -16
- data/lib/samagotchi/bridge.rb +1 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/continue_offer.rb +5 -5
- data/lib/samagotchi/engine.rb +68 -39
- data/lib/samagotchi/guardrails/approval.rb +6 -4
- data/lib/samagotchi/guardrails/data_readers.rb +214 -0
- data/lib/samagotchi/guardrails/gate.rb +7 -3
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
- data/lib/samagotchi/guardrails/targets.rb +14 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
- data/lib/samagotchi/hooks/registry.rb +44 -3
- data/lib/samagotchi/host_config.rb +8 -8
- data/lib/samagotchi/host_registry.rb +12 -4
- data/lib/samagotchi/idle_client.rb +41 -11
- data/lib/samagotchi/idle_recap.rb +6 -1
- data/lib/samagotchi/idle_target.rb +12 -4
- data/lib/samagotchi/llm/adapter_common.rb +4 -2
- data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
- data/lib/samagotchi/llm/http.rb +12 -7
- data/lib/samagotchi/llm/usage.rb +9 -3
- data/lib/samagotchi/model_price.rb +16 -0
- data/lib/samagotchi/plugin/card.rb +44 -0
- data/lib/samagotchi/plugin/context.rb +61 -9
- data/lib/samagotchi/plugin/http.rb +154 -0
- data/lib/samagotchi/plugin/http_transport.rb +77 -0
- data/lib/samagotchi/plugin/loader.rb +29 -10
- data/lib/samagotchi/plugin/service.rb +24 -1
- data/lib/samagotchi/question_answer.rb +29 -0
- data/lib/samagotchi/question_desk.rb +20 -19
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/relay_verifier.rb +1 -1
- data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
- data/lib/samagotchi/session_metrics.rb +46 -12
- data/lib/samagotchi/session_observer.rb +3 -0
- data/lib/samagotchi/side_usage.rb +38 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
- data/lib/samagotchi/testing/boundary.rb +156 -0
- data/lib/samagotchi/testing/bundle.rb +349 -0
- data/lib/samagotchi/testing/fake_executable.rb +42 -0
- data/lib/samagotchi/testing/fake_http.rb +137 -0
- data/lib/samagotchi/testing/helpers.rb +56 -0
- data/lib/samagotchi/testing/host.rb +76 -0
- data/lib/samagotchi/testing/minitest.rb +31 -0
- data/lib/samagotchi/testing/recorder.rb +141 -0
- data/lib/samagotchi/testing/recording_api.rb +27 -0
- data/lib/samagotchi/testing/rspec.rb +31 -0
- data/lib/samagotchi/testing/sandbox.rb +165 -0
- data/lib/samagotchi/testing/sessions.rb +77 -0
- data/lib/samagotchi/testing/verdict.rb +28 -0
- data/lib/samagotchi/testing/waiting.rb +36 -0
- data/lib/samagotchi/testing.rb +26 -0
- data/lib/samagotchi/tools/delegate_relay.rb +7 -6
- data/lib/samagotchi/tools/tool_index.rb +3 -0
- data/lib/samagotchi/turn_state.rb +28 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -1
- data/lib/samagotchi/web/public/app.js +47 -23
- data/lib/samagotchi/web/public/ctx.js +32 -6
- data/lib/samagotchi/web/public/images.js +33 -0
- data/lib/samagotchi/web/public/index.html +71 -38
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/level_chip.js +11 -11
- data/lib/samagotchi/web/public/lists_view.js +20 -15
- data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
- data/lib/samagotchi/web/public/model_picker.js +1 -1
- data/lib/samagotchi/web/public/scope.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +7 -0
- data/lib/samagotchi/web/public/strip.js +35 -9
- data/lib/samagotchi/web/public/strip_grip.js +76 -0
- data/lib/samagotchi/web/public/thinking_chip.js +5 -5
- data/lib/samagotchi/web/public/timing.js +6 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- 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
|
|
18
|
-
# picks the model, sends one
|
|
19
|
-
# file, or a hosts: entry
|
|
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] [--
|
|
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
|
|
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
|
|
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
|
-
|
|
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" =>
|
|
420
|
+
{ "url" => url_field(result) }
|
|
357
421
|
else
|
|
358
422
|
{ "host" => candidate.host, "port" => candidate.port }
|
|
359
423
|
end
|
|
360
|
-
|
|
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
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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
|
|
180
|
-
# those as a parent too, but an older
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
#
|
|
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.
|
|
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:
|
|
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
|
|
214
|
+
text = answer.freeform.to_s.gsub(/\s+/, " ").strip
|
|
215
215
|
@continue_steer = nil
|
|
216
|
-
if
|
|
217
|
-
@continue_steer = { text: answer
|
|
218
|
-
source: answer
|
|
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
|
|
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
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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 =
|
|
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 [
|
|
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
|
-
|
|
2573
|
-
|
|
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),
|
|
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]
|
|
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?(
|
|
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 =
|
|
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
|
|
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
|