samagotchi 0.22.0 → 0.24.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 +72 -1
- data/bin/chi +9 -4
- data/docs/cli.md +6 -1
- data/docs/configuration.md +13 -4
- data/docs/hooks.md +20 -6
- data/docs/plugins.md +1 -1
- data/docs/releasing.md +1 -1
- data/docs/sub-agent.md +4 -3
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +4 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +2 -2
- data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
- data/lib/samagotchi/bridge/card_store.rb +4 -3
- data/lib/samagotchi/bridge/sse_writer.rb +0 -6
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -3
- data/lib/samagotchi/bridge/turn_notice.rb +1 -0
- data/lib/samagotchi/bridge.rb +19 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
- data/lib/samagotchi/bridge_client.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +39 -29
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +4 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cancellation_controller.rb +1 -1
- data/lib/samagotchi/cli/command.rb +1 -1
- data/lib/samagotchi/client.rb +3 -5
- data/lib/samagotchi/commands/registry.rb +2 -2
- data/lib/samagotchi/config.rb +42 -14
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +6 -6
- data/lib/samagotchi/continue_offer.rb +15 -8
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/desktop/macos.rb +5 -4
- data/lib/samagotchi/empty_answer_retry.rb +1 -1
- data/lib/samagotchi/engine.rb +97 -18
- data/lib/samagotchi/gem_update.rb +2 -1
- data/lib/samagotchi/generation_phase.rb +83 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +5 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -2
- data/lib/samagotchi/guardrails/gate.rb +2 -2
- data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +113 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
- data/lib/samagotchi/hooks/loader.rb +18 -16
- data/lib/samagotchi/hooks/registry.rb +10 -2
- data/lib/samagotchi/host_registry.rb +1 -3
- data/lib/samagotchi/idle_recap.rb +22 -17
- data/lib/samagotchi/idle_reminders.rb +6 -3
- data/lib/samagotchi/image_store.rb +5 -2
- data/lib/samagotchi/kernel_loop.rb +50 -40
- data/lib/samagotchi/llm/chat_loop.rb +29 -16
- data/lib/samagotchi/llm/errors.rb +2 -2
- data/lib/samagotchi/llm/http.rb +12 -12
- data/lib/samagotchi/llm/openai_chat.rb +42 -28
- data/lib/samagotchi/llm/prompt_cache.rb +1 -1
- data/lib/samagotchi/log.rb +2 -2
- data/lib/samagotchi/log_subscriber.rb +5 -3
- data/lib/samagotchi/memory_bundle/builder.rb +9 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
- data/lib/samagotchi/memory_bundle/installer.rb +67 -57
- data/lib/samagotchi/memory_bundle/listing.rb +5 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
- data/lib/samagotchi/memory_bundle/merger.rb +6 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
- data/lib/samagotchi/memory_bundle/source.rb +7 -3
- data/lib/samagotchi/memory_bundle/status.rb +3 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
- data/lib/samagotchi/memory_bundle/trash.rb +5 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/model_overlay.rb +3 -5
- data/lib/samagotchi/model_profile.rb +12 -8
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/output_formatter.rb +18 -17
- data/lib/samagotchi/owner_lock.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +8 -5
- data/lib/samagotchi/plugin/context.rb +1 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/plugin/service.rb +1 -1
- data/lib/samagotchi/plugin/sessions.rb +1 -0
- data/lib/samagotchi/plugin_tasks.rb +1 -1
- data/lib/samagotchi/prompt.rb +72 -13
- data/lib/samagotchi/prompt_literal_guard.rb +1 -1
- data/lib/samagotchi/question_desk.rb +7 -7
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/relay_desk.rb +1 -1
- data/lib/samagotchi/reminder_store.rb +2 -1
- data/lib/samagotchi/send_command.rb +4 -2
- data/lib/samagotchi/session.rb +2 -2
- data/lib/samagotchi/session_archive_command.rb +5 -1
- data/lib/samagotchi/session_commands.rb +1 -3
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/session_metrics.rb +10 -10
- data/lib/samagotchi/session_observer.rb +3 -2
- data/lib/samagotchi/session_retention.rb +2 -2
- data/lib/samagotchi/sessions_command.rb +14 -5
- data/lib/samagotchi/steer.rb +103 -8
- data/lib/samagotchi/system_prompt.rb +19 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +19 -10
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
- data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
- data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
- data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
- data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
- data/lib/samagotchi/terminal_ui.rb +14 -5
- data/lib/samagotchi/text_diff.rb +12 -12
- data/lib/samagotchi/thinking.rb +6 -2
- data/lib/samagotchi/thought_stream_splitter.rb +15 -6
- data/lib/samagotchi/tool_activity.rb +1 -0
- data/lib/samagotchi/tool_call_parser.rb +13 -14
- data/lib/samagotchi/tool_declarations.rb +36 -35
- data/lib/samagotchi/tool_runner.rb +7 -0
- data/lib/samagotchi/tool_view.rb +40 -0
- data/lib/samagotchi/tools/ask_user_question.rb +3 -4
- data/lib/samagotchi/tools/builtin_calls.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +0 -1
- data/lib/samagotchi/tools/edit.rb +14 -13
- data/lib/samagotchi/tools/execute.rb +3 -5
- data/lib/samagotchi/tools/list_reminders.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +21 -22
- data/lib/samagotchi/tools/read.rb +7 -7
- data/lib/samagotchi/tools/task_create.rb +1 -1
- data/lib/samagotchi/tools/task_get.rb +1 -1
- data/lib/samagotchi/tools/task_list.rb +1 -1
- data/lib/samagotchi/tools/task_runtime.rb +8 -9
- data/lib/samagotchi/tools/task_stop.rb +1 -1
- data/lib/samagotchi/tools/task_wait.rb +3 -4
- data/lib/samagotchi/tools/web_fetch.rb +2 -4
- data/lib/samagotchi/tools/write.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -8
- data/lib/samagotchi/turn_note.rb +5 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -2
- data/lib/samagotchi/waiting_steer.rb +64 -0
- data/lib/samagotchi/web/app.rb +13 -6
- data/lib/samagotchi/web/markdown_renderer.rb +2 -1
- data/lib/samagotchi/web/message_parts.rb +5 -0
- data/lib/samagotchi/web/public/activity.js +5 -0
- data/lib/samagotchi/web/public/app.js +2 -1
- data/lib/samagotchi/web/public/command_view.js +28 -0
- data/lib/samagotchi/web/public/copy.js +7 -0
- data/lib/samagotchi/web/public/index.html +8 -0
- data/lib/samagotchi/web/public/stage_model.js +11 -4
- data/lib/samagotchi/web/public/stage_view.js +5 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -1
- data/lib/samagotchi/web/public/turn_view.js +15 -6
- data/lib/samagotchi/web/qr.rb +7 -1
- data/lib/samagotchi/web/server.rb +6 -2
- data/lib/samagotchi/worker.rb +14 -3
- metadata +24 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d6348260d3f666ffbfc0b8c3f10a51a2c64c3a30ce9cbd5a5c1280fa0325306
|
|
4
|
+
data.tar.gz: 0cad64155e27a000a49c82e89778d69804fc609526418c1e03846a32d2fb864c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9a1f022f8a225c517e3160d0d512fbb5a97750c32a6df7769bbcb91ddff5534c5b12f540c4ef80871d8e4f2f80d460c71cd56107476435a69d54cef0ef78395
|
|
7
|
+
data.tar.gz: 419a527ffca2330de67b42544f619db872c06388ded821d3bc5e7f17f07a488a7adf96ce56d55032a53c4bc21e6076d32af398c0b8aff15c48e651e6596e7873
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,75 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.24.0] - 2026-10-04
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `steer.cut_after` (default 20 s, `0` = never): a message for a running turn from you, `chi send` or a parent
|
|
16
|
+
agent cuts a generation that has streamed only thinking for that long (counted from its first thinking token;
|
|
17
|
+
a message that came earlier cuts once the thinking gets there).
|
|
18
|
+
The model starts the step again with the message, and a `↪ cut in for your message` row marks it. Plugins'
|
|
19
|
+
steers never cut. Only the cut thinking is lost (llama.cpp re-reads the few tokens after its cached prompt).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- The web shows an `execute` / `task_create` call's full command: an expanded tool row has it as a block above the
|
|
24
|
+
output (whitespace kept, a copy button, `in <cwd>` when the call gave one), and the row's and the stage's hovers
|
|
25
|
+
show the whole command instead of the 80-character `command="…"` line. The terminal is unchanged.
|
|
26
|
+
- A message that joins a running turn (a line typed in the terminal or the web, `chi send -m`, a delegate's
|
|
27
|
+
follow-up, `chi answer --option Continue --text`, a plugin's `ctx.steer`) reaches the model with a one-line
|
|
28
|
+
header naming its sender and asking it to follow the message, or carry on if it asks for nothing, so the model
|
|
29
|
+
can tell who is steering it. Sessions keep the raw text.
|
|
30
|
+
- The `generation_stopped` log line names who cut the generation as `by=` (was `bundle=`), as a message can cut
|
|
31
|
+
it now too.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Guardrails read heredocs (`cat <<'EOF' … EOF`): a heredoc's body no longer asks needlessly (an `rm -rf` or
|
|
36
|
+
`git push` written into a file by `cat`/`tee`), and an apostrophe in a body no longer hides the commands after it
|
|
37
|
+
from the strict-mode check for git outside the repo. A body fed to `bash`, `sh` and the like still counts.
|
|
38
|
+
- A `chi send -m` or delegate follow-up merged into a running turn is saved with its sender (`source: chi_send` /
|
|
39
|
+
`parent_agent`) instead of reading as the user's own words.
|
|
40
|
+
- The idle recap keeps the text of a `chi answer --option Continue --text` (or a continue card answered with
|
|
41
|
+
text); it was dropped as a plugin's prod.
|
|
42
|
+
- check-in no longer says a nudge was not sent when a user line was merged after it (bundle check-in 0.2.3).
|
|
43
|
+
- A plugin's cut of a generation (loop-guard) with a message or a plugin's steer waiting now delivers it even after
|
|
44
|
+
`retry.empty_answer`'s budget is spent; the turn used to end cancelled and drop a waiting plugin steer.
|
|
45
|
+
|
|
46
|
+
Update with `chi update`: check-in moves to 0.2.3 (the nudge-not-sent fix). Restart `chi web` and running sessions
|
|
47
|
+
afterwards (`chi sessions restart ID`).
|
|
48
|
+
|
|
49
|
+
## [0.23.0] - 2026-10-04
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- chi no longer strips the blank lines Splash before 1.2.0 put at the start of an answer (incoai/splash#254, fixed
|
|
54
|
+
in Splash 1.2.0): upgrade Splash with `brew upgrade incoai/tap/splash`.
|
|
55
|
+
- Requests to OpenRouter send `max_tokens: 32768` (a request's own limit, as a recap's, still wins). OpenRouter
|
|
56
|
+
holds each running request's estimated cost against the balance, counting the output `max_tokens` allows (a
|
|
57
|
+
fixed per-request cap without one), so concurrent requests hit "would exceed your available credits given your
|
|
58
|
+
current in-flight requests" (402) sooner without a limit. `default.max_tokens` raises or lowers that limit.
|
|
59
|
+
- `default.n_predict` is renamed `default.max_tokens` (env `SAMAGOTCHI_DEFAULT_MAX_TOKENS`, flag
|
|
60
|
+
`--default-max-tokens`), with no alias: an old `n_predict:` in config.yml warns as an unknown key. It now
|
|
61
|
+
limits chat turns on OpenAI-compatible hosts too, sent as `max_tokens` on any host; it was used only by native
|
|
62
|
+
hosts (where it is still sent as llama.cpp's `n_predict`).
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- A Ctrl-C (or SIGTERM) while chi loads bundle hooks and plugins stops chi again; before, it was logged as a
|
|
67
|
+
failed hook and chi carried on. A hook's or plugin's own error, `exit` or syntax error is still only reported.
|
|
68
|
+
- A continue turn that fails before it begins (its session save, say) asks the step-limit question again
|
|
69
|
+
instead of crashing the session's worker.
|
|
70
|
+
- Native Gemma 4 prompts follow Gemma 4's chat template: turns end with `<turn|>` (not Gemma 3's
|
|
71
|
+
`<end_of_turn>`, which Gemma 4 reads as plain text), so the stop sequence matches; tool results stay
|
|
72
|
+
inside the model's turn as `response:NAME{value:…}` blocks, one per call; tools are declared in the
|
|
73
|
+
template's compact form at the end of the system turn; an answer's thought is dropped once the next
|
|
74
|
+
user turn starts; and thinking off prefills Gemma's empty thought channel.
|
|
75
|
+
|
|
76
|
+
Update with `chi update`: btw 0.1.2, check-in 0.2.2, known-names 0.1.5, loop-guard 0.3.2, mcp 0.4.2, skills 0.1.6 and
|
|
77
|
+
source-links 0.3.3 move (code style only, no behaviour change). If your config.yml has `default: n_predict:`, rename it
|
|
78
|
+
to `max_tokens:`. Restart `chi web` and running sessions afterwards (`chi sessions restart ID`).
|
|
79
|
+
|
|
11
80
|
## [0.22.0] - 2026-10-04
|
|
12
81
|
|
|
13
82
|
### Added
|
|
@@ -1273,7 +1342,9 @@ and long-lived sessions.
|
|
|
1273
1342
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
1274
1343
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
1275
1344
|
|
|
1276
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
1345
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.24.0...HEAD
|
|
1346
|
+
[0.24.0]: https://github.com/dm1try/samagotchi/compare/v0.23.0...v0.24.0
|
|
1347
|
+
[0.23.0]: https://github.com/dm1try/samagotchi/compare/v0.22.0...v0.23.0
|
|
1277
1348
|
[0.22.0]: https://github.com/dm1try/samagotchi/compare/v0.21.0...v0.22.0
|
|
1278
1349
|
[0.21.0]: https://github.com/dm1try/samagotchi/compare/v0.20.0...v0.21.0
|
|
1279
1350
|
[0.20.0]: https://github.com/dm1try/samagotchi/compare/v0.19.0...v0.20.0
|
data/bin/chi
CHANGED
|
@@ -72,6 +72,7 @@ cli_overrides = {}
|
|
|
72
72
|
# Strict kebab enforcement: --recap_base_url is unknown (OptionParser would otherwise accept _ as -)
|
|
73
73
|
ARGV.each do |arg|
|
|
74
74
|
next unless arg.start_with?("--") && arg.include?("_")
|
|
75
|
+
|
|
75
76
|
# Allow --no-interrupt etc. already dash; underscore is always rejected for config flags
|
|
76
77
|
# We check raw ARGV before OptionParser normalizes
|
|
77
78
|
base = arg.split("=", 2).first
|
|
@@ -108,7 +109,7 @@ parser = OptionParser.new do |opts|
|
|
|
108
109
|
opts.on("--[no-]shared", "Run the session in a background worker and attach to it, so `chi web` can share it (the default, config session.shared; with --resume: that session); --no-shared runs a plain in-process REPL") { |v| options[:shared] = v }
|
|
109
110
|
opts.on("--model NAME", "Model for this run, not saved (overrides --resume's; config default.model) [env SAMAGOTCHI_DEFAULT_MODEL]") { |m| options[:model] = m; cli_overrides["default.model"] = m }
|
|
110
111
|
opts.on("--profile NAME", Samagotchi::ModelProfile::NAMES,
|
|
111
|
-
"Prompt profile for every model in this run (#{Samagotchi::ModelProfile::NAMES.join(
|
|
112
|
+
"Prompt profile for every model in this run (#{Samagotchi::ModelProfile::NAMES.join("|")}) [alias for --model-profile]") { |p| cli_overrides["model.profile"] = p }
|
|
112
113
|
opts.on("--thinking LEVEL", Samagotchi::Thinking::LEVELS.map(&:to_s), "How much the model thinks this run: off|low|medium|high|default (outranks config.yml; " \
|
|
113
114
|
"sessions already running keep theirs) [env SAMAGOTCHI_THINKING_LEVEL]") { |l| cli_overrides["thinking.level"] = l }
|
|
114
115
|
opts.on("--memory NAME", "Preload a memory entry into the system prompt (repeatable; a comma list too)") { |m| (options[:memories] ||= []) << m }
|
|
@@ -133,13 +134,14 @@ parser = OptionParser.new do |opts|
|
|
|
133
134
|
next if entry.key == "web.port" # already handled as --port
|
|
134
135
|
next if entry.key == "default.model" # alias --model above
|
|
135
136
|
next if entry.key == "web.markdown" # handled below with explicit inverse flags
|
|
137
|
+
|
|
136
138
|
case entry.type
|
|
137
139
|
when :bool
|
|
138
140
|
# --[no-]: a bool that defaults to on is only useful switched off.
|
|
139
|
-
opts.on("--[no-]#{entry.cli_flag.delete_prefix(
|
|
141
|
+
opts.on("--[no-]#{entry.cli_flag.delete_prefix("--")}", "Set #{entry.key} (bool, default #{entry.default ? "on" : "off"}) [env #{entry.env_key}]") { |v| cli_overrides[entry.key] = v }
|
|
140
142
|
when :integer, :float, :string, :enum
|
|
141
143
|
desc = "Set #{entry.key} [env #{entry.env_key}]"
|
|
142
|
-
desc += " (#{entry.enum_values.join(
|
|
144
|
+
desc += " (#{entry.enum_values.join("|")})" if entry.type == :enum
|
|
143
145
|
desc += " (#{value_hints[entry.key]})" if value_hints.key?(entry.key)
|
|
144
146
|
desc += "; only without a hosts: config" if %w[server.host server.port].include?(entry.key)
|
|
145
147
|
desc += "; hosts: entries without a transport use it" if entry.key == "server.transport"
|
|
@@ -257,7 +259,10 @@ if options[:memories] || options[:muted]
|
|
|
257
259
|
end
|
|
258
260
|
memory_exists = lambda do |raw|
|
|
259
261
|
scope, name = raw.split("/", 2)
|
|
260
|
-
|
|
262
|
+
unless name && Samagotchi::Tools::VALID_SCOPES.include?(scope)
|
|
263
|
+
scope = nil
|
|
264
|
+
name = raw
|
|
265
|
+
end
|
|
261
266
|
!Samagotchi::Tools::MemoryRead.call(name, scope: scope).start_with?("Error:")
|
|
262
267
|
end
|
|
263
268
|
cli_memories = (options[:memories] || []).flat_map { |m| m.split(",") }.map(&:strip).reject(&:empty?)
|
data/docs/cli.md
CHANGED
|
@@ -274,7 +274,12 @@ Plain `chi` runs the session in a background worker and attaches the terminal
|
|
|
274
274
|
to it (`session.shared`, default `true`). A worker's session can have any number
|
|
275
275
|
of UIs at once: the Web UI and attached terminals (`chi`, `--resume`,
|
|
276
276
|
`--attach`). They all see the same turns as they happen, and any of them can send
|
|
277
|
-
a prompt, also while a turn runs (it merges into that turn as steering
|
|
277
|
+
a prompt, also while a turn runs (it merges into that turn as steering: the model
|
|
278
|
+
is told it is a steer and who sent it). Steering waits for the model's next step,
|
|
279
|
+
unless the model has streamed only thinking for `steer.cut_after` seconds (default
|
|
280
|
+
20), now or while your message waits: then that generation is cut and the step starts again with your message, marked
|
|
281
|
+
`↪ cut in for your message` (see
|
|
282
|
+
[configuration.md](configuration.md#llama-network-retry-behavior)). The
|
|
278
283
|
first answer to an `ask_user_question` wins; the other UIs close their widget.
|
|
279
284
|
An empty answer dismisses the question in every UI. The model is then told
|
|
280
285
|
not to go ahead with what it asked about, or change anything else, and to wait.
|
data/docs/configuration.md
CHANGED
|
@@ -308,8 +308,8 @@ prompt profile (see "Prompt profile" below). How the selector reaches the reques
|
|
|
308
308
|
(`model: Field required`) without it, so samagotchi forwards the selector
|
|
309
309
|
resolved to the exact id listed in the server's `/v1/models` — matched by exact
|
|
310
310
|
(case-insensitive) first, then substring, then passed through unchanged. That
|
|
311
|
-
resolved id is usually prefixed (e.g. `mlx-community--gemma-
|
|
312
|
-
short selector such as `gemma-
|
|
311
|
+
resolved id is usually prefixed (e.g. `mlx-community--gemma-4-e4b-it-4bit`), so a
|
|
312
|
+
short selector such as `gemma-4-e4b-it-4bit` is what you set in
|
|
313
313
|
`default.model`. An unknown selector passes through raw and oMLX 404s,
|
|
314
314
|
listing its available models; if `/v1/models` is unreachable, samagotchi falls
|
|
315
315
|
back to the raw selector and lets the server decide (its own 400/404). Either
|
|
@@ -535,7 +535,7 @@ With several `hosts:`, `host:model` (or an alias naming a host) pins the host.
|
|
|
535
535
|
An unqualified model name goes to the default host when its `/models` list has
|
|
536
536
|
that exact id, else to the first host in `hosts:` order whose list has it, else
|
|
537
537
|
to the default host. Only exact ids count, never a substring: `/model gemma` on
|
|
538
|
-
a box that lists `gemma-
|
|
538
|
+
a box that lists `gemma-4-26b` needs `box:gemma` or the exact id. The lists are
|
|
539
539
|
known only after `/models` ran (nothing is fetched before the first turn), so
|
|
540
540
|
until then an unqualified name goes to the default host; use `host:model` to pin
|
|
541
541
|
one. A **remote** host (an `https` url or a public address; see "Remote or local") keeps its model
|
|
@@ -646,6 +646,14 @@ no made-up answer is saved.
|
|
|
646
646
|
A generation a plugin cuts while it streams (loop-guard's thinking watch, or any `stop_generation`,
|
|
647
647
|
[hooks.md](hooks.md#watching-the-stream)) uses the same budget: `↻ cut by loop-guard, asking again (1/1)`, and with
|
|
648
648
|
none left the turn ends as cancelled (hook).
|
|
649
|
+
A message for a running turn waits for the next step, unless the model's generation has streamed only thinking for
|
|
650
|
+
`steer.cut_after` seconds (default `20`, env `SAMAGOTCHI_STEER_CUT_AFTER`, `0` = never; counted from its first
|
|
651
|
+
thinking token, so the wait for a busy server doesn't count) and still does: then a message from you (the terminal,
|
|
652
|
+
the web), `chi send` or a parent agent cuts that generation, and the model starts the step again with the message
|
|
653
|
+
(`↪ cut in for your message`). A message that comes earlier cuts once the thinking passes `steer.cut_after`, unless
|
|
654
|
+
the step ends first and the message goes in there. Such a cut spends no `retry.empty_answer` attempt and sends no
|
|
655
|
+
hidden note; only the cut thinking is lost. A generation that has streamed visible text or a tool call is never cut,
|
|
656
|
+
and a plugin's steer (`ctx.steer`, `ctx.sessions.send`) never cuts.
|
|
649
657
|
|
|
650
658
|
Assist-mode UX:
|
|
651
659
|
|
|
@@ -837,7 +845,7 @@ described in their own sections.
|
|
|
837
845
|
|---|---|---|---|
|
|
838
846
|
| `default.model` | (required) | `--model` | The model a new session starts with; `host:model` pins a host. |
|
|
839
847
|
| `default.input` | none | | Text pre-filled at the first prompt (a trailing space is kept); `--no-default-input` skips it. See [CLI](cli.md). |
|
|
840
|
-
| `default.
|
|
848
|
+
| `default.max_tokens` | server's | yes | Most tokens one generation may produce (`n_predict` on a native llama.cpp host; OpenRouter: 32768 when unset). |
|
|
841
849
|
| `model.profile` | none | `--profile` | Prompt profile for every model (`qwen36`, `gemma4`); env and CLI only. See "Prompt profile". |
|
|
842
850
|
| `server.transport` | `llama_cpp` | yes | `llama_cpp`, `mlx` or `omlx`; see "Model Server Transport". |
|
|
843
851
|
| `server.host` | `localhost` | yes | The model server when there is no `hosts:` map. |
|
|
@@ -887,6 +895,7 @@ described in their own sections.
|
|
|
887
895
|
| `retry.base_delay` | `0.5` | yes | |
|
|
888
896
|
| `retry.max_delay` | `8.0` | yes | |
|
|
889
897
|
| `retry.empty_answer` | `1` | | Times a turn asks again after an empty answer (at most 3, `0` = off). See "Llama Network Retry Behavior". |
|
|
898
|
+
| `steer.cut_after` | `20` | | Seconds a generation must have streamed only thinking before a message for the running turn (yours, `chi send`'s, a parent agent's) cuts it; `0` = never. See "Llama Network Retry Behavior". |
|
|
890
899
|
| `turn.max_iterations` | `100` | | A turn's step limit: model ↔ tool rounds before it stops and asks to continue (an integer ≥ 1). `--no-interrupt` turns get the larger of 1000 and this. See [CLI: Iteration Limit Behavior](cli.md#iteration-limit-behavior). |
|
|
891
900
|
| `turn.parent_continue` | `true` | | Whether a parent agent (`chi answer`) may answer Continue to a session's step-limit question; `false`: Stop only. config.yml only: no environment variable. See [Sub-agent](sub-agent.md) and [Guardrails](guardrails.md#approvals-from-a-parent-agent). |
|
|
892
901
|
| `update.gem` | `true` | | `false`: `chi update` never installs a newer gem (`--no-gem` for one run). See [CLI: Updating](cli.md#updating). |
|
data/docs/hooks.md
CHANGED
|
@@ -115,10 +115,16 @@ known-names)` for a bundle hook, `audit.rb (config)` for a config hook,
|
|
|
115
115
|
`turn hook` for one registered at runtime.
|
|
116
116
|
|
|
117
117
|
A steer is saved in the session as `{role: "user", kind: "steer", source:
|
|
118
|
-
"<bundle>", content: "…"}
|
|
118
|
+
"<bundle>", content: "…"}`, its text raw. The model reads it as a user turn
|
|
119
|
+
led by one line naming the sender: `[Steer from the <bundle> plugin,
|
|
120
|
+
mid-task. Follow it; if it asks for nothing, carry on with the task.]`.
|
|
119
121
|
Its `source` is the hook's bundle (a config or turn hook's label otherwise).
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
Lines typed into a running turn are saved as `{role: "user", kind: "input",
|
|
123
|
+
content: "…"}`: part of that turn, not a turn of their own. They carry a
|
|
124
|
+
`source` when they are not the user's own: `chi_send` (`chi send -m`),
|
|
125
|
+
`parent_agent` (a delegate's follow-up) or `plugin_send` (`ctx.sessions.send`).
|
|
126
|
+
The model reads them with the same kind of header (`[Steer from the user, …]`,
|
|
127
|
+
`[Steer sent with chi send, …]`, and so on).
|
|
122
128
|
|
|
123
129
|
Timing: a notice from `:after_turn` or `:session_end` shows after the turn's
|
|
124
130
|
end line. A question from `:before_tool_call` shows **before** the tool
|
|
@@ -172,19 +178,27 @@ What a cut is, in detail:
|
|
|
172
178
|
|
|
173
179
|
- It is an empty answer made early: it uses the `retry.empty_answer` budget
|
|
174
180
|
([configuration.md](configuration.md#llama-network-retry-behavior)). With
|
|
175
|
-
no retry left (`retry.empty_answer: 0`, or already used)
|
|
176
|
-
cancelled (hook): "✕ turn canceled (hook)".
|
|
181
|
+
no retry left (`retry.empty_answer: 0`, or already used) and nothing
|
|
182
|
+
queued, the turn ends cancelled (hook): "✕ turn canceled (hook)".
|
|
177
183
|
- It shows nothing by itself: post your own notice (`event[:notify]`) to say
|
|
178
184
|
why. The bundle and the reason go into the note the model reads and the
|
|
179
185
|
log (`generation_stopped`).
|
|
180
186
|
- Queued input (the user's line, or a hook's `steer`) goes in place of the
|
|
181
|
-
hidden note,
|
|
187
|
+
hidden note, also once the retry budget is spent, and spends no retry;
|
|
188
|
+
then there is no `↻` line.
|
|
182
189
|
- The cut generation is not kept: its thinking and any visible text it had
|
|
183
190
|
streamed go with it (the retry answers anew). `:after_generation` doesn't
|
|
184
191
|
fire for it, and its token usage is lost (the stream never sent its last
|
|
185
192
|
chunk). A client that joins the running turn still sees its thinking in
|
|
186
193
|
the turn so far until the turn ends.
|
|
187
194
|
- A Stop from the user right after a cut is a plain cancel.
|
|
195
|
+
- A cut for a message (a user's, `chi send`'s or a parent agent's line into
|
|
196
|
+
a generation that has streamed only thinking for `steer.cut_after` seconds)
|
|
197
|
+
is not a plugin's cut: it sends no hidden note and spends no retry; the
|
|
198
|
+
message goes in and the UIs print `↪ cut in for your message`. The
|
|
199
|
+
stream's `generation_completed` says `stopped_by: "steer"`, and
|
|
200
|
+
`stop_generation` returns false for a generation a message already cut.
|
|
201
|
+
A plugin's `steer` never cuts.
|
|
188
202
|
|
|
189
203
|
## Presenting the answer (display only)
|
|
190
204
|
|
data/docs/plugins.md
CHANGED
|
@@ -490,7 +490,7 @@ full label, `plugin.rb (bundle my-bundle)`.
|
|
|
490
490
|
| `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
|
|
491
491
|
| `ctx.ask_user(question:, options:, header: nil, allow_freeform: false)` | a single-select question through the question flow: `{selected:, freeform:, selected_indices:}`, or nil (no one to ask, cancelled, bad options) |
|
|
492
492
|
| `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
|
|
493
|
-
| `ctx.steer(text)` | put text into the running turn: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook (false from `:after_turn`), your own |
|
|
493
|
+
| `ctx.steer(text)` | put text into the running turn: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. The model reads it with a header naming your bundle (`[Steer from the my-bundle plugin, mid-task. …]`); the session keeps the raw text. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook (false from `:after_turn`), your own |
|
|
494
494
|
| `ctx.stop_turn(reason)` | stop the running turn after a warn notice with the reason; true when it stopped one now. In a `:before_tool_call` block it also denies that call |
|
|
495
495
|
| `ctx.stop_generation(reason)` | cut the generation that is streaming: the turn goes on and the model is asked again ([hooks.md](hooks.md#watching-the-stream)). Shows nothing: post your own notice. True when it cut one now |
|
|
496
496
|
| `ctx.ask_model(messages:, prompt:, …)` | a side answer from the session's model: see [Side answers](#side-answers-ctxask_model) |
|
data/docs/releasing.md
CHANGED
|
@@ -52,7 +52,7 @@ as the GitHub release body.
|
|
|
52
52
|
| `rake bundles:check` | Sha lines match; a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). Warns (doesn't fail) when such a bundle's `requires_chi` still admits the tagged chi: raise it if the change uses anything new. |
|
|
53
53
|
| `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
|
|
54
54
|
| `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG. No commit. |
|
|
55
|
-
| `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, `bundles:check`, rspec + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
|
|
55
|
+
| `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, `bundles:check`, `rake lint` (RuboCop), rspec + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
|
|
56
56
|
| `rake release:notes[X.Y.Z]` | Prints the CHANGELOG section (default: the current VERSION). |
|
|
57
57
|
|
|
58
58
|
## Runbook
|
data/docs/sub-agent.md
CHANGED
|
@@ -60,9 +60,10 @@ answers waits as a question of kind `continue` (`"limit": 100`, options
|
|
|
60
60
|
`Continue` and `Stop`). `answer_with` continues it:
|
|
61
61
|
`chi answer ID --question QID --option Continue`, and the continued turn's
|
|
62
62
|
reply (or its next question) comes back on that same wait. With
|
|
63
|
-
`--text "…"` the text joins the continued turn as a steer
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
`--text "…"` the text joins the continued turn as a steer, labelled for the
|
|
64
|
+
model as coming from the parent agent that started the session, so one answer
|
|
65
|
+
continues and redirects it. (A `chi send -m` into a running turn joins it too,
|
|
66
|
+
labelled as sent with chi send.) To stop it:
|
|
66
67
|
`--option Stop`, with `--text "why"` for the model;
|
|
67
68
|
that wait ends with `status: not_continued`, exit 0, and the turn's work so far
|
|
68
69
|
stays in the session. A message instead (`chi send ID -m "…"`) drops the
|
data/docs/testing.md
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
bundle exec parallel_rspec -n 8 # Ruby specs, in parallel (or plain: bundle exec rspec)
|
|
5
5
|
npm test # web frontend specs
|
|
6
6
|
npm run e2e # web UI happy paths in Chromium, fake model (once: npx playwright install chromium)
|
|
7
|
+
bundle exec rake lint # RuboCop: no offenses (CI runs it)
|
|
7
8
|
```
|
|
8
9
|
|
|
10
|
+
Run `bundle exec rubocop -a` on the files you changed before committing: every enabled style cop follows the
|
|
11
|
+
code's own style and has a safe autocorrect, so `-a` fixes them. What it leaves is a Lint finding (a possible
|
|
12
|
+
bug) or a broken spec: fix it, or disable that line with the reason (`# rubocop:disable Cop -- why`).
|
|
13
|
+
`.rubocop.yml` says why each cop that is off is off.
|
|
14
|
+
|
|
9
15
|
No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
|
|
10
16
|
clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
|
|
11
17
|
|
|
@@ -84,12 +84,14 @@ module Samagotchi
|
|
|
84
84
|
options = parsed.options
|
|
85
85
|
return usage_error("give one session id") unless parsed.args.size == 1
|
|
86
86
|
return usage_error("--question QID is required (chi send --wait prints it)") if options[:question].to_s.strip.empty?
|
|
87
|
+
|
|
87
88
|
if options[:dismiss]
|
|
88
89
|
return usage_error("--dismiss takes no --option or --text") if !options[:options].empty? || options[:text]
|
|
89
90
|
elsif options[:options].empty? && options[:text].nil?
|
|
90
91
|
return usage_error("nothing to answer with: --option, --text or --dismiss")
|
|
91
92
|
end
|
|
92
93
|
return usage_error("--format takes text or json") if options[:format] && !FORMATS.include?(options[:format])
|
|
94
|
+
|
|
93
95
|
if options[:timeout]
|
|
94
96
|
options[:timeout] = Float(options[:timeout], exception: false)
|
|
95
97
|
return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
|
|
@@ -155,7 +157,7 @@ module Samagotchi
|
|
|
155
157
|
# (Guardrails::ParentApprovals, with this process's config.yml). A
|
|
156
158
|
# Deny, text alone or a dismissal always passes: each one denies. The
|
|
157
159
|
# worker checks it again with its own config.
|
|
158
|
-
def approval_refusal(pending, selected,
|
|
160
|
+
def approval_refusal(pending, selected, _id)
|
|
159
161
|
offered = Array(pending[:options]).map(&:to_s)
|
|
160
162
|
indices = selected.map { |label| offered.index(label) }
|
|
161
163
|
reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: Guardrails::ParentApprovals.setting)
|
|
@@ -170,7 +172,7 @@ module Samagotchi
|
|
|
170
172
|
|
|
171
173
|
# A step-limit question can't be left unanswered: the turn waits on it.
|
|
172
174
|
def not_dismissable(pending)
|
|
173
|
-
"the step-limit question can't be dismissed: answer #{Array(pending[:options]).join(
|
|
175
|
+
"the step-limit question can't be dismissed: answer #{Array(pending[:options]).join(" or ")} " \
|
|
174
176
|
"(--option Stop --text WHY stops it)"
|
|
175
177
|
end
|
|
176
178
|
|
|
@@ -15,7 +15,8 @@ module Samagotchi
|
|
|
15
15
|
# comments), after a backup, and is checked by parsing it again: every
|
|
16
16
|
# other key must be unchanged, or the backup goes back.
|
|
17
17
|
class ConfigWriter
|
|
18
|
-
Error
|
|
18
|
+
class Error < StandardError
|
|
19
|
+
end
|
|
19
20
|
|
|
20
21
|
# kind: :new (a fresh file), :appended, :exists (the host is already
|
|
21
22
|
# there as +existing+; nothing written), :snippet (a file chi can't
|
|
@@ -18,7 +18,7 @@ module Samagotchi
|
|
|
18
18
|
# port answers at once instead of after chi's usual backoff.
|
|
19
19
|
class Probe
|
|
20
20
|
# The ports `chi bootstrap` with no target tries on localhost.
|
|
21
|
-
LOCAL_PORTS = { 8080 => "llama.cpp",
|
|
21
|
+
LOCAL_PORTS = { 8080 => "llama.cpp", 11_434 => "Ollama", 1234 => "LM Studio", 8000 => "vLLM" }.freeze
|
|
22
22
|
DEFAULT_PORT = 8080
|
|
23
23
|
# Connect and read seconds for /props and /v1/models: a remote https
|
|
24
24
|
# handshake needs more than Client#server_props' 1 s / 2 s.
|
|
@@ -193,7 +193,7 @@ module Samagotchi
|
|
|
193
193
|
def describe(result)
|
|
194
194
|
if result.native?
|
|
195
195
|
build = result.props["build_info"].to_s
|
|
196
|
-
@stdout.puts("found: llama.cpp at #{result.candidate.root}#{
|
|
196
|
+
@stdout.puts("found: llama.cpp at #{result.candidate.root}#{" (build #{build})" unless build.empty?}")
|
|
197
197
|
else
|
|
198
198
|
@stdout.puts("found: an OpenAI-compatible API at #{result.candidate.base}")
|
|
199
199
|
end
|
|
@@ -406,7 +406,7 @@ module Samagotchi
|
|
|
406
406
|
shown.first(PICK_SHOWN).each_with_index { |item, i| @stdout.puts(format(" %2d) %s", i + 1, item)) }
|
|
407
407
|
more = shown.size - PICK_SHOWN
|
|
408
408
|
@stdout.puts(" … #{more} more: type part of a name to narrow the list") if more.positive?
|
|
409
|
-
@stdout.print("#{noun} (number#{
|
|
409
|
+
@stdout.print("#{noun} (number#{" or text" if items.size > PICK_SHOWN}, empty to stop): ")
|
|
410
410
|
answer = @stdin.gets.to_s.strip
|
|
411
411
|
return nil if answer.empty?
|
|
412
412
|
|
|
@@ -16,7 +16,7 @@ module Samagotchi
|
|
|
16
16
|
# iteration started before it (a before_tool_call hook's notice comes
|
|
17
17
|
# before its call's row, so the web puts it above row calls + 1). The
|
|
18
18
|
# loop's "asking again" row (:empty_answer_retry, after an empty or cut
|
|
19
|
-
# answer)
|
|
19
|
+
# answer) and a steer's cut row (:steer_cut) are kept the same way, and so is a question the turn asked
|
|
20
20
|
# (:question_requested), with how it was answered or cancelled, so a
|
|
21
21
|
# reload draws the resolved card where it was.
|
|
22
22
|
#
|
|
@@ -46,11 +46,11 @@ module Samagotchi
|
|
|
46
46
|
# onto a new worker doesn't print those again).
|
|
47
47
|
class CardStore
|
|
48
48
|
CAPACITY = 20
|
|
49
|
-
NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry].freeze
|
|
49
|
+
NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
|
|
50
50
|
FILE = "cards.json"
|
|
51
51
|
# The events after which the file is written again: the ones that
|
|
52
52
|
# change an entry or the turn count.
|
|
53
|
-
SAVED_ON = (%i[card hook_notice empty_answer_retry question_requested question_answered question_cancelled
|
|
53
|
+
SAVED_ON = (%i[card hook_notice empty_answer_retry steer_cut question_requested question_answered question_cancelled
|
|
54
54
|
question_relay turn_failed] + Events::TURN_KEPT).freeze
|
|
55
55
|
|
|
56
56
|
# What a session's saved file lists, as a store started from it lists
|
|
@@ -167,6 +167,7 @@ module Samagotchi
|
|
|
167
167
|
when :guardrail_warning then push({ type: :guardrail_warning, message: event[:message], label: event[:label] }
|
|
168
168
|
.compact.merge(in_turn: false, turns: @turns_done))
|
|
169
169
|
when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by)) if @running
|
|
170
|
+
when :steer_cut then add_turn_row(event.slice(:type, :source)) if @running
|
|
170
171
|
when :question_requested
|
|
171
172
|
add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running
|
|
172
173
|
when :question_answered then resolve_question(event[:id], answer: event[:answer])
|
|
@@ -217,8 +217,6 @@ module Samagotchi
|
|
|
217
217
|
"\r\n"
|
|
218
218
|
)
|
|
219
219
|
io.flush
|
|
220
|
-
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
221
|
-
raise
|
|
222
220
|
end
|
|
223
221
|
|
|
224
222
|
# Serialise one SSE event: an `id:` line (the replay cursor), an optional
|
|
@@ -232,15 +230,11 @@ module Samagotchi
|
|
|
232
230
|
JSON.generate(data).each_line { |line| lines << "data: #{line.chomp}" }
|
|
233
231
|
io.write(lines.join("\r\n") + "\r\n\r\n")
|
|
234
232
|
io.flush
|
|
235
|
-
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
236
|
-
raise
|
|
237
233
|
end
|
|
238
234
|
|
|
239
235
|
def write_heartbeat(io)
|
|
240
236
|
io.write(": ping\r\n\r\n")
|
|
241
237
|
io.flush
|
|
242
|
-
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
243
|
-
raise
|
|
244
238
|
end
|
|
245
239
|
|
|
246
240
|
# Emit a control frame carrying the current snapshot so a client that
|
|
@@ -158,7 +158,8 @@ module Samagotchi
|
|
|
158
158
|
call = { iteration: part[:iteration], call_index: part[:call_index], tool: part[:tool] }
|
|
159
159
|
call[:label] = part[:label] if part[:label]
|
|
160
160
|
title = part[:title] ? { title: part[:title] } : {}
|
|
161
|
-
|
|
161
|
+
view = part[:view] ? { view: part[:view] } : {}
|
|
162
|
+
events = [{ type: :tool_call_started, **call, params: part[:params], **title, **view }]
|
|
162
163
|
if part[:status] == "running"
|
|
163
164
|
events.first[:elapsed_ms] = part[:elapsed_ms] unless part[:elapsed_ms].nil?
|
|
164
165
|
return events
|
|
@@ -170,6 +171,7 @@ module Samagotchi
|
|
|
170
171
|
activity: activity }
|
|
171
172
|
completed[:duration_ms] = part[:duration_ms] unless part[:duration_ms].nil?
|
|
172
173
|
completed[:diff] = part[:diff] if part[:diff]
|
|
174
|
+
completed.merge!(view)
|
|
173
175
|
events << with_images(completed, part[:images])
|
|
174
176
|
end
|
|
175
177
|
private_class_method :tool_replay_events
|
|
@@ -230,8 +232,8 @@ module Samagotchi
|
|
|
230
232
|
if Events::TURN_END.include?(type)
|
|
231
233
|
@turn = nil
|
|
232
234
|
@merged_origins = []
|
|
233
|
-
|
|
234
|
-
@turn[:event_seq] = event[:event_seq]
|
|
235
|
+
elsif event[:event_seq]
|
|
236
|
+
@turn[:event_seq] = event[:event_seq]
|
|
235
237
|
end
|
|
236
238
|
end
|
|
237
239
|
|
|
@@ -253,6 +255,7 @@ module Samagotchi
|
|
|
253
255
|
tool: event[:tool], params: event[:params], status: "running" }
|
|
254
256
|
part[:label] = event[:label] if event[:label]
|
|
255
257
|
part[:title] = event[:title] if event[:title]
|
|
258
|
+
part[:view] = event[:view] if event[:view]
|
|
256
259
|
parts << part
|
|
257
260
|
@tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
|
|
258
261
|
when :tool_call_completed
|
|
@@ -12,6 +12,7 @@ module Samagotchi
|
|
|
12
12
|
FIELDS = {
|
|
13
13
|
hook_notice: %i[hook text level],
|
|
14
14
|
empty_answer_retry: %i[iteration attempt of stopped_by],
|
|
15
|
+
steer_cut: %i[iteration source],
|
|
15
16
|
question_requested: %i[pending_question],
|
|
16
17
|
question_answered: %i[id answer],
|
|
17
18
|
question_cancelled: %i[id reason]
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -20,6 +20,7 @@ require_relative "session"
|
|
|
20
20
|
require_relative "engine"
|
|
21
21
|
require_relative "guardrails/parent_approvals"
|
|
22
22
|
require_relative "session_commands"
|
|
23
|
+
require_relative "steer"
|
|
23
24
|
require_relative "image_store"
|
|
24
25
|
require_relative "log"
|
|
25
26
|
require_relative "version"
|
|
@@ -481,7 +482,7 @@ module Samagotchi
|
|
|
481
482
|
%w[GET snapshot] => :handle_snapshot,
|
|
482
483
|
%w[GET tail] => :handle_tail
|
|
483
484
|
}.freeze
|
|
484
|
-
ROUTE_PATH = %r
|
|
485
|
+
ROUTE_PATH = %r{\A/session/([^/]+)/(.+)\z}u
|
|
485
486
|
|
|
486
487
|
# @return [Array(Symbol, String), nil] the handler and the session id, or
|
|
487
488
|
# nil when no route takes this method and path
|
|
@@ -509,7 +510,7 @@ module Samagotchi
|
|
|
509
510
|
# /recap in an attached TUI: the saved recap, and a new one asked for at
|
|
510
511
|
# once (it arrives as :recap_ready). Answers mid-turn too.
|
|
511
512
|
# 200 {enabled:, saved:, request:, min_user_turns:}. Returns [headers, status, body].
|
|
512
|
-
def handle_recap(
|
|
513
|
+
def handle_recap(_session_id, _body = nil)
|
|
513
514
|
recap = @engine.recap
|
|
514
515
|
return [{}, 200, { enabled: false }] unless recap
|
|
515
516
|
|
|
@@ -519,7 +520,7 @@ module Samagotchi
|
|
|
519
520
|
|
|
520
521
|
# Cancel the active turn on this session's engine, if any.
|
|
521
522
|
# Returns [headers, status, body].
|
|
522
|
-
def handle_cancel(
|
|
523
|
+
def handle_cancel(_session_id, body)
|
|
523
524
|
# Optional reason from JSON body
|
|
524
525
|
reason = :manual
|
|
525
526
|
if body && !body.strip.empty?
|
|
@@ -546,6 +547,7 @@ module Samagotchi
|
|
|
546
547
|
unless parsed.is_a?(Hash)
|
|
547
548
|
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }]
|
|
548
549
|
end
|
|
550
|
+
|
|
549
551
|
qid = fetched(parsed, "id")
|
|
550
552
|
selected = fetched(parsed, "selected")
|
|
551
553
|
freeform = fetched(parsed, "freeform")
|
|
@@ -556,6 +558,7 @@ module Samagotchi
|
|
|
556
558
|
if qid.to_s.strip.empty?
|
|
557
559
|
return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "id required" }]
|
|
558
560
|
end
|
|
561
|
+
|
|
559
562
|
deadline = fetched(parsed, "deadline")
|
|
560
563
|
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
561
564
|
return deadline_passed("answer") if expired?("answer_expired", deadline, sid: session_id, id: qid)
|
|
@@ -722,6 +725,7 @@ module Samagotchi
|
|
|
722
725
|
@on_exit_request.call(client_id, **options)
|
|
723
726
|
end
|
|
724
727
|
return deadline_passed("exit request") if reason == :expired
|
|
728
|
+
|
|
725
729
|
if reason.nil?
|
|
726
730
|
return [{}, 200, { status: "restarting", session_id: @session_id }] if restart
|
|
727
731
|
|
|
@@ -745,7 +749,7 @@ module Samagotchi
|
|
|
745
749
|
# dropped with 408 deadline_passed. It is checked with the event log held,
|
|
746
750
|
# right before the write, so nothing that holds the log (an exit check)
|
|
747
751
|
# can delay an accepted turn past it. No deadline (an older client): taken.
|
|
748
|
-
def handle_post_turn(
|
|
752
|
+
def handle_post_turn(_session_id, body)
|
|
749
753
|
parsed = parse_json(body)
|
|
750
754
|
unless parsed.is_a?(Hash)
|
|
751
755
|
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }]
|
|
@@ -777,6 +781,8 @@ module Samagotchi
|
|
|
777
781
|
end
|
|
778
782
|
|
|
779
783
|
enqueued_id = SecureRandom.uuid
|
|
784
|
+
# Before it is queued: a drain after this may have taken it (#cut_for_steer).
|
|
785
|
+
epoch = @engine.input_epoch
|
|
780
786
|
# Write and announce with the event log held: the worker can't emit
|
|
781
787
|
# this turn's :turn_started (or merge it mid-turn) before
|
|
782
788
|
# :turn_enqueued, and a failed write announces nothing.
|
|
@@ -796,12 +802,16 @@ module Samagotchi
|
|
|
796
802
|
return deadline_passed("turn") if enqueued == :expired
|
|
797
803
|
return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
|
|
798
804
|
|
|
805
|
+
# Queued (outside the event lock): a running generation that has only
|
|
806
|
+
# been thinking for long is cut for it, unless a plugin sent it. An
|
|
807
|
+
# image input never merges mid-turn, so it never cuts.
|
|
808
|
+
@engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty?
|
|
799
809
|
[{}, 202, { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id }]
|
|
800
810
|
end
|
|
801
811
|
|
|
802
812
|
# Read-only snapshot surface. AC #4: too-old reconnects re-derive state
|
|
803
813
|
# from here.
|
|
804
|
-
def handle_state(
|
|
814
|
+
def handle_state(_session_id, _body = nil)
|
|
805
815
|
state = @engine.session_state_snapshot
|
|
806
816
|
[{}, 200, { session_id: @session_id, session_state_snapshot: state.merge(event_id: event_id(state[:event_seq])) }]
|
|
807
817
|
end
|
|
@@ -809,7 +819,7 @@ module Samagotchi
|
|
|
809
819
|
# /stats for an attached client: the metrics, with the window and prompt
|
|
810
820
|
# profile asked from the server when no turn has reported them yet (so,
|
|
811
821
|
# unlike /state, it may wait on one short /props GET).
|
|
812
|
-
def handle_stats(
|
|
822
|
+
def handle_stats(_session_id, _body = nil)
|
|
813
823
|
[{}, 200, { session_id: @session_id, metrics: @engine.stats_snapshot }]
|
|
814
824
|
end
|
|
815
825
|
|
|
@@ -817,7 +827,7 @@ module Samagotchi
|
|
|
817
827
|
# the messages elsewhere (the web server strips and formats them): it then
|
|
818
828
|
# streams from the snapshot's event_seq, and the ring replays what came
|
|
819
829
|
# after (or the stream resets). Returns [headers, status, body].
|
|
820
|
-
def handle_snapshot(
|
|
830
|
+
def handle_snapshot(_session_id, _body = nil)
|
|
821
831
|
[{}, 200, snapshot_frame]
|
|
822
832
|
end
|
|
823
833
|
|
|
@@ -825,7 +835,7 @@ module Samagotchi
|
|
|
825
835
|
# that turn's answer (a queued turn's page re-reads late). The one
|
|
826
836
|
# handler that gets the query (#dispatch); an older worker's route
|
|
827
837
|
# ignores it and sends the newest answer.
|
|
828
|
-
def handle_tail(
|
|
838
|
+
def handle_tail(_session_id, _body = nil, query = nil)
|
|
829
839
|
turn_id = query && URI.decode_www_form(query).to_h["turn_id"].to_s
|
|
830
840
|
[{}, 200, tail_frame(turn_id: turn_id.to_s.empty? ? nil : turn_id)]
|
|
831
841
|
rescue ArgumentError
|
|
@@ -885,7 +895,7 @@ module Samagotchi
|
|
|
885
895
|
headers = {}
|
|
886
896
|
content_length = 0
|
|
887
897
|
while (line = io.gets)
|
|
888
|
-
break if
|
|
898
|
+
break if ["\r\n", "\n"].include?(line)
|
|
889
899
|
|
|
890
900
|
key, value = line.split(":", 2)
|
|
891
901
|
next unless key
|