samagotchi 0.10.1 → 0.11.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/docs/architecture.md +19 -0
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +44 -11
  6. data/docs/guardrails.md +2 -1
  7. data/docs/hooks.md +3 -2
  8. data/docs/internals/context-telemetry.md +4 -2
  9. data/docs/sessions.md +3 -3
  10. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  11. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  12. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  14. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  15. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/client.rb +21 -1
  18. data/lib/samagotchi/config.rb +44 -40
  19. data/lib/samagotchi/context_status.rb +17 -16
  20. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  21. data/lib/samagotchi/engine.rb +64 -20
  22. data/lib/samagotchi/host_registry.rb +50 -90
  23. data/lib/samagotchi/kernel_loop.rb +288 -305
  24. data/lib/samagotchi/llm/chat_loop.rb +78 -87
  25. data/lib/samagotchi/model_profile.rb +22 -3
  26. data/lib/samagotchi/model_ref.rb +60 -0
  27. data/lib/samagotchi/prompt_history.rb +91 -0
  28. data/lib/samagotchi/recap_setup.rb +15 -3
  29. data/lib/samagotchi/self_report.rb +11 -6
  30. data/lib/samagotchi/send_command.rb +8 -8
  31. data/lib/samagotchi/session.rb +9 -2
  32. data/lib/samagotchi/session_commands.rb +10 -12
  33. data/lib/samagotchi/session_manager.rb +20 -3
  34. data/lib/samagotchi/sessions_command.rb +57 -51
  35. data/lib/samagotchi/steer.rb +23 -0
  36. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  37. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  38. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  39. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  40. data/lib/samagotchi/terminal_ui.rb +9 -8
  41. data/lib/samagotchi/tool_response.rb +67 -0
  42. data/lib/samagotchi/tool_runner.rb +6 -1
  43. data/lib/samagotchi/tools/delegate.rb +4 -3
  44. data/lib/samagotchi/tools/memory.rb +8 -6
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +28 -2
  47. data/lib/samagotchi/web/public/app.js +29 -2
  48. data/lib/samagotchi/web/public/data.js +9 -1
  49. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  50. data/lib/samagotchi/worker.rb +2 -1
  51. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fbd9765613c81776d1a8d62e1edacd50cc426843dcae07d2c0e6a11ebe80972
4
- data.tar.gz: 8ed4b10801de4650ded6cd223d7723055acd8d0fa1ed3e98af36fe2cb174c403
3
+ metadata.gz: 1f0638cac2109278f683d0b6caeca1bb633786721471c1f366f2cf3bc459b43f
4
+ data.tar.gz: 988b05ee21480227d4a1ed814457dd26af11ed71bf21b3d6c62f863e07f18e1a
5
5
  SHA512:
6
- metadata.gz: fd72e187dab11b52fa7ba403eeecd3ea29b43b2e2364b31cce773279cbbadb18c0539e30f16fed5e76ae849f82c31e76e300cb2f6a419699c127c7396cc14263
7
- data.tar.gz: 4abd84f3c1a83d0afa57239709919c9753c4eb8037503532e5a373a6ef3d0e674cfee330d15a717c50c48b11e5adf2be6611178c13aaf26fa4237fb89ed030e4
6
+ metadata.gz: b7905a2a451c696ca057def8388137bc6a03523bd43a8e9405d2086a99b979f4804fe51c3bb3fd7fc444ade5ea7909339a29c99a3373b9ad3b254d8702210906
7
+ data.tar.gz: ce71c8a3a069014d738088e50372c9f50553bd9da12613a756c5941498ba2f3024f9954d2bbe672b10b6c0b00cc5c80cd296858ec84a1425e4ef3713e84cf0fd
data/CHANGELOG.md CHANGED
@@ -8,6 +8,70 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.11.0] - 2026-10-01
12
+
13
+ ### Added
14
+
15
+ - The web composer has the TUI's prompt history: ↑/↓ walk the prompts typed in either (↑ with the caret on the first
16
+ line; ↓ past the newest brings your draft back). A running TUI picks up lines typed in the web, or in another
17
+ terminal, without a restart.
18
+ - Hooks and plugins: `:after_tool_call` carries the call's `status` (`ok`, `error`, `blocked` or `stopped`), the one its
19
+ activity line shows, worked out from the full output (`output:` is capped).
20
+ - The `skills` bundle (0.1.5) tells a failed step by that `status` instead of matching the output's wording: an
21
+ execute killed by a signal now counts as failed, and an `Error:` line in the output of a command that exited 0 no
22
+ longer does. It needs this chi.
23
+
24
+ ### Changed
25
+
26
+ - The prompt history keeps 100 entries (was 20), is written under a lock and replaced whole (no lost lines when two
27
+ chi processes write at once), and is now mode 0600.
28
+ - With an OpenAI-compatible chat host (`api: openai`), the context value in the REPL's status line and the web's ctx
29
+ meter now moves during a turn, before each request, and the model gets the same short `[CONTEXT: …]` line the
30
+ llama.cpp loop gives it once usage rises past 40 % (`context.status_thresholds`). Before the host reports its first
31
+ token counts, the value is an estimate.
32
+ - Model aliases apply once (an alias pointing to another alias sends that name as written; chi warns at start), and a
33
+ session stores the resolved model (`box:gemma-small` for `tiny`) plus the name it was typed as (`model_typed`), so a
34
+ resumed session keeps its model when an alias is retargeted. With a `default.model` alias, memory overlays and the
35
+ guardrails' `models:` rules now go by the alias's target (an overlay saved under the alias is still read).
36
+ - Breaking: only `:` names a host in a model ref. `/` is part of the id, so `openai/gpt-4o` (an OpenRouter id) goes to
37
+ the default host as written even when a host is named `openai`. chi always wrote `host:`; a hand-written
38
+ `host/model` in `default.model`, an alias or a saved session warns at start (fix it with `host:model`).
39
+ - `/models` shows a `host:model` alias only under its own host.
40
+ - Breaking: an unqualified model name is routed by exact id only. It goes to the default host when that host lists it,
41
+ else to the first host in `hosts:` order that does (it was whichever host answered `/models` first), else to the
42
+ default host; substring routing is gone (`/model gemma` no longer picks a local host listing `gemma-…`: use
43
+ `box:gemma` or the exact id). chi warns at start about a host named like a model family (`qwen3`, `llama`, …).
44
+ - `recap.model` is a model ref like any other: an alias is applied, and `recap: {model: box:x}` with no `host_ref` recaps
45
+ on `box` (it turned recap off). Breaking: `recap: {host_ref: a, model: b:x}` now warns and turns recap off instead of
46
+ silently asking `a` for `x`.
47
+
48
+ ### Fixed
49
+
50
+ - `recap: {host_ref: openrouter, model: openai/gpt-4o}` sent `gpt-4o`.
51
+ - An alias in `default.model`, the web's new-session model, `chi send --new --model` or a plugin fork was sent to the
52
+ server as the alias, not its target; `chi self` named a different model than the worker sent.
53
+ - `/model box:tiny` (an alias for `box:gemma-small`) sent `box:gemma-small` to box; `/model openrouter:tiny` is now
54
+ refused (the alias names host `box`), in the TUI and as a web 400.
55
+ - `models: {small: …}` now applies after `chi --model small` and after resuming such a session.
56
+ - A TUI already waiting at `>` shows a prompt just typed in the web (or another terminal) at the first ↑, instead of
57
+ only from the next prompt on.
58
+ - The `known-names` bundle checks the name in `~name` (that user's home folder): `ls ~myname` is no longer rejected
59
+ as a near miss of `myname`, and `ls ~mynmae` is caught.
60
+ - `chi sessions` usage errors exit 2, as every other command's: an unknown subcommand, `stop` with no ids, a bad
61
+ `list --format` or `--scope`, and now also an unknown flag or a flag missing its value for `list`, `prune` and
62
+ `clean` (they were ignored). Each prints what was wrong and the usage on stderr. A session that is unknown or
63
+ refused still exits 1.
64
+ - With a llama.cpp, mlx or oMLX server (no `api: openai`), an empty answer cut short because the context is full
65
+ (90 % or more) is no longer asked again, as `retry.empty_answer` documents and as with OpenAI-compatible chat
66
+ hosts; a thinking loop cut by the output cap still is. These loops' `generation_completed` events and log lines now
67
+ carry the `finish_reason` too.
68
+ - With an OpenAI-compatible chat host, an answer of only whitespace is an empty answer, as with llama.cpp: asked again
69
+ (`retry.empty_answer`) instead of saved, and with no retry left the turn says the model returned an empty answer.
70
+ - `--no-interrupt` (and `no_interrupt: true`) now raises the tool-call limit to 1000 in the in-process REPL with an
71
+ OpenAI-compatible chat host too; it only applied to llama.cpp, mlx and oMLX hosts there.
72
+
73
+ Update with `chi update` (bundles: known-names 0.1.4, skills 0.1.5).
74
+
11
75
  ## [0.10.1] - 2026-10-01
12
76
 
13
77
  ### Fixed
@@ -578,7 +642,8 @@ and long-lived sessions.
578
642
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
579
643
  hotkey panel that send selected text or the clipboard to your sessions.
580
644
 
581
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.10.1...HEAD
645
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.11.0...HEAD
646
+ [0.11.0]: https://github.com/dm1try/samagotchi/compare/v0.10.1...v0.11.0
582
647
  [0.10.1]: https://github.com/dm1try/samagotchi/compare/v0.10.0...v0.10.1
583
648
  [0.10.0]: https://github.com/dm1try/samagotchi/compare/v0.9.0...v0.10.0
584
649
  [0.9.0]: https://github.com/dm1try/samagotchi/compare/v0.8.1...v0.9.0
data/docs/architecture.md CHANGED
@@ -269,6 +269,11 @@ a hub; an `App.new` without one answers both routes `503 no_hub`.
269
269
 
270
270
  ## Model loops and adapters
271
271
 
272
+ A model ref (`--model`, `/model`, `default.model`, an alias's target) is parsed
273
+ by one pure resolver, `ModelRef.parse` (host prefix, alias), given the configured
274
+ hosts and aliases; `HostRegistry` routes a name that names no host (its model
275
+ index after `/models`) and builds the `ModelTarget`.
276
+
272
277
  Engine picks the loop from the effective model's host (`HostRegistry#resolve`):
273
278
 
274
279
  | Loop | Class | Host | Talks through |
@@ -297,6 +302,20 @@ hook's `stop_generation` cancels the generation's own controller (a child of
297
302
  the turn's, `CancellationController#generation`), and each loop takes the cut
298
303
  as an empty answer and asks again.
299
304
 
305
+ Both loops keep one `EmptyAnswerRetry` per turn (the `retry.empty_answer`
306
+ budget, the hidden nudge, the retry's sampling), and both end each
307
+ generation's `:generation_completed` with a `finish_reason`: the chat host's
308
+ own, or for the raw-prompt loop what `Client::Transport#finish_reason_from`
309
+ reads from the stream's last payload (llama.cpp `/completion`'s `stop_type`:
310
+ `limit` is `length`, `eos` and a stop word are `stop`; `/v1/completions` sends
311
+ its own). An empty answer with a `length` stop and the context 90 % full or
312
+ more is not asked again (a full window, not a thinking loop); with no finish
313
+ reason it is. Both also keep one `ContextStatus` per turn: before each request
314
+ it estimates how full the window is (the server's last count plus what the turn
315
+ appended since), emits `:context_status` on a bucket change, and on a rise past
316
+ the second threshold puts the model's own `[CONTEXT: …]` line on the tail as a
317
+ system message (docs/internals/context-telemetry.md).
318
+
300
319
  **Adapters.** `Client` (raw-prompt servers) and `LLM::OpenAIChat` (one per host,
301
320
  `HostRegistry#adapter_for`) share `LLM::HTTP`: timeouts, TLS for https, a line
302
321
  reader for streamed bodies, the retry loop (`retry.*`; network errors, 429 and
data/docs/cli.md CHANGED
@@ -15,7 +15,7 @@
15
15
  - `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
16
16
  - `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages
17
17
  - `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
18
- - `chi sessions list|stop|archive|unarchive|delete|prune|clean` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md))
18
+ - `chi sessions list|stop|archive|unarchive|delete|prune|clean` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md)). A usage error (an unknown subcommand or flag, a flag missing its value, `stop` or `delete` with no ids, a bad `list --format` or `--scope`) exits 2; an unknown or refused session exits 1
19
19
  - `chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all` — add a context note (TEXT or stdin) to sessions: background the model sees on its next turn; it starts no turn (see [Sessions: Context notes](sessions.md#context-notes))
20
20
  - `chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one picks it up); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead, and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending; see [Starting a session](sessions.md#starting-a-session))
21
21
  - `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text to live sessions as context notes (see [Desktop helper](desktop.md))
@@ -576,13 +576,13 @@ access token", paste the token there (the part of the link after
576
576
  In interactive assist mode, you can switch the request model without restarting:
577
577
 
578
578
  - `/model <name>`: set a session-scoped model override.
579
- - `/model host:model` or `/model host/alias`: qualified host routing (`host:alias` expands alias bare, alias may itself be `host:model` — hybrid).
579
+ - `/model host:model` or `/model host:alias`: qualified host routing (only `:` names a host; `openai/gpt-4o` is a model id). `host:alias` applies the alias; an alias whose target names another host is refused (`alias 'tiny' names host 'box', not 'openrouter'`). Aliases apply once: an alias pointing to another alias sends that name as written.
580
580
  - `/model --default <name>`: set session model and persist as new default in `config.yml` (also updates `SAMAGOTCHI_DEFAULT_MODEL` for future sessions; supports `host:model` full ref).
581
581
  - `/model <name> --alias <alias>`: create alias for current effective model (alias value may be bare or `host:model`).
582
582
  - `/model`: show the effective model (and default when diverged: `runtime model: <effective> (default: <default>, profile=<name>, <source>)`, e.g. `profile=qwen36, server (chat_template)`).
583
583
  - `/model clear` (or `default`/`none`/`off`): clear the session override, reverting to the configured default.
584
584
  - `/guardrails`: the guardrail rules (by source), what failed to load, and your stored approvals, numbered; `/guardrails revoke N` removes approval N (see [Guardrails](guardrails.md)).
585
- - `/models`: list model ids aggregated across all `hosts:` (grouped `host (host:port):` with per-host `unreachable` warnings, e.g. an unset `api_key_env`; lists cached 60s, 10 minutes for a remote host; lazy — no startup prefill). At most 20 ids per host, then `… and N more`; `/models <text>` lists every id containing `<text>` (any case), e.g. `/models qwen` on OpenRouter.
585
+ - `/models`: list model ids aggregated across all `hosts:` (an alias shows next to its id: a bare alias on every host listing the id, a `host:model` one only under its host; grouped `host (host:port):` with per-host `unreachable` warnings, e.g. an unset `api_key_env`; lists cached 60s, 10 minutes for a remote host; lazy — no startup prefill). At most 20 ids per host, then `… and N more`; `/models <text>` lists every id containing `<text>` (any case), e.g. `/models qwen` on OpenRouter.
586
586
 
587
587
  Notes:
588
588
 
@@ -640,17 +640,34 @@ This is separate from verbose mode:
640
640
 
641
641
  ## Persistent Prompt History
642
642
 
643
- Assist mode keeps a small persistent prompt history across restarts.
643
+ Assist mode keeps a persistent prompt history across restarts, one for every
644
+ chi prompt: the REPL, attached mode and the `chi web` composer (↑/↓ there)
645
+ share it.
644
646
 
645
647
  - Default history file: `$XDG_STATE_HOME/samagotchi/history.json`
646
648
  - XDG fallback when unset: `~/.local/state/samagotchi/history.json`
647
- - Optional override: `SAMAGOTCHI_HISTORY_FILE=/custom/path/history.json`
648
- - Stored entries: most recent `20` prompts
649
- - Format: JSON array of prompt strings
649
+ - Optional override: `history.file` in the config, or
650
+ `SAMAGOTCHI_HISTORY_FILE=/custom/path/history.json`
651
+ - Stored entries: most recent `100` prompts (and `!commands`; not `/commands`
652
+ or `!rollback`)
653
+ - Format: JSON array of prompt strings, mode 0600. Each write holds
654
+ `<file>.lock` and replaces the file whole, so a TUI and the web writing at
655
+ once keep both lines (a symlinked history file becomes a regular file).
650
656
 
651
657
  Behavior details:
652
658
 
653
659
  - Prompt history is loaded on startup before the first `>` prompt.
660
+ - A running REPL or attached TUI picks up the lines other chi processes (the
661
+ web, another terminal) added: before each `>` prompt and on the first ↑ at
662
+ an open one (Ctrl-P, vi `k` too), when the file changed, its new lines join
663
+ the ↑ list after the ones already there, without a
664
+ restart. A `/command` you typed stays under ↑ too. `chi scratch` keeps its
665
+ own list and writes nothing.
666
+ - The web records a prompt once its session's worker took it (a refused or
667
+ timed-out send isn't saved), a `!command` likewise; an image-only message
668
+ isn't saved. `chi send`, `delegate` and plugin turns aren't typed at a
669
+ prompt and aren't saved. With `chi web --web-host lan` the list is served to
670
+ token holders (`GET /api/history`), as the sessions are.
654
671
  - Only real user prompts are persisted.
655
672
  - Continue-flow inputs (`yes`, `no`, `no, <reason>`, `/continue`) are not persisted as prompts.
656
673
  - In assist mode, pressing `Tab` on an `@`-prefixed token (for example `@lib/sama`) completes project file and directory paths while preserving the `@` prefix.
@@ -47,7 +47,10 @@ hosts:
47
47
  # (on a paid remote host that is one small request per idle window).
48
48
  # host_ref + model pin another model: host_ref asks that host's OpenAI API (its
49
49
  # url:, else http://host:port/v1) with its api_key_env; base_url is an OpenAI API
50
- # base as given (e.g. http://h:8081/v1). recap: false turns it off.
50
+ # base as given (e.g. http://h:8081/v1). model is a model ref like any other: an
51
+ # alias is applied, and its host:/the alias's host picks the host when host_ref
52
+ # is left out (a model naming another host than host_ref turns recap off with a
53
+ # warning). recap: false turns it off.
51
54
  recap:
52
55
  # host_ref: small-box
53
56
  # model: your-small-model-id
@@ -480,18 +483,31 @@ To explicitly route requests to a named model in llama.cpp, set:
480
483
 
481
484
  When `default.model` is unset or blank, Samagotchi fails fast with a clear startup/configuration error.
482
485
 
483
- With several `hosts:`, an unqualified model name goes to the host whose `/models`
484
- list has it (after `/models` ran), by exact id first, then by substring. A
485
- **remote** host (one with `api_key_env:` or an `https` url) is only chosen by exact
486
- id, `host:model` or an alias, never by a substring, and its model list is kept for
487
- 10 minutes (60s for local hosts). For a chat host the context window comes from
486
+ **The default host** is the host named `default`, else the first one under
487
+ `hosts:` (without `hosts:`, the `server:` section).
488
+
489
+ With several `hosts:`, `host:model` (or an alias naming a host) pins the host.
490
+ An unqualified model name goes to the default host when its `/models` list has
491
+ that exact id, else to the first host in `hosts:` order whose list has it, else
492
+ to the default host. Only exact ids count, never a substring: `/model gemma` on
493
+ a box that lists `gemma-3-12b` needs `box:gemma` or the exact id. The lists are
494
+ known only after `/models` ran (nothing is fetched before the first turn), so
495
+ until then an unqualified name goes to the default host; use `host:model` to pin
496
+ one. A **remote** host (one with `api_key_env:` or an `https` url) keeps its model
497
+ list for 10 minutes (60s for local hosts). For a chat host the context window comes from
488
498
  the running server (llama.cpp's `/props`), else the window the host's model list
489
499
  gives (`context_length`, `context_window`, `max_model_len` or llama.cpp's
490
500
  `meta.n_ctx`), else `context.window_tokens`.
491
501
 
492
- A `:` in a model name is often part of the id (`qwen3:8b`, `mistral:7b`,
502
+ Only `:` names a host: `/` is part of a model id (`openai/gpt-4o` is an
503
+ OpenRouter id and goes to the default host as written, even with a host named
504
+ `openai`). A `default.model`, an alias or a saved session that still says
505
+ `host/model` gets a warning at start; fix it with `host:model`. A `:` in a model
506
+ name is often part of the id (`qwen3:8b`, `mistral:7b`,
493
507
  `unsloth/Qwen3-8B-GGUF:Q4_K_M`), so the part before the first `:` picks a host
494
- only when it is a configured host's name. An unknown prefix is refused with an
508
+ only when it is a configured host's name. chi warns at start about a host named
509
+ like a model family (`qwen3`, `llama`, `gemma`, …): `qwen3:8b` would go to that
510
+ host as `8b`. An unknown prefix is refused with an
495
511
  error naming it and the configured hosts (with a "did you mean" for a near
496
512
  miss) when either
497
513
  - the rest is an `org/model` id (`nosuch:anthropic/claude-sonnet-4`), or
@@ -504,6 +520,23 @@ alias, `/model`, `chi send --new --model`, the web's new-session model and a
504
520
  delegate's model. Any other unknown prefix (`nosuch:x`) is sent to the default
505
521
  host as the model id.
506
522
 
523
+ ### Model aliases
524
+
525
+ `model_aliases:` maps a name to a model id or `host:model`. An alias works
526
+ wherever a model name does (`--model`, `default.model`, `/model`, the web's
527
+ new-session model, `chi send --new --model`, a delegate's or fork's model, the
528
+ `models:` keys) and the server is sent its target:
529
+
530
+ - Aliases apply once. An alias whose target is another alias sends that name as
531
+ written, and chi warns about it at start ("aliases don't chain").
532
+ - `host:alias` applies the alias on that host. When the alias's target names
533
+ another host, the model is refused (`alias 'tiny' names host 'box', not
534
+ 'openrouter'`).
535
+ - An alias named like a real model id shadows that id, also as `host:<id>`.
536
+ - A session stores the resolved ref (`box:your-small-model-id`) plus the name it
537
+ was typed as, so a resumed session keeps its model when an alias is
538
+ retargeted, and `models: {small: …}` still applies to it.
539
+
507
540
  ## Llama Network Retry Behavior
508
541
 
509
542
  Transient network failures are retried automatically with exponential backoff.
@@ -734,8 +767,8 @@ described in their own sections.
734
767
  | `server.read_timeout` | `600` | yes | Read timeout, seconds. |
735
768
  | `server.first_token_timeout` | 120 remote, off local | | Seconds to the first token; `0` = off. `hosts.<name>.first_token_timeout` wins. |
736
769
  | `recap.enabled` | on | | `false` (or `recap: false`) turns the idle recap off. |
737
- | `recap.model` | session's | yes | Model that writes the recap. |
738
- | `recap.host_ref` | session's | yes | A `hosts:` name to ask (`host:` is accepted too). |
770
+ | `recap.model` | session's | yes | Model that writes the recap: an id, alias or `host:model` (the host then picks where to ask). |
771
+ | `recap.host_ref` | session's | yes | A `hosts:` name to ask (`host:` is accepted too, also a host name, not a model ref). |
739
772
  | `recap.base_url` | none | yes | An OpenAI API base to ask instead (`http://h:8081/v1`). |
740
773
  | `recap.inactivity` | `180` | yes | Idle seconds before a recap. |
741
774
  | `recap.timeout` | `30` | yes | Seconds a recap request may take. |
@@ -789,7 +822,7 @@ described in their own sections.
789
822
  | `web.markdown` | `false` | yes | Render answers as Markdown in `chi web`. |
790
823
  | `web.view` | `stage` | yes | How `chi web` draws a turn: `stage` (the running turn pinned above the composer) or `turn` (one block per turn in the history). See [CLI](cli.md#web-views). |
791
824
  | `web.annotate_presets` | `Agreed\|Could you please elaborate?` | yes | Quick replies next to Annotate in `chi web`, `\|`-separated (a YAML list works too); `""` in the file or on the CLI leaves only Annotate (an empty env value means the default). See [CLI](cli.md#web-annotate-presets). |
792
- | `history.file` | state dir | | Prompt history path. |
825
+ | `history.file` | state dir | | Prompt history path, shared by the TUI and the web composer. |
793
826
  | `no_interrupt` | `false` | `--no-interrupt` | Raise the tool-call limit of a turn to 1000; a top-level key. |
794
827
  | `no_default_input` | `false` | `--no-default-input` | Don't pre-fill `default.input`; a top-level key. |
795
828
  | `skip_agent_md` | `false` | | Don't load AGENT.md; a top-level key (see below). |
data/docs/guardrails.md CHANGED
@@ -223,7 +223,8 @@ call's command, `cwd` or paths (never a write's content) that is within one
223
223
  edit of a known name shorter than 10 characters, or two edits of a longer
224
224
  one, is a near miss. Tokens and names shorter than `min_length` (6) are
225
225
  skipped, as is a token that equals another known name. A token with a shell
226
- glob (`*?[]{}`, as in `ls -d samagotchi*`) is not checked.
226
+ glob (`*?[]{}`, as in `ls -d samagotchi*`) is not checked. In `~name` (that
227
+ user's home folder) the name after `~` is what is checked.
227
228
 
228
229
  By default (`mode: reject`) the call is denied with advice in place of the
229
230
  usual tail, so the model retries it corrected:
data/docs/hooks.md CHANGED
@@ -55,7 +55,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
55
55
  | `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
56
56
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N }` |
57
57
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
58
- | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." }` |
58
+ | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (capped at max_tool_output_chars), status: "ok" \| "error" \| "blocked" \| "stopped" }` |
59
59
  | `:session_end` | After every turn (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
60
60
 
61
61
  Every event also carries the hook runtime (next section): `hook:` (the label
@@ -348,7 +348,8 @@ is sticky too), and the model gets `[<tool>] Error: blocked by guardrail: <reaso
348
348
  (default reason `blocked by hook`). A verdict's deny reads
349
349
  `[<tool>] Error: denied by guardrail (<rule or hook>): <reason>. … Do not retry it …`
350
350
  (after a user's Deny on an ask: `[<tool>] Error: The user declined this call… It needed approval (<rule or hook>): <reason>. …`).
351
- Either way the activity status is `blocked`, and `:after_tool_call` still fires.
351
+ Either way the activity status is `blocked`, and `:after_tool_call` still fires,
352
+ with `status: "blocked"`.
352
353
  Only `:before_tool_call` votes.
353
354
 
354
355
  **Mutating params (legacy):**
@@ -10,8 +10,10 @@ resumed session's line already names):
10
10
 
11
11
  `[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted and range reads over full-file dumps]`
12
12
 
13
- The native loop only (the chat loop has no context status). The event carries
14
- a `status` string using this prefix:
13
+ Both loops do this, before each request (`ContextStatus`, one per turn). The
14
+ chat loop's estimate counts the conversation's text and tool calls, not the
15
+ tool schemas it sends, until the host's first count (its `usage`). The event
16
+ carries a `status` string using this prefix:
15
17
 
16
18
  `CONTEXT_STATUS ...`
17
19
 
data/docs/sessions.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sessions
2
2
 
3
- Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`). Besides the messages and turn state, the JSON keeps `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, and `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise); files from before those keys read as empty lists and no parent.
3
+ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`). Besides the messages and turn state, the JSON keeps `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, and `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise); files from before those keys read as empty lists and no parent. `model_name` is the resolved model ref (an alias applied, `host:id` when it names a host), so a resumed session keeps its model when an alias is retargeted; `model_typed` is the name it was given as when that differs (an alias), for the `models:` lookup after a resume (`null` otherwise).
4
4
 
5
5
  **Retention (file-based, opt-out via env):**
6
6
 
@@ -61,9 +61,9 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
61
61
  **Ordering:**
62
62
 
63
63
  - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
64
- - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat; "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first command line (`/model …`) still starts the session as its `prompt`. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`.
64
+ - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat; "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first command line (`/model …`) still starts the session as its `prompt`. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
65
65
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
66
- - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
66
+ - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
67
67
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
68
68
 
69
69
  **Test-session hygiene:**
@@ -93,7 +93,7 @@ module Samagotchi
93
93
 
94
94
  # The entry's name: +requested+ as given (it must be free), else
95
95
  # +base+, with -2, -3… when the name is taken or is the prefix of a
96
- # model id (chi would read "openai/gpt-x" as host openai, model gpt-x).
96
+ # model id (chi would read "qwen3:8b" as host qwen3, model 8b).
97
97
  def host_name(base, requested: nil, model_ids: [])
98
98
  taken = current_hosts.keys
99
99
  if requested
@@ -104,7 +104,7 @@ module Samagotchi
104
104
  return name
105
105
  end
106
106
 
107
- prefixes = [*model_ids, configured_default_model].compact.map { |id| id.to_s.split(%r{[:/]}, 2).first.downcase }
107
+ prefixes = [*model_ids, configured_default_model].compact.map { |id| id.to_s.split(":", 2).first.downcase }
108
108
  name = base
109
109
  n = 1
110
110
  name = "#{base}-#{n += 1}" while taken.include?(name) || prefixes.include?(name)
@@ -6,7 +6,8 @@
6
6
  # that once misspelled a name in a path keeps copying the wrong spelling;
7
7
  # a rejection that names the right one breaks the loop.
8
8
  #
9
- # A token with a shell glob (*?[]{}) is not checked.
9
+ # A token with a shell glob (*?[]{}) is not checked. In "~name" the name
10
+ # after "~" is checked.
10
11
  #
11
12
  # Settings (config.yml, `bundles: known-names:`), all optional:
12
13
  # names: [jonathandoe] names to protect besides the derived ones
@@ -21,8 +22,9 @@ class KnownNames
21
22
  DERIVATIONS = %w[home user git repo].freeze
22
23
  DEFAULT_MIN_LENGTH = 6
23
24
  # Splits a command or path into tokens; each token's [-_.] parts are
24
- # candidates too.
25
- TOKEN_SPLIT = %r{[/\s"'=:,;|&()<>@]+}.freeze
25
+ # candidates too. "~" splits: in "~name" (that user's home) the name
26
+ # after it is what is checked.
27
+ TOKEN_SPLIT = %r{[/\s"'=:,;|&()<>@~]+}.freeze
26
28
  PART_SPLIT = /[-_.]+/.freeze
27
29
  # A candidate with a shell glob character is not checked: `workato*`
28
30
  # is a pattern over the name, not a misspelling of it.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: known-names
3
- version: 0.1.3
3
+ version: 0.1.4
4
4
  scope: system
5
5
  description: Catches a near-miss of a protected name (home folder, login, git name, repo name, names from config) in a tool call and rejects it with the right spelling; mode ask / correct optional
6
6
  trust_level: reviewed
@@ -8,7 +8,7 @@ files:
8
8
  known_names.md: sha256:d5348b95b6c655017b541910b36e384220da280818160201f6ec9a50e1e4287c
9
9
  hooks:
10
10
  known_names.rb:
11
- sha256: sha256:443777b7d6208ff7b2b668a4510db853826a507bd44ea23ea2428fa4b2df5686
11
+ sha256: sha256:c0845c9b6d04ec884c4641441d8a0ba3482283f4ee3e00c8b3bf99efb0c635b1
12
12
  event: before_tool_call
13
13
  on_error: log
14
14
  priority: 50
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: skills
3
- version: 0.1.4
3
+ version: 0.1.5
4
4
  scope: system
5
5
  description: "Skills (skill_<name> memories, the steps of a task done together): /skill save, list, show and diff; older versions kept with a short diff line on every update; a nudge when a followed skill's step fails"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
- sha256: sha256:cc5b3bcfd571e17074776f48c8d28184475f2a4becd06d70edbacd7381e1f306
10
- requires_chi: ">= 0.9.0"
9
+ sha256: sha256:9bd637f8fa933b03434e4ccdcbf2547aa011993b8044f2739ee7d50f995d2882
10
+ requires_chi: ">= 0.11.0"
@@ -173,10 +173,10 @@ class Plugin
173
173
  nil
174
174
  end
175
175
 
176
+ # A failed step is a call whose status (core's, from the full output) is
177
+ # error: a denied call is blocked, a stopped task_wait stopped.
176
178
  def step_failed(event, ctx)
177
- tool = event[:tool].to_s
178
- output = event[:output].to_s
179
- return if NOT_FAILURES.include?(tool) || guardrail_deny?(tool, output) || !failure?(tool, output)
179
+ return if NOT_FAILURES.include?(event[:tool].to_s) || event[:status] != "error"
180
180
 
181
181
  @failed = true
182
182
  return if @nudged || @read.any? { |name| @written.include?(name) }
@@ -184,26 +184,6 @@ class Plugin
184
184
  @nudged = ctx.steer(format(NUDGE, @read.join(", ")))
185
185
  end
186
186
 
187
- # A denied call, by its first line: a legacy veto, a rule's, hook's or
188
- # core's deny, or the user's (declined, approval cancelled). Core's
189
- # wording (ToolRunner#denied, Verdict#deny_text).
190
- def guardrail_deny?(tool, output)
191
- output.match?(/\A\[#{Regexp.escape(tool)}\] Error: (?:blocked by guardrail: |denied by guardrail \(|[^\n]*? It needed approval \()/)
192
- end
193
-
194
- # A tool error ("[x] Error: …" raised, "[x]\nError: …" returned), or an
195
- # execute that exited non-zero; its "exit: N" line may be cut off by the
196
- # hook's output cap, and then an Error: line near the top counts.
197
- def failure?(tool, output)
198
- return true if output.match?(/\A\[#{Regexp.escape(tool)}\](?: |\n)Error:/)
199
- return false unless tool == "execute"
200
-
201
- exit_line = output.match(/^exit: (\d+)(?: \(no output\))?\s*\z/)
202
- return exit_line[1] != "0" if exit_line
203
-
204
- output.lines.first(20).any? { |line| line.match?(/\A\s*Error:/i) }
205
- end
206
-
207
187
  def after_turn(ctx)
208
188
  changed_elsewhere(ctx)
209
189
  return unless @nudge && @failed
@@ -56,7 +56,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
56
56
 
57
57
  **Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
58
58
 
59
- - `model_aliases:` map of alias → model id (`ConfigFile.resolve_model_alias`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid).
59
+ - `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
60
60
  - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
61
61
  - `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
62
62
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.10.1
3
+ version: 0.11.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
9
- config_modification_protocol.md: sha256:d8bdf062d5afce7430ead7f49439c1d1ed197a6261dc69a7c884f4b814266b12
9
+ config_modification_protocol.md: sha256:dddd083d6f419674e6e0cc39118ca9785583a557e4950af1a9b028e52e8a3e0c
10
10
  memory_guide.md: sha256:81b911b9a015f0924c48162b3dc4b6376309e743765074419a99fab46e480858
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -90,6 +90,21 @@ module Samagotchi
90
90
  openai_compatible? ? payload.dig("choices", 0, "text").to_s : payload.fetch("content", "")
91
91
  end
92
92
 
93
+ # Why the stream stopped, from its last payload, as an OpenAI finish
94
+ # reason ("length" for the token cap or a full context, "stop"
95
+ # otherwise); nil for a payload that doesn't say. llama.cpp's
96
+ # /completion names it stop_type ("limit", "eos", "word"); the
97
+ # OpenAI-compatible servers send their own finish_reason.
98
+ def finish_reason_from(payload)
99
+ return nil unless payload.is_a?(Hash)
100
+ return payload.dig("choices", 0, "finish_reason") if openai_compatible?
101
+
102
+ case payload["stop_type"]
103
+ when "limit" then "length"
104
+ when "eos", "word" then "stop"
105
+ end
106
+ end
107
+
93
108
  # The request's `model` field for this transport (nil = omit the field):
94
109
  # - llama.cpp: forward the selector (SAMAGOTCHI_DEFAULT_MODEL) verbatim.
95
110
  # - mlx_lm.server: omit `model` entirely (use whatever was loaded via the
@@ -248,7 +263,12 @@ module Samagotchi
248
263
  content, payload = parsed_chunk
249
264
  shown.call unless content.to_s.empty?
250
265
  result << content
251
- on_chunk&.call(content: content, payload: payload)
266
+ next unless on_chunk
267
+
268
+ chunk = { content: content, payload: payload }
269
+ finish_reason = @transport.finish_reason_from(payload)
270
+ chunk[:finish_reason] = finish_reason if finish_reason
271
+ on_chunk.call(**chunk)
252
272
  end
253
273
  result
254
274
  rescue LLM::BadRequest => e