samagotchi 0.51.0 → 0.52.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/README.md +1 -1
  4. data/bin/chi +11 -3
  5. data/docs/architecture.md +1 -1
  6. data/docs/cli.md +23 -8
  7. data/docs/configuration.md +59 -19
  8. data/docs/memory.md +11 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +5 -1
  11. data/docs/testing.md +6 -1
  12. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  13. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  14. data/lib/samagotchi/bridge.rb +10 -13
  15. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/children_status.rb +1 -1
  19. data/lib/samagotchi/commands/registry.rb +2 -1
  20. data/lib/samagotchi/config.rb +50 -182
  21. data/lib/samagotchi/config_text_edit.rb +8 -32
  22. data/lib/samagotchi/context_sources.rb +0 -7
  23. data/lib/samagotchi/context_window.rb +2 -2
  24. data/lib/samagotchi/continue_offer.rb +5 -4
  25. data/lib/samagotchi/cut_policy.rb +0 -1
  26. data/lib/samagotchi/delivery.rb +23 -0
  27. data/lib/samagotchi/engine.rb +48 -17
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/host_config.rb +183 -0
  30. data/lib/samagotchi/host_registry.rb +5 -10
  31. data/lib/samagotchi/last_turn.rb +48 -0
  32. data/lib/samagotchi/live_versions.rb +0 -6
  33. data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
  34. data/lib/samagotchi/llm_context_override.rb +3 -22
  35. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  36. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  37. data/lib/samagotchi/model_profile.rb +4 -4
  38. data/lib/samagotchi/model_settings.rb +46 -0
  39. data/lib/samagotchi/plugin/context.rb +3 -3
  40. data/lib/samagotchi/plugin/sessions.rb +2 -2
  41. data/lib/samagotchi/queued_command.rb +35 -0
  42. data/lib/samagotchi/reminder_store.rb +0 -27
  43. data/lib/samagotchi/reply_wait.rb +11 -13
  44. data/lib/samagotchi/sampling_settings.rb +4 -4
  45. data/lib/samagotchi/self_report.rb +8 -5
  46. data/lib/samagotchi/send_command.rb +37 -19
  47. data/lib/samagotchi/session.rb +45 -24
  48. data/lib/samagotchi/session_commands.rb +36 -7
  49. data/lib/samagotchi/session_inbox.rb +18 -9
  50. data/lib/samagotchi/session_manager.rb +8 -7
  51. data/lib/samagotchi/session_setup.rb +30 -0
  52. data/lib/samagotchi/steer_cut.rb +9 -2
  53. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  55. data/lib/samagotchi/terminal_ui/formatting.rb +21 -3
  56. data/lib/samagotchi/terminal_ui.rb +8 -3
  57. data/lib/samagotchi/thinking.rb +67 -7
  58. data/lib/samagotchi/thinking_command.rb +72 -0
  59. data/lib/samagotchi/tools/builtins.rb +7 -2
  60. data/lib/samagotchi/tools/peers.rb +3 -2
  61. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/vision_support.rb +4 -3
  64. data/lib/samagotchi/web/app.rb +54 -6
  65. data/lib/samagotchi/web/public/app.js +133 -20
  66. data/lib/samagotchi/web/public/data.js +8 -4
  67. data/lib/samagotchi/web/public/format.js +8 -4
  68. data/lib/samagotchi/web/public/index.html +8 -5
  69. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  70. data/lib/samagotchi/web/public/model_pick.js +3 -1
  71. data/lib/samagotchi/web/public/thinking_chip.js +169 -0
  72. data/lib/samagotchi/web/public/timing.js +15 -0
  73. data/lib/samagotchi/web/public/turn_events.js +23 -0
  74. data/lib/samagotchi/web/public/turn_view.js +328 -310
  75. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  76. data/lib/samagotchi/web/session_summary.rb +1 -1
  77. data/lib/samagotchi/worker.rb +43 -42
  78. data/lib/samagotchi/worker_inbound.rb +5 -5
  79. data/lib/samagotchi/worker_wakes.rb +0 -2
  80. data/lib/samagotchi/yaml_lines.rb +72 -0
  81. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f93eddaef9e07210789382afa552868d94a39b0bca798bb7c0c5a8d4e23cc693
4
- data.tar.gz: 61f1005b2dd4efcdfba5379622193e79c7bfd305a4eac651827830a54399d1c9
3
+ metadata.gz: 98660ef911df1f010a45ded3190d0017793fc4e01c1229860ab523a7d2825ffd
4
+ data.tar.gz: 2c1eab9152f8fba6efa7507d17dde3deb54a0e5bbd26a56a61f23f54d3d38a15
5
5
  SHA512:
6
- metadata.gz: d6ea69e71620afeca9b1813fef569c9f39742d1dbf0d28ca9aa2675c0445ef5b397bdd44fd3b6d544dc3ba07a131771136e7d2163669d13925a0d952b69df175
7
- data.tar.gz: 9abd2290def4fd2bc5b12e9ea0c5fe722c2b4a8ead03044a8944108a43e3105325fc7bf854e5dd5bba3641fb1e0c0c2047835aa4e5ddeef91013ccf115244a0c
6
+ metadata.gz: d85715550f95d875ced247c2642c04183507fbb2d85bf6f1201e62a3fb2471ba27c27a110dee57046a716a38e139acd8aaf5dbc7ea0ded2051c486056bb115fb
7
+ data.tar.gz: 25591f45d69afe94d02ebabfce8887cfb126bf0c1b21ed74cc172ecbcfe881990f3431f43cde1b5b2c8801684102f628afbf4446bc2acab77fc50ef2ca6ff7f7
data/CHANGELOG.md CHANGED
@@ -8,6 +8,50 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.52.0] - 2026-10-10
12
+
13
+ ### Added
14
+
15
+ - `/thinking` shows the thinking level the next turn runs at and where it came from; `/thinking off|low|medium|high`
16
+ sets the session's own level (saved with the session, first in the order), `/thinking default` unsets it. A change
17
+ waits for the turn's end, and the reply says what it costs the prompt cache. `/model` and `/stats` show
18
+ `low (session)`; a continue and a plugin's fork keep the level, a delegate starts without it.
19
+ - Web: a `think` chip in the session bar and on the start page shows the thinking level (`think low · session`); a
20
+ click opens a level select that runs `/thinking` (the start page's choice is for the next new chat only).
21
+ - `chi send --new --thinking LEVEL` starts the session with its own thinking level (refused beside `--continues`,
22
+ which takes the previous link's).
23
+
24
+ ### Changed
25
+
26
+ - `chi --thinking LEVEL` on a run that starts or resumes a session sets that session's own level (saved, above
27
+ `SAMAGOTCHI_THINKING_LEVEL`) instead of the process's; `default` unsets it. `chi web --thinking` stays a default for
28
+ the workers it spawns, below a session's own.
29
+ - `llm_context.budget_tokens` (and a model's or host's `llm_context_budget_tokens`) takes `64k` and `off` like
30
+ `/llm-context budget`, with the same range (4k to 10M); a budget out of range warns and is ignored.
31
+ - Web: a turn's step is one lit-html template for the live page and a reload (lit-html 3.3.3 vendored in
32
+ `web/public/vendor/`, no build step). A live step no longer keeps an empty narration element, and a plain answer's
33
+ thinking block is drawn fresh from the same template.
34
+
35
+ ### Fixed
36
+
37
+ - Web: the info bar's session clock ticks during a session's first turn (a session started from the start page
38
+ showed a frozen `session 0ms`).
39
+ - Web: `/cut TEXT` in the composer sends TEXT as a cut, as in a terminal and `chi send --cut` (it was refused as "not a
40
+ session command"); with no turn running it is TEXT as a normal turn. `/queue TEXT` during a turn says it isn't
41
+ available in the web yet, instead of that error.
42
+ - Web: command bubbles (`/help`, `/model`, `/llm-context` …) stay in place when a `!cmd` or `!rollback` redraws the
43
+ conversation; a reload still drops them (only a `!cmd`'s output is saved).
44
+ - Web: a reloaded turn whose messages carry no parts keeps "(called as bash)" on its rows (from the tool records).
45
+ - A turn posted with `delivery: "queue"` (the Bridge's and the web's `POST …/turn`) is refused with 400
46
+ `delivery_unavailable` until queueing is built, instead of being acked as queued and then merged at the running
47
+ turn's next step. No chi client sends it.
48
+ - An attached terminal runs `/queue TEXT` at the open prompt (no turn running) as a turn, as the plain REPL and the
49
+ docs say, instead of refusing it; it is refused only while a turn runs.
50
+ - A `hosts:` entry that fails while it is read is ignored on its own with a warning naming it, instead of every host
51
+ silently dropping (in the process and in its workers); a failure outside the entries is logged.
52
+ - A `/thinking`, `/model` or `/llm-context` queued during a turn no longer lets the turn-end warm-up prefill a prompt
53
+ the change then throws away.
54
+
11
55
  ## [0.51.0] - 2026-10-10
12
56
 
13
57
  ### Added
@@ -2343,7 +2387,8 @@ and long-lived sessions.
2343
2387
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2344
2388
  hotkey panel that send selected text or the clipboard to your sessions.
2345
2389
 
2346
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.51.0...HEAD
2390
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.52.0...HEAD
2391
+ [0.52.0]: https://github.com/dm1try/samagotchi/compare/v0.51.0...v0.52.0
2347
2392
  [0.51.0]: https://github.com/dm1try/samagotchi/compare/v0.50.0...v0.51.0
2348
2393
  [0.50.0]: https://github.com/dm1try/samagotchi/compare/v0.49.0...v0.50.0
2349
2394
  [0.49.0]: https://github.com/dm1try/samagotchi/compare/v0.48.0...v0.49.0
data/README.md CHANGED
@@ -210,7 +210,7 @@ once, for the session, for the repo, or for the whole rule in the repo.
210
210
  - [Hooks](docs/hooks.md): plugin hooks and bundle hooks
211
211
  - [Plugins](docs/plugins.md): bundle plugins (commands, tools, hooks)
212
212
  - [Architecture](docs/architecture.md): Engine, TerminalUI, bridge, web
213
- - Internals: [Gemma 4 contract](docs/internals/gemma4-contract.md), [context telemetry](docs/internals/context-telemetry.md), [tool output limits](docs/internals/tool-guardrails.md), [background tasks](docs/internals/background-tasks.md)
213
+ - Internals: [Engine](docs/internals/engine.md), [Gemma 4 contract](docs/internals/gemma4-contract.md), [prompt caching](docs/internals/prompt-caching.md), [context telemetry](docs/internals/context-telemetry.md), [LLM context: forget layer](docs/internals/llm-context-forget.md), [replay benchmark](docs/internals/llm-context-bench.md), [live runs](docs/internals/llm-context-live.md), [tool output limits](docs/internals/tool-guardrails.md), [background tasks](docs/internals/background-tasks.md)
214
214
 
215
215
  ## Development
216
216
 
data/bin/chi CHANGED
@@ -116,8 +116,9 @@ parser = OptionParser.new do |opts|
116
116
  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 }
117
117
  opts.on("--profile NAME", Samagotchi::ModelProfile::NAMES,
118
118
  "Prompt profile for every model in this run (#{Samagotchi::ModelProfile::NAMES.join("|")}) [alias for --model-profile]") { |p| cli_overrides["model.profile"] = p }
119
- 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; " \
120
- "sessions already running keep theirs) [env SAMAGOTCHI_THINKING_LEVEL]") { |l| cli_overrides["thinking.level"] = l }
119
+ opts.on("--thinking LEVEL", Samagotchi::Thinking::LEVELS.map(&:to_s), "This session's own thinking level: off|low|medium|high|default (saved in the session, " \
120
+ "before the model's; default unsets it; /thinking changes it). With chi web: the default for the sessions " \
121
+ "its workers run, below a session's own [env SAMAGOTCHI_THINKING_LEVEL]") { |l| options[:thinking] = l }
121
122
  opts.on("--memory NAME", "Preload a memory entry into the system prompt (repeatable; a comma list too)") { |m| (options[:memories] ||= []) << m }
122
123
  opts.on("--mute NAME", "Hide a memory from this session: not in the prompt, refused by memory_read (repeatable; a comma list too)") { |m| (options[:muted] ||= []) << m }
123
124
  opts.on("--llm-context LAYERS", "This session's own LLM context strategy: none, or layers such as stale,forget (saved in the session, " \
@@ -198,6 +199,11 @@ if scratch
198
199
  end
199
200
  end
200
201
 
202
+ # --thinking: chi web's is a default for the workers it spawns (below a
203
+ # session's own level); a session-starting run's is that session's own
204
+ # (TerminalUI, AttachLauncher), not a process setting.
205
+ cli_overrides["thinking.level"] = options[:thinking] if web && options[:thinking]
206
+
201
207
  # Apply CLI precedence: CLI > ENV > file. Workers this chi spawns get
202
208
  # these through their env (Config.cli_env).
203
209
  Samagotchi::Config.reload!(cli_overrides: cli_overrides) unless cli_overrides.empty?
@@ -333,7 +339,8 @@ if launch == :attached
333
339
  default_input: !options[:no_default_input],
334
340
  memories: options[:memories] || [],
335
341
  muted_memories: options[:muted] || [],
336
- llm_context: options[:llm_context] || {})
342
+ llm_context: options[:llm_context] || {},
343
+ thinking: options[:thinking])
337
344
  rescue Samagotchi::TerminalUI::AttachLauncher::Error => e
338
345
  warn "Error: #{e.message}"
339
346
  exit 1
@@ -351,6 +358,7 @@ begin
351
358
  memories: options[:memories] || [],
352
359
  muted_memories: options[:muted] || [],
353
360
  llm_context: options[:llm_context] || {},
361
+ thinking: options[:thinking],
354
362
  non_interactive: options[:non_interactive],
355
363
  model_name: options[:model],
356
364
  scratch: scratch
data/docs/architecture.md CHANGED
@@ -65,7 +65,7 @@ TerminalUI ── delegates ──▶ Engine
65
65
  ```
66
66
 
67
67
  - **`Engine`** (`lib/samagotchi/engine.rb`) owns *all* agent logic and knows nothing
68
- about the terminal.
68
+ about the terminal ([internals/engine.md](internals/engine.md)).
69
69
  - **`TerminalUI`** (`lib/samagotchi/terminal_ui.rb`) owns the REPL and rendering; it
70
70
  delegates all core work to an `Engine`.
71
71
  - The `on_event:` seam on `run_turn` exposes raw `KernelLoop` events plus the
data/docs/cli.md CHANGED
@@ -186,7 +186,7 @@ controls exit behavior (`--non-interactive`); `--resume` composes with both.
186
186
  | `--no-shared` | Run the plain in-process REPL for this run. |
187
187
  | `--attach SESSION_ID` | Attach to a session's worker, waking one if it has exited. |
188
188
  | `--model NAME` | Use this model for the run (overrides the configured default and a resumed session's model). |
189
- | `--thinking LEVEL` | How much the model thinks this run: `off`, `low`, `medium`, `high` or `default` (env `SAMAGOTCHI_THINKING_LEVEL`), over the config's levels. A session already running keeps its own. See "Thinking" in configuration.md. |
189
+ | `--thinking LEVEL` | This session's own thinking level: `off`, `low`, `medium` or `high`, saved in the session and first in the order (`default` unsets it). On `--resume` or `--attach` it is set at once, through a `/thinking` on an attached worker. With `chi web` it is instead the default for the sessions its workers run, below a session's own (as env `SAMAGOTCHI_THINKING_LEVEL` is). See "Thinking" in configuration.md. |
190
190
  | `--profile NAME` | Prompt profile (`qwen36` or `gemma4`) for every model in this run, over config and the server's template (same as `--model-profile`, env `SAMAGOTCHI_MODEL_PROFILE`). See "Prompt profile" in configuration.md. |
191
191
  | `--memory NAME` | Preload a memory entry into the system prompt (repeatable; a comma list too). Merged under the config.yml `memories:` baseline. Works attached: the list is stored on the session, so its worker builds the same prompt on every respawn. |
192
192
  | `--mute NAME` | Hide a memory from this session (repeatable; a comma list too): its index line is not in the prompt, `memory_read` refuses it, the identity auto-load skips it, and it is dropped from the preloads (config baseline or `--memory`). A name matches in both scopes (`gh-helper`, `project/gh-helper` and `gh-helper.md` all hide `gh-helper`). Nothing on disk changes. See [Muting a memory](#muting-a-memory). |
@@ -290,12 +290,12 @@ of UIs at once: the Web UI and attached terminals (`chi`, `--resume`,
290
290
  `--attach`). They all see the same turns as they happen, and any of them can send
291
291
  a prompt, also while a turn runs (it merges into that turn as steering: the model
292
292
  is told it is a steer and who sent it). Steering waits for the model's next step
293
- and cuts nothing. `/cut TEXT` in a terminal (or `chi send --cut`) asks for a cut:
293
+ and cuts nothing. `/cut TEXT` in a terminal or the web composer (or `chi send --cut`) asks for a cut:
294
294
  once the model has streamed only thinking for `steer.cut_after` seconds (default
295
295
  20), now or while your message waits, that generation is cut and the step starts again with your message, marked
296
296
  `↪ cut in for your message` (see
297
- [configuration.md](configuration.md#llama-network-retry-behavior)). The web
298
- composer doesn't cut yet. The
297
+ [configuration.md](configuration.md#messages-during-a-running-turn-steer-and-cut)). The web
298
+ composer has no `/queue` yet (it says so and sends nothing). The
299
299
  first answer to an `ask_user_question` wins; the other UIs close their widget.
300
300
  An empty answer dismisses the question in every UI. The model is then told
301
301
  not to go ahead with what it asked about, or change anything else, and to wait.
@@ -483,6 +483,20 @@ the same values on its `llm context:` line. The web's info bar shows them as the
483
483
  through `/llm-context`. See "LLM context: a session's own strategy" in
484
484
  [configuration.md](configuration.md).
485
485
 
486
+ `/thinking` shows the thinking level the next turn runs at and where it came
487
+ from (`session`, `--thinking` / `SAMAGOTCHI_THINKING_LEVEL`, `models: <key>`,
488
+ `hosts.<name>`, `thinking.level`). `/thinking off|low|medium|high` sets the
489
+ session's own level, which comes first; `/thinking default` unsets it. It is
490
+ saved with the session (a `--resume`, a continue and `/model` keep it) and
491
+ applies from the next turn's start; the reply says what the change costs the
492
+ prompt cache: the whole prompt on native Gemma 4 (its `<|think|>` starts the
493
+ prompt), only the tail on native Qwen, and other request fields on a chat host
494
+ (a local llama.cpp or Splash server keeps its cache; a hosted API such as
495
+ OpenRouter → Claude or OpenAI may restart its own). `/thinking` alone answers
496
+ mid-turn too; a change sent while a turn runs waits for the turn's end, and
497
+ skips that turn's warm-up. `/stats` shows the level on its `thinking:` line.
498
+ See "Thinking" in [configuration.md](configuration.md).
499
+
486
500
  ### Typing during a turn
487
501
 
488
502
  The prompt stays open while a turn runs, in an attached terminal and in the plain
@@ -503,16 +517,17 @@ REPL alike:
503
517
  cut).
504
518
  - `/queue TEXT` (plain REPL only) runs TEXT as a turn of its own after this
505
519
  one: `(queued: runs after this turn)`. An attached terminal says
506
- `(/queue isn't available in an attached session yet)` and sends nothing.
520
+ `(/queue isn't available in an attached session yet)` and sends nothing (the web
521
+ composer: `(/queue isn't available in the web yet; …)`).
507
522
  At the open prompt (no turn running) `/cut TEXT` and `/queue TEXT` run TEXT
508
523
  as a normal prompt.
509
524
  - Commands answer, wait or are refused, by what they do:
510
525
  - At once, beside the turn: `/stats`, `/recap`, `/help`, `/context`, a
511
526
  plugin's anytime command (`/btw`), and the commands that only show
512
- something: `/models` (with a filter too), and `/model`, `/llm-context` and
513
- `/guardrails` alone.
527
+ something: `/models` (with a filter too), and `/model`, `/llm-context`,
528
+ `/thinking` and `/guardrails` alone.
514
529
  - After the turn: the ones that change something (`/model X`, `/model clear`,
515
- `/llm-context strategy …`, `/guardrails revoke N`) and `!cmd`, whose output
530
+ `/llm-context strategy …`, `/thinking LEVEL`, `/guardrails revoke N`) and `!cmd`, whose output
516
531
  joins the conversation. `(queued: runs after this turn)` says so at once (the
517
532
  web draws the command's bubble dashed, with the same words, until it ran);
518
533
  when the turn ends they run in the order sent, a prompt sent before one
@@ -470,13 +470,38 @@ models:
470
470
  - `default` sends nothing: the provider's or the chat template's own default, which is chi's behaviour without the
471
471
  setting. For some hybrid models that default is *no* thinking (DeepSeek V3.1 on OpenRouter); `medium` turns it on.
472
472
  `on` isn't a level.
473
- - Order, first set wins: `--thinking LEVEL` or `SAMAGOTCHI_THINKING_LEVEL`, then the `models:` entry (found the way
474
- `profile:` is), then the `hosts:` entry, then `thinking.level` in the file, then `default`. Anything else than a
475
- level warns once and counts as unset.
476
- - The flag reaches the sessions that start with it; a session already running keeps its level. `models:` levels
477
- and `thinking.level` in the file are read every turn; a host's `thinking:` reaches a worker when it starts, as
478
- its `sampling:` does (`chi sessions stop` to change it).
479
- - `/model` shows the level and where it came from (`thinking: off (models: qwen3.6-35b-a3b)`), `chi self` too.
473
+ - Order, first set wins: the session's own level, then the process default (`chi web --thinking LEVEL`, or
474
+ `SAMAGOTCHI_THINKING_LEVEL`), then the `models:` entry (found the way `profile:` is), then the `hosts:` entry, then
475
+ `thinking.level` in the file, then `default`. Anything else than a level warns once and counts as unset.
476
+ - **A session's own level** (`off`, `low`, `medium`, `high`) is saved in the session (its file's `"thinking"`), so a
477
+ `--resume`, a respawned worker, a continue (the next link of a chain) and `/model` keep it; a plugin's fork copies
478
+ it, a delegate child starts without it (it runs at its model's level). Set it with:
479
+ - `/thinking LEVEL` in the session (`/thinking default` unsets it, `/thinking` alone shows the level and where it
480
+ came from);
481
+ - `chi --thinking LEVEL` on a run that starts or resumes a session (the REPL, `-p`, `--resume`, an attached one);
482
+ - `chi send --new --thinking LEVEL`;
483
+ - the web's `think` chip, in the session bar and on the start page (`POST /api/sessions` takes `"thinking": "low"`;
484
+ anything that isn't a level answers `400 invalid_thinking`).
485
+ - `chi web --thinking LEVEL` is the default for the sessions its workers run, below a session's own. A session started
486
+ with only the env (`SAMAGOTCHI_THINKING_LEVEL=low chi send --new …`) loses the level when a worker without that env
487
+ (`chi web`'s, say) wakes it later: use `chi send --new --thinking low`.
488
+ - A level is fixed for a turn: a change takes effect at the next turn's start. `/thinking LEVEL` sent while a turn
489
+ runs waits for its end (as `/model X` does), and holds off that turn's warm-up. What a change costs the prompt cache,
490
+ which `/thinking` says:
491
+
492
+ | Backend | A level change |
493
+ |---|---|
494
+ | native Qwen (`qwen36`) | only the prompt's tail (the empty thought after the cue) |
495
+ | native Gemma 4 (`gemma4`) | the whole prompt is read again: `<\|think\|>` starts the system prompt |
496
+ | chat host, local llama.cpp or Splash | other request fields; the server keeps its cache (a Gemma template re-reads it all) |
497
+ | chat host, OpenRouter → Claude | thinking params are part of the cached prefix: the cache restarts |
498
+ | chat host, OpenRouter → OpenAI reasoning | an effort change rewrites hidden instructions: the prefix is lost |
499
+
500
+ - `models:` levels and `thinking.level` in the file are read every turn; a host's `thinking:` reaches a worker when
501
+ it starts, as its `sampling:` does (`chi sessions stop` to change it).
502
+ - `/model` shows the level and where it came from (`thinking: off (models: qwen3.6-35b-a3b)`, `thinking: low
503
+ (session)`), `/stats` on its `thinking:` line, `chi self` too (a session's own level first when run from its
504
+ commands).
480
505
  - The idle recap and plugins' side questions always ask with thinking off, whatever the level.
481
506
 
482
507
  What each backend gets:
@@ -765,14 +790,6 @@ no made-up answer is saved.
765
790
  A generation a plugin cuts while it streams (loop-guard's thinking watch, or any `stop_generation`,
766
791
  [hooks.md](hooks.md#watching-the-stream)) uses the same budget: `↻ cut by loop-guard, asking again (1/1)`, and with
767
792
  none left the turn ends as cancelled (hook).
768
- A message for a running turn goes in at its next step and cuts nothing. A message sent as a cut (`/cut TEXT` in a
769
- terminal, `chi send --cut`) cuts the model's generation once it has streamed only thinking for `steer.cut_after`
770
- seconds (default `20`, env `SAMAGOTCHI_STEER_CUT_AFTER`, `0` = never; counted from its first thinking token, so the
771
- wait for a busy server doesn't count) and still does, and the model starts the step again with the message
772
- (`↪ cut in for your message`). One that comes earlier cuts once the thinking passes `steer.cut_after`, unless the
773
- step ends first and the message goes in there. The web composer and a parent agent never ask for a cut. Such a cut spends no `retry.empty_answer` attempt and sends no
774
- hidden note; only the cut thinking is lost. A generation that has streamed visible text or a tool call is never cut,
775
- and a plugin's steer (`ctx.steer`, `ctx.sessions.send`) never cuts.
776
793
 
777
794
  Assist-mode UX:
778
795
 
@@ -781,6 +798,29 @@ Assist-mode UX:
781
798
  `stream dropped`), the wait, and the retry of all there will be.
782
799
  - If retry attempts are exhausted, the submitted prompt is restored into the input editor so you can edit and resubmit.
783
800
 
801
+ ## Messages during a running turn: steer and cut
802
+
803
+ A message for a running turn goes in at its next step and cuts nothing. A message sent as a cut (`/cut TEXT` in a
804
+ terminal or the web composer, `chi send --cut`) cuts the model's generation once it has streamed only thinking for `steer.cut_after`
805
+ seconds (default `20`, env `SAMAGOTCHI_STEER_CUT_AFTER`, `0` = never; counted from its first thinking token, so the
806
+ wait for a busy server doesn't count) and still does, and the model starts the step again with the message
807
+ (`↪ cut in for your message`). One that comes earlier cuts once the thinking passes `steer.cut_after`, unless the
808
+ step ends first and the message goes in there. A plain message (from the web composer, `chi send` without `--cut`,
809
+ a parent agent) never asks for a cut. Such a cut spends no `retry.empty_answer` attempt and sends no hidden note; only
810
+ the cut thinking is lost. A generation that has streamed visible text or a tool call is never cut, and a plugin's steer
811
+ (`ctx.steer`, `ctx.sessions.send`) never cuts.
812
+
813
+ `/queue TEXT` in the plain REPL runs TEXT as a turn of its own after the running one; the attached terminal and the web
814
+ don't queue yet, and a request that asks the Bridge or the web for `delivery: "queue"` is refused (400
815
+ `delivery_unavailable`).
816
+
817
+ `steer.cut_after` (default `20`, env `SAMAGOTCHI_STEER_CUT_AFTER`):
818
+
819
+ ```yaml
820
+ steer:
821
+ cut_after: 20 # seconds of thinking-only output before a cut may land; 0 = never
822
+ ```
823
+
784
824
  ## Server errors
785
825
 
786
826
  An error status or a server's error event fails the turn with the server's
@@ -961,7 +1001,7 @@ without it: no ids, no tool, the same prompt.
961
1001
 
962
1002
  ```yaml
963
1003
  llm_context:
964
- budget_tokens: 64000 # optional: the [CONTEXT: …] lines count against 64k, not the window
1004
+ budget_tokens: 64k # optional (64000, 64k; 4k to 10M): the [CONTEXT: …] lines count against 64k, not the window
965
1005
  models:
966
1006
  deepseek-v4.1-flash:
967
1007
  llm_context_strategy: [stale, forget]
@@ -1122,7 +1162,7 @@ described in their own sections.
1122
1162
  | `models.<key>.llm_context_apply`, `hosts.<name>.llm_context_apply` | none | | A model's or host's `llm_context.apply`. |
1123
1163
  | `llm_context.stale_edits` | `false` | | Opt-in, experimental: `stale` also stubs a read that a later successful edit or write of the file superseded. |
1124
1164
  | `llm_context.protect_steps` | `3` | | `stale` never stubs a read of a file edited or written in the last N steps (tool batches; counted across the whole conversation, not only the turn). `0` turns the protection off. Under `forget`, `forget_outputs` refuses an output from the last N steps, and a read of a file edited or written in them unless the forget keeps some of its lines. |
1125
- | `llm_context.budget_tokens` | none | | A soft context budget in tokens (e.g. `64000`); off when unset or `0`. When set, the context status buckets (`context.status_thresholds`) count against it instead of the window (the smaller of the two), so the `[CONTEXT: …]` lines, `payoff`'s top bucket and, under `forget`, the `forget_outputs` offers come under it. A session's own (`--llm-context-budget`, `/llm-context budget`; `off` there wins over the model's), then `models.<key>.llm_context_budget_tokens` and `hosts.<name>.llm_context_budget_tokens` come first. |
1165
+ | `llm_context.budget_tokens` | none | | A soft context budget in tokens (`64000` or `64k`, 4k to 10M, as `/llm-context budget` takes it); off when unset, `0` or `off`; a value out of range warns and is ignored. When set, the context status buckets (`context.status_thresholds`) count against it instead of the window (the smaller of the two), so the `[CONTEXT: …]` lines, `payoff`'s top bucket and, under `forget`, the `forget_outputs` offers come under it. A session's own (`--llm-context-budget`, `/llm-context budget`; `off` there wins over the model's), then `models.<key>.llm_context_budget_tokens` and `hosts.<name>.llm_context_budget_tokens` come first. |
1126
1166
  | `models.<key>.llm_context_budget_tokens`, `hosts.<name>.llm_context_budget_tokens` | none | | A model's or host's `llm_context.budget_tokens`. |
1127
1167
  | `llm_context.policy` | the subtask-boundaries line | | The sentence `forget_outputs`' description carries (the `forget` layer): "Tidy at subtask boundaries: once a subtask is done, forget its tool outputs and note what it established; keep anything you'll still edit against." Blank: none. Read when a session starts. |
1128
1168
  | `context.chars_per_token` | `4.0` | yes | Estimate ratio when the server reports no usage. |
@@ -1132,7 +1172,7 @@ described in their own sections.
1132
1172
  | `context.every_seconds` | `300` | | How often an attached context source's command runs when it has no `--every` (seconds, at least 30). See [Attached context](context.md). |
1133
1173
  | `context.wake` | `true` | yes | A source whose update says `wake: true` may start a turn in a live, idle session (one per source per 10 minutes, within `session.max_wakes`); `false`: updates wait as notes for the next turn. See [Waking](context.md#waking). |
1134
1174
  | `thinking.turn_preamble` | `true` | yes | Ask a `qwen36` model to open its thinking with a short `TURN:` line (the step label). |
1135
- | `thinking.level` | `default` | `--thinking` | `off`, `low`, `medium`, `high` or `default` for every model; the flag and env outrank the `models:`/`hosts:` entries, the file's value doesn't. See "Thinking". |
1175
+ | `thinking.level` | `default` | `--thinking` | `off`, `low`, `medium`, `high` or `default` for every model; the flag and env outrank the `models:`/`hosts:` entries, the file's value doesn't; a session's own level outranks them all, and `--thinking` sets it on a session run (on `chi web` it sets this). See "Thinking". |
1136
1176
  | `models.<key>.thinking`, `hosts.<name>.thinking` | none | | A model's or host's level. See "Thinking". |
1137
1177
  | `hosts.<name>.models` | none | | The model ids a host serves whatever its `/v1/models` lists: a map of ids, or a plain list. See "Models a host serves but doesn't list". |
1138
1178
  | `hosts.<name>.models.<id>.served` | none | | The models the host may answer that id with (exact ids, any case), or `any`: no served-model warning for those. See "Models a host serves but doesn't list". |
@@ -1146,7 +1186,7 @@ described in their own sections.
1146
1186
  | `retry.base_delay` | `0.5` | yes | |
1147
1187
  | `retry.max_delay` | `8.0` | yes | |
1148
1188
  | `retry.empty_answer` | `1` | | Times a turn asks again after an empty answer (at most 3, `0` = off). See "Llama Network Retry Behavior". |
1149
- | `steer.cut_after` | `20` | | Seconds a generation must have streamed only thinking before a message sent as a cut (`/cut TEXT`, `chi send --cut`) cuts it; other messages go in at the next step; `0` = never. See "Llama Network Retry Behavior". |
1189
+ | `steer.cut_after` | `20` | | Seconds a generation must have streamed only thinking before a message sent as a cut (`/cut TEXT`, `chi send --cut`) cuts it; other messages go in at the next step; `0` = never. See [Messages during a running turn](#messages-during-a-running-turn-steer-and-cut). |
1150
1190
  | `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). |
1151
1191
  | `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). |
1152
1192
  | `update.gem` | `true` | | `false`: `chi update` never installs a newer gem (`--no-gem` for one run). See [CLI: Updating](cli.md#updating). |
data/docs/memory.md CHANGED
@@ -250,6 +250,17 @@ for a file another bundle installed.
250
250
  installed bundle owns is left out with a line (`Left out identity.md:
251
251
  installed by bundle samagotchi-system (name it to include it)`); naming it
252
252
  in `FILES...` includes it.
253
+ `--name NAME`, `--version VER` and `--description DESC` go into the
254
+ bundle's manifest, and `--out PATH` picks where it goes (`.zip`, `.tar.gz`,
255
+ `.tgz` or `.tar` gives that archive, any other path a directory; default
256
+ `<name>.zip`).
257
+ - **An upgrade that meets your edits.** `chi bundle upgrade` updates a file
258
+ you didn't edit and keeps one you did (a conflict), then asks on a
259
+ terminal whether to launch a chi session that merges the two (`/exit`
260
+ when done). `--agent` launches it without asking, `--no-agent` never does;
261
+ with neither and no terminal it keeps your edits and says so. Kept edits
262
+ exit 2; `chi bundle diff NAME FILE` shows the base, and `--force` takes
263
+ the bundle's version.
253
264
 
254
265
  ## Bundle profiles: core and dev
255
266
 
data/docs/plugins.md CHANGED
@@ -140,7 +140,8 @@ end
140
140
 
141
141
  `/help` (itself an anytime command) lists every command the session knows:
142
142
  chi's own, each bundle's with the bundle's name, and the terminal UIs' own
143
- (`/stats`, `/exit`, `/detach` …), marked `terminal only` or `attached only`.
143
+ (`/stats`, `/exit`, `/detach` …), marked where they run: `terminal only`,
144
+ `attached only`, `repl only`, or `terminal and web only` (`/cut`).
144
145
  It works in all three UIs.
145
146
 
146
147
  ### `chi.tool(name, description, params:, schema:, label:, preview:, targets:) { |args, ctx| … }`
data/docs/sessions.md CHANGED
@@ -181,12 +181,16 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
181
181
  ```
182
182
 
183
183
  - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one). A host config.yml doesn't have is refused before anything is spawned (exit 1). A `host:id` whose id that host's last saved list doesn't have (`~/.local/state/samagotchi/model_lists.json`, what `chi models` / `/models` / the web last listed for that host, at most a week old) gets one warning line on stderr, with up to three close ids as a hint (`did you mean: gemma-small?`), and the session starts anyway: some hosts serve ids they don't list (a one-model llama.cpp server takes any name). A saved list older than 10 minutes that misses the id is re-listed once (that one host, capped at 5 s) before the warning: the list can be stale by content, e.g. a one-model server reloaded with another model; a list re-listed that way names the typed id, nothing is said. A list younger than 10 minutes is taken at its word (a host that serves ids it never lists would otherwise pay the re-list on every launch). A re-list that fails, times out or lists nothing falls back to the saved list. An id its host declares under `hosts.<name>.models` (see [configuration](configuration.md#models-a-host-serves-but-doesnt-list)) is never warned about or re-listed. A bare id (no `host:`) isn't checked that way — routing sends it to whichever host lists or declares it — and with no saved list for the host nothing is said; a wrong id then fails the worker's first turn.
184
+ - `--thinking LEVEL` (with `--new`) gives the new session its own thinking level (`off`, `low`, `medium`, `high`;
185
+ `default` gives it none), saved in it and first in the order. A level from the env
186
+ (`SAMAGOTCHI_THINKING_LEVEL=low chi send --new …`) only reaches the worker this call spawns: when the session's
187
+ worker is woken later (by `chi web`, a delivery) without that env, the level is gone. Use `--thinking`.
184
188
  - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
185
189
  - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
186
190
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
187
191
  - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
188
192
  - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
189
- - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model and LLM context, so `--dir`, `--model` and `--llm-context*` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1.
193
+ - `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model, LLM context and thinking level, so `--dir`, `--model`, `--llm-context*` and `--thinking` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1.
190
194
  - The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
191
195
  - A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
192
196
 
data/docs/testing.md CHANGED
@@ -15,6 +15,10 @@ bug) or a broken spec: fix it, or disable that line with the reason (`# rubocop:
15
15
  No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
16
16
  clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
17
17
 
18
+ The web specs run in plain node. One that renders with lit-html (`turn_view.js`, `vendor/lit-html.js`) needs a DOM:
19
+ `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only, never shipped; about 0.3 s per spec
20
+ file that loads it). The shim's `htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments.
21
+
18
22
  ## Worktrees
19
23
 
20
24
  Each fix or feature gets its own worktree next to the main checkout:
@@ -70,7 +74,8 @@ a model that follows tool instructions: a small model may fail some of them.
70
74
  strategies (freed tokens, wrongly forgotten outputs, one-step re-reads, re-prefilled tokens; for `forget_outputs` a
71
75
  model's answers to chi's offer, with its call rate and notes). It is opt-in: the suite
72
76
  runs its code only on small synthetic sessions, never on yours. See
73
- [internals/llm-context-bench.md](internals/llm-context-bench.md).
77
+ [internals/llm-context-bench.md](internals/llm-context-bench.md). `script/llm_context_live.rb` runs chi itself on
78
+ real tasks under each strategy (needs a model; costs tokens): [internals/llm-context-live.md](internals/llm-context-live.md).
74
79
 
75
80
  ## The model notes report
76
81
 
@@ -6,6 +6,7 @@ require "fileutils"
6
6
  require "ipaddr"
7
7
  require_relative "../atomic_file"
8
8
  require_relative "../config"
9
+ require_relative "../yaml_lines"
9
10
 
10
11
  module Samagotchi
11
12
  module Bootstrap
@@ -15,6 +16,8 @@ module Samagotchi
15
16
  # comments), after a backup, and is checked by parsing it again: every
16
17
  # other key must be unchanged, or the backup goes back.
17
18
  class ConfigWriter
19
+ include YAMLLines
20
+
18
21
  class Error < StandardError
19
22
  end
20
23
 
@@ -33,7 +36,6 @@ module Samagotchi
33
36
  end
34
37
 
35
38
  HOSTS_LINE_RE = /\Ahosts:[ \t]*(#.*)?\z/
36
- DEFAULT_INDENT = 2
37
39
  SERVER_TRANSPORTS = ConfigFile::VALID_TRANSPORTS_FOR_CONFIG
38
40
 
39
41
  # The host part of a name: localhost/127.x → local, another IP → lan,
@@ -157,9 +159,7 @@ module Samagotchi
157
159
  original = File.binread(@path).force_encoding(Encoding::UTF_8)
158
160
  return snippet(name, fields) unless data.is_a?(Hash) && !anchors?(original)
159
161
 
160
- eol = original.include?("\r\n") ? "\r\n" : "\n"
161
- lines = original.split(/(?<=\n)/)
162
- lines[-1] = "#{lines[-1]}#{eol}" if lines.any? && !lines[-1].end_with?("\n")
162
+ lines, eol = lines_of(original)
163
163
  hosts_at = lines.index { |line| line.chomp.match?(HOSTS_LINE_RE) }
164
164
  return snippet(name, fields) if hosts_at.nil? && data.key?("hosts")
165
165
 
@@ -207,28 +207,14 @@ module Samagotchi
207
207
  # before trailing blank lines and comments shallower than an entry.
208
208
  # @return [index, entry indent, field indent step]
209
209
  def block_end(lines, hosts_at)
210
- stop = hosts_at + 1
211
- stop += 1 while stop < lines.length && inside_block?(lines[stop])
210
+ stop = block_stop(lines, hosts_at)
212
211
  children = lines[(hosts_at + 1)...stop].reject { |line| blank_or_comment?(line) }
213
212
  child = children.first ? indent(children.first) : DEFAULT_INDENT
214
213
  field = children.find { |line| indent(line) > child }
215
214
  step = field ? indent(field) - child : child
216
- at = stop
217
- at -= 1 while at > hosts_at + 1 && (lines[at - 1].strip.empty? || (comment?(lines[at - 1]) && indent(lines[at - 1]) < child))
218
- [at, child, step]
219
- end
220
-
221
- def inside_block?(line)
222
- return true if line.strip.empty?
223
- return false if line.start_with?("---", "...")
224
-
225
- line.start_with?(" ", "\t") || comment?(line)
215
+ [insert_at(lines, hosts_at, stop, child), child, step]
226
216
  end
227
217
 
228
- def blank_or_comment?(line) = line.strip.empty? || comment?(line)
229
- def comment?(line) = line.lstrip.start_with?("#")
230
- def indent(line) = line[/\A */].length
231
-
232
218
  def entry_lines(name, fields, child, step)
233
219
  ["#{" " * child}#{name}:\n", *fields.map { |key, value| "#{" " * (child + step)}#{key}: #{scalar(value)}\n" }]
234
220
  end
@@ -316,23 +302,6 @@ module Samagotchi
316
302
  false
317
303
  end
318
304
 
319
- def parse(text) = YAML.safe_load(text, permitted_classes: [], aliases: false)
320
-
321
- # Anchors and aliases: chi's reader refuses aliases, and an insert
322
- # can't know what an anchor shares.
323
- def anchors?(text)
324
- stack = [Psych.parse_stream(text)]
325
- until stack.empty?
326
- node = stack.pop
327
- return true if node.is_a?(Psych::Nodes::Alias) || (node.respond_to?(:anchor) && node.anchor)
328
-
329
- stack.concat(Array(node.children)) if node.respond_to?(:children)
330
- end
331
- false
332
- rescue Psych::Exception
333
- true
334
- end
335
-
336
305
  def backup_path(real)
337
306
  stamp = @now.call.utc.strftime("%Y%m%dT%H%M%SZ")
338
307
  candidate = "#{real}.bak-#{stamp}"
@@ -40,11 +40,6 @@ module Samagotchi
40
40
  end
41
41
  end
42
42
 
43
- # Whether any buffered event could satisfy a reconnect from +from_seq+.
44
- def has_any_after?(from_seq:)
45
- @mutex.synchronize { @events.any? { |e| e[:seq] > from_seq } }
46
- end
47
-
48
43
  # @return [Integer, nil] the highest seq buffered so far.
49
44
  def last_seq
50
45
  @mutex.synchronize { @events.last&.[](:seq) }
@@ -21,6 +21,7 @@ require_relative "session"
21
21
  require_relative "engine"
22
22
  require_relative "guardrails/parent_approvals"
23
23
  require_relative "session_commands"
24
+ require_relative "queued_command"
24
25
  require_relative "steer"
25
26
  require_relative "image_store"
26
27
  require_relative "log"
@@ -62,10 +63,6 @@ module Samagotchi
62
63
  MAX_BODY_BYTES = 1_000_000
63
64
  # A request's deadline (see #handle_post_turn) that isn't epoch seconds.
64
65
  BAD_DEADLINE = [{ "Allow" => "POST" }, 400, { error: "bad_deadline", detail: "deadline must be epoch seconds" }].freeze
65
- # A request's delivery (see #handle_post_turn) that isn't a string. An
66
- # unknown string is not an error: it is the default, as a missing one is.
67
- BAD_DELIVERY = [{ "Allow" => "POST" }, 400,
68
- { error: "bad_delivery", detail: "delivery must be a string" }].freeze
69
66
 
70
67
  # @param engine [Samagotchi::Engine] the owning engine (must already live
71
68
  # in this process)
@@ -290,12 +287,11 @@ module Samagotchi
290
287
  # basename): it runs once that file is taken (Worker#on_command)
291
288
  # @return [String] its command_id
292
289
  def queue_command(line, client_id: nil, card: false, after_file: nil)
293
- command = { command_id: SecureRandom.uuid, client_id: client_id, line: line.to_s.strip }
294
290
  # A card's action (the step-limit question's answer): the UIs leave
295
291
  # its line out.
296
- command[:card] = true if card
292
+ command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card)
297
293
  @engine.synchronize_events { queue_command_locked(command, after_file: after_file) }
298
- command[:command_id]
294
+ command.command_id
299
295
  end
300
296
 
301
297
  private
@@ -726,18 +722,17 @@ module Samagotchi
726
722
  # :command_queued, unless +deadline+ has passed. Returns [headers,
727
723
  # status, body]: 202 with its command_id, or 408.
728
724
  def queue_command_request(session_id, line, client_id:, deadline: nil, card: false)
729
- command = { command_id: SecureRandom.uuid, client_id: client_id, line: line }
730
725
  # A card's action: the UIs leave its line out (the card is the echo).
731
- command[:card] = true if card
726
+ command = QueuedCommand.mint(line, client_id: client_id, card: card)
732
727
  queued = @engine.synchronize_events do
733
- next false if expired?("command_expired", deadline, sid: session_id, client_id: command[:client_id])
728
+ next false if expired?("command_expired", deadline, sid: session_id, client_id: command.client_id)
734
729
 
735
730
  queue_command_locked(command)
736
731
  true
737
732
  end
738
733
  return deadline_passed("command") unless queued
739
734
 
740
- [{}, 202, { status: "accepted", command_id: command[:command_id], session_id: @session_id }]
735
+ [{}, 202, { status: "accepted", command_id: command.command_id, session_id: @session_id }]
741
736
  end
742
737
 
743
738
  # With the event log held. +on_command+ answers what it did with the
@@ -752,7 +747,7 @@ module Samagotchi
752
747
  when :queue then { waits: "turn_end" }
753
748
  else {}
754
749
  end
755
- @engine.announce(type: :command_queued, **command, **marks)
750
+ @engine.announce(type: :command_queued, **command.event_fields, **marks)
756
751
  end
757
752
 
758
753
  # A client asks the worker to exit now (`/exit` in the attached TUI), or
@@ -832,7 +827,9 @@ module Samagotchi
832
827
  return BAD_DEADLINE unless deadline_valid?(deadline)
833
828
 
834
829
  raw_delivery = fetched(parsed, "delivery")
835
- return BAD_DELIVERY unless raw_delivery.nil? || raw_delivery.is_a?(String)
830
+ if (refused = Delivery.refusal(raw_delivery))
831
+ return [{ "Allow" => "POST" }, 400, refused]
832
+ end
836
833
 
837
834
  delivery = Delivery.parse(raw_delivery)
838
835
 
@@ -46,8 +46,7 @@ module Samagotchi
46
46
  return false unless (time(row[:updated_at]) || Time.at(0)) >= cutoff
47
47
 
48
48
  session = Session.summary_from_file(Session.session_file(row[:id], state_dir: state_dir))
49
- last_turn = session&.last_turn
50
- ended = last_turn.is_a?(Hash) ? time(last_turn["ended_at"] || last_turn[:ended_at]) : nil
49
+ ended = time(session&.last_turn&.ended_at)
51
50
  (ended || time(row[:updated_at])) >= cutoff
52
51
  end
53
52
  private_class_method :active_since?