samagotchi 0.40.0 → 0.42.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/README.md +1 -1
  4. data/bin/chi +19 -2
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +34 -3
  7. data/docs/guardrails.md +15 -3
  8. data/docs/internals/prompt-caching.md +2 -1
  9. data/docs/memory.md +108 -1
  10. data/docs/sessions.md +5 -5
  11. data/docs/testing.md +16 -0
  12. data/lib/samagotchi/bridge/card_store.rb +6 -7
  13. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +23 -0
  14. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -5
  15. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  17. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  18. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  19. data/lib/samagotchi/child_reports.rb +8 -4
  20. data/lib/samagotchi/config.rb +3 -0
  21. data/lib/samagotchi/context_command.rb +5 -2
  22. data/lib/samagotchi/engine.rb +121 -16
  23. data/lib/samagotchi/events.rb +8 -1
  24. data/lib/samagotchi/guardrails/model_size.rb +3 -4
  25. data/lib/samagotchi/guardrails/rules.rb +27 -17
  26. data/lib/samagotchi/guardrails/targets.rb +40 -1
  27. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  28. data/lib/samagotchi/llm/errors.rb +33 -1
  29. data/lib/samagotchi/llm_context_command.rb +151 -0
  30. data/lib/samagotchi/llm_context_override.rb +180 -0
  31. data/lib/samagotchi/llm_context_strategy.rb +63 -13
  32. data/lib/samagotchi/memory_bundle/index_size.rb +103 -0
  33. data/lib/samagotchi/memory_bundle/index_sync.rb +3 -2
  34. data/lib/samagotchi/model_match.rb +54 -0
  35. data/lib/samagotchi/model_notes.rb +205 -0
  36. data/lib/samagotchi/parent_report.rb +12 -2
  37. data/lib/samagotchi/plugin/context.rb +3 -2
  38. data/lib/samagotchi/plugin/sessions.rb +3 -1
  39. data/lib/samagotchi/prompt_note.rb +53 -0
  40. data/lib/samagotchi/reply_wait.rb +3 -17
  41. data/lib/samagotchi/self_report.rb +47 -0
  42. data/lib/samagotchi/send_command.rb +22 -2
  43. data/lib/samagotchi/session.rb +44 -4
  44. data/lib/samagotchi/session_commands.rb +45 -6
  45. data/lib/samagotchi/session_manager.rb +5 -2
  46. data/lib/samagotchi/session_metrics.rb +47 -7
  47. data/lib/samagotchi/system_prompt.rb +79 -11
  48. data/lib/samagotchi/terminal_ui/attach_launcher.rb +13 -5
  49. data/lib/samagotchi/terminal_ui/attached_loop.rb +32 -12
  50. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -0
  51. data/lib/samagotchi/terminal_ui/formatting.rb +40 -0
  52. data/lib/samagotchi/terminal_ui.rb +16 -6
  53. data/lib/samagotchi/tool_runner.rb +26 -1
  54. data/lib/samagotchi/tools/memory.rb +70 -3
  55. data/lib/samagotchi/turn_flow.rb +44 -8
  56. data/lib/samagotchi/turn_note.rb +40 -1
  57. data/lib/samagotchi/version.rb +1 -1
  58. data/lib/samagotchi/web/app.rb +35 -6
  59. data/lib/samagotchi/web/public/app.js +47 -7
  60. data/lib/samagotchi/web/public/ctx.js +28 -5
  61. data/lib/samagotchi/web/public/format.js +29 -2
  62. data/lib/samagotchi/web/public/index.html +12 -0
  63. data/lib/samagotchi/web/public/live_turn.js +1 -0
  64. data/lib/samagotchi/web/public/llm_context_chip.js +194 -0
  65. data/lib/samagotchi/web/public/session_state.js +6 -0
  66. data/lib/samagotchi/web/public/timing.js +8 -2
  67. data/lib/samagotchi/web/public/turn_view.js +15 -2
  68. data/lib/samagotchi/web/session_summary.rb +4 -0
  69. data/lib/samagotchi/worker.rb +36 -18
  70. data/lib/samagotchi/worker_wakes.rb +2 -1
  71. metadata +8 -1
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. `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).
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, `prompt_notes`, the model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)), 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
 
@@ -21,7 +21,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
21
21
 
22
22
  **Which chi a worker runs:** a new or woken worker runs the newest chi installed for this Ruby (`gem 'samagotchi', '>= 0.a'`, prereleases count), whoever starts it: a `chi web` or terminal still on an older chi starts it on the newest one, so a stale process doesn't spread old code. A worker already running keeps its version until it is restarted. Should the newest install fail to load, the worker runs the starter's own version and logs `boot_fallback` (its stdio is `/dev/null`). Under `bundle exec` the worker drops Bundler's env first, so it activates the newest gem too. Two consequences: a wrapper pin (`chi _0.17.0_ web`) pins only that process, not the workers it starts; and a source checkout (`bin/chi` in a worktree, `bundle exec`) starts its workers on its own `lib/`, but a session it made that is later woken by an installed chi (`chi web`, `chi send`) runs the installed code. The names an older starter hands a newer worker (the boot line, its env keys, `input/` files, the session JSON fields and the Bridge routes sessions use on each other) are pinned in `spec/worker_contract_spec.rb`.
23
23
 
24
- **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s.
24
+ **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s.
25
25
 
26
26
  **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
27
27
 
@@ -65,7 +65,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
65
65
  **Ordering:**
66
66
 
67
67
  - `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.
68
- - 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 (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). 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. It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only. 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, sampling?}], warning?}`; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; 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 line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ 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).
68
+ - 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 (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). 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. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only, and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). 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, sampling?}], warning?}`; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; 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 line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ 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).
69
69
  - 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).
70
70
  - 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; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `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`).
71
71
  - 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).
@@ -198,8 +198,8 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
198
198
  - **Delegate reports: a child's reply comes back by itself.** When a child ends a turn that was its parent's (its task, a follow-up, a continue the parent answered, a reminder: not a turn its user typed into it directly), it rings the parent: a small file in the parent's `children/` dir, written after the child's turn is saved. So does a question the parent's model decides (the child's own `ask_user_question`, the step-limit continue; not an approval or a hook's question, which the user answers on the child's card), the turn after such a question whoever starts it, and the child's worker crashing. The parent reads the child as `delegate_result` would and hands its model a **report**: the same text (`session:`, `status:`, the reply), under its own header (`[Delegate report, delivered by chi …; not your user's message. Tell your user what it found …]`), as an input message with `source: "delegate_report"`.
199
199
  - Parent mid-turn: the report joins the turn at its next iteration boundary (a "delegate report" bubble in the web, `delegate report> ab12cd34 answered: …` in the attached terminal).
200
200
  - Parent idle: it runs a turn for the report (a *wake turn*, `last_turn.origin: "delegate_report"`) within the worker's 5 s tick, or wakes a worker for it when the parent's has idle-exited. Several reports that wait together are one turn and one message. A stopped, archived or scratch parent, or one open in a `--no-shared` REPL, is not woken: the rings wait on disk and join the next turn it runs (the user's message first). A worker just started (`chi send` to a stopped parent) runs no wake turn in its first 2 s, so the message it was started for goes first.
201
- - The parent's cursor per child (the newest reply it was given and the child as it was then) is in its `delegates.json`, shared with `delegate_result` and `wait: true`: a reply the model already had is never reported again, also after the parent's worker restarts. A ring is deleted only when the turn that took its report is kept; a turn that fails leaves it for the next one (and the report isn't handed back to the composer).
202
- - Limits: `session.max_wakes` (default `10`) wake turns in a row with no human input; past it the reports wait for the next message and the UIs show `N delegate reports waiting; they join your next message`. A wake turn that fails pauses wakes until the next human input; its reports' rings stay, so they join that input's turn, and the failure note tells the model so. A continue offer waiting in the parent takes the reports into its continue turn instead.
201
+ - The parent's cursor per child (the newest reply it was given and the child as it was then) is in its `delegates.json`, shared with `delegate_result` and `wait: true`: a reply the model already had is never reported again, also after the parent's worker restarts. A ring is deleted only when the turn that took its report stays in the conversation (completed, canceled, or failed but kept: one that got to tool steps, or a reminder or continue turn, which keep what they read); a turn rolled back (a failed one that got nowhere, a canceled continue turn) leaves it for the next one (and the report isn't handed back to the composer). So the model reads each report once, unless a later `!rollback` erases a kept turn (what it read goes with it).
202
+ - Limits: `session.max_wakes` (default `10`) wake turns in a row with no human input; past it the reports wait for the next message and the UIs show `N delegate reports waiting; they join your next message`. A wake turn that fails, or is stopped before the model call (a Stop, a `before_turn` hook's cancel), pauses wakes until the next human input; its reports' rings stay, so they join that input's turn, and a failure's note tells the model so. A continue offer waiting in the parent takes the reports into its continue turn instead.
203
203
  - `session.delegate_reports`: `wake` (default), `queue` (no wake turns: reports join the parent's next turn) or `off` (no rings; only `delegate_result` asks, as before). Env `SAMAGOTCHI_SESSION_DELEGATE_REPORTS`, `SAMAGOTCHI_SESSION_MAX_WAKES`.
204
204
  - The web's bell rings at a wake turn's end however short (nobody started it, so nobody watched it). The text a model writes about a report mid-turn, before its next tool call, stays visible after the report's bubble once the turn ends.
205
205
  - Every child starts with the shipped `delegated` system memory preloaded (`preloaded_memory_names: ["system/delegated"]`; `--mute delegated` would hide it): put everything in the final reply, don't delegate further, don't write memories or change config, stay in the folder. Its system prompt also says `Delegated by session <parent id>`. The parent's own `--mute` list does not carry over.
data/docs/testing.md CHANGED
@@ -71,3 +71,19 @@ strategies (freed tokens, wrongly forgotten outputs, one-step re-reads, re-prefi
71
71
  model's answers to chi's offer, with its call rate and notes). It is opt-in: the suite
72
72
  runs its code only on small synthetic sessions, never on yours. See
73
73
  [internals/llm-context-bench.md](internals/llm-context-bench.md).
74
+
75
+ ## The model notes report
76
+
77
+ `ruby script/model_notes_report.rb` reads a sessions folder (`--sessions DIR`, else chi's own) and groups the
78
+ sessions by model and by the model notes their prompt carried (the session file's `prompt_notes`, as
79
+ `name@digest`; `none` without notes, or in a file from before they were recorded). For each session and group it
80
+ prints the numbers a model note is meant to move: the place of the first edit (an `edit` or `write` call) and of the
81
+ first commit (an `execute` call that runs `git [options] commit`) among the tool calls, commits per 100 steps (model
82
+ requests), `execute` calls with a bare `&`, the longest run of calls with no edit, and what the session needed from
83
+ outside: Continues (the turns in its `analytics.json` that had no prompt of their own), steers (lines that reached a
84
+ running turn from the user, `chi send` or a parent, and a Continue's text), follow-up prompts and plugin nudges. A
85
+ group has the medians of the places and runs, commits per 100 steps pooled and the counts summed. `--help` defines
86
+ each column; `--model GLOB`, `--since DATE` and `--min-steps N` filter, `--json` gives JSON. It reads only and prints
87
+ no session text (ids, model names, note names and digests, dates, numbers), and the suite runs it only on small
88
+ synthetic sessions. It is how a note is judged on and off (`chi --mute model_notes_<name>`) for the same kind of
89
+ task: see [memory.md](memory.md) ("Model notes").
@@ -152,14 +152,13 @@ module Samagotchi
152
152
  when :tool_call_started
153
153
  @iteration = event[:iteration]
154
154
  @calls = event[:call_index].to_i
155
- when :turn_failed
155
+ # The turns that stay in the conversation (a failed one whose work
156
+ # stayed too); a failed one rolled back leaves none.
157
+ when *Events::TURN_END
156
158
  @running = false
157
- settle_during(after_turn: false)
158
- # The turns that stay in the conversation.
159
- when *Events::TURN_KEPT
160
- @running = false
161
- @turns_done += 1
162
- settle_during(after_turn: true)
159
+ kept = Events.kept_turn?(event)
160
+ @turns_done += 1 if kept
161
+ settle_during(after_turn: kept)
163
162
  when :card then add_card(event)
164
163
  when :hook_notice then add_notice(event)
165
164
  # What failed to load (guardrails, plugins): before the first turn
@@ -71,6 +71,29 @@ rules:
71
71
  scopes: [once]
72
72
  reason: removes a memory (it goes to the bundle trash, which chi bundle trash lists)
73
73
 
74
+ # The identity memory and the model notes (model_notes_<name>,
75
+ # docs/memory.md), with their overlays, go into the system prompt of every
76
+ # later session (a note on the models it names), and a model overlay
77
+ # (memory_write current_model_only: true) is appended to its memory for
78
+ # this model: a model, maybe steered by text it read, would be writing its
79
+ # own system prompt. Every such write asks, once at a time, and only the
80
+ # user may allow it. memory: prompt is memory_write, write or edit of such a
81
+ # file right in a memories folder, resolved (symlinks, .., and any case of
82
+ # the name, as a case-insensitive disk reads it); a shell command naming
83
+ # chi's config dir asks under shell-touches-chi.
84
+ - id: prompt-memory-write
85
+ tool: [memory_write, write, edit]
86
+ memory: prompt
87
+ verdict: ask
88
+ scopes: [once]
89
+ reason: writes a memory that goes into the system prompt of later sessions (identity, a model note, or an overlay of one)
90
+ - id: model-overlay-write
91
+ tool: memory_write
92
+ memory: overlay
93
+ verdict: ask
94
+ scopes: [once]
95
+ reason: writes a model overlay, guidance added to that memory for this model in later sessions
96
+
74
97
  # ── running code from the network ──────────────────────────────────────
75
98
  - id: pipe-to-shell
76
99
  tool: shell
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.8.0
3
+ version: 0.9.0
4
4
  scope: system
5
- description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer, removing a memory; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
5
+ description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer, removing a memory, writing a memory that reaches the system prompt (identity, model notes) or a model overlay; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
7
7
  # models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0;
8
- # a rule's memory: key (memory-remove) needs 0.36.0: an older chi refuses the rule set (unknown key), so every
9
- # guarded call would be refused
10
- requires_chi: ">= 0.36.0"
8
+ # a rule's memory: key (memory-remove) needs 0.36.0, and its overlay and prompt values (model-overlay-write,
9
+ # prompt-memory-write) need 0.41.0: an older chi refuses the rule set, so every guarded call would be refused
10
+ requires_chi: ">= 0.41.0"
11
11
  files:
12
12
  guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
@@ -18,7 +18,7 @@ Single registry `Samagotchi::Config` (`Config::ENTRIES` in `lib/samagotchi/confi
18
18
 
19
19
  Sections forbid `_`/`-` (`SECTION_RE` `/\A[a-z0-9]+\z/`); leaves keep `snake_case` in YAML (`base_url`) and become kebab in CLI (`base-url`) via registry derivation — no generic string split, registry lookup avoids flat vs nested collision.
20
20
 
21
- **Universal entries** (`expose: [:env,:config,:cli]`): `default.model`, `server.host/port/transport/open_timeout/read_timeout`, `server.first_token_timeout` (env and config only), `recap.model/base_url/host_ref/inactivity/timeout/min_user_turns/sentences`, `session.retention_days/max_count/keep_status/sweep_interval_hours/idle_exit_minutes`, `session.shared/keep_empty/max_children` (env and config only), `log.file/disable`, `status.line`, `context.status/window_tokens/chars_per_token/status_thresholds/status_cadence`, `thinking.turn_preamble`, `default.max_tokens`, `max_tool_output_chars`, `retry.max/base_delay/max_delay`, `read.*`, `execute.*`, `web.port/host`, `no_interrupt`, `no_default_input` etc. (`Config::ENTRIES`; `expose` says which of env/config/cli each takes). Precedence is `CLI > ENV > file > default`.
21
+ **Universal entries** (`expose: [:env,:config,:cli]`): `default.model`, `server.host/port/transport/open_timeout/read_timeout`, `server.first_token_timeout` (env and config only), `recap.model/base_url/host_ref/inactivity/timeout/min_user_turns/sentences`, `session.retention_days/max_count/keep_status/sweep_interval_hours/idle_exit_minutes`, `session.shared/keep_empty/max_children` (env and config only), `log.file/disable`, `status.line`, `context.status/window_tokens/chars_per_token/status_thresholds/status_cadence`, `memory.index_warn_tokens`, `thinking.turn_preamble`, `default.max_tokens`, `max_tool_output_chars`, `retry.max/base_delay/max_delay`, `read.*`, `execute.*`, `web.port/host`, `no_interrupt`, `no_default_input` etc. (`Config::ENTRIES`; `expose` says which of env/config/cli each takes). Precedence is `CLI > ENV > file > default`.
22
22
 
23
23
  Example `config.yml` (new nested form, preferred):
24
24
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.40.0
3
+ version: 0.42.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:78969929094d2c4f3530529cd54c5c37ce6b13b1c3867a318d9ce07e5b77cd73
9
- config_modification_protocol.md: sha256:648b19b1b9f7ca35fccdd69fee4c9886cb8cfb3748ae86affef7457c12164399
10
- memory_guide.md: sha256:f7049304a333be842b761037bab5c8e31e890b8f4ad12f092b14ee8deb053c51
8
+ self_map.md: sha256:9d1b2ff7ff5f237c1b1beed2eb8ecc3069570705a2c1763e533dd15829ff309d
9
+ config_modification_protocol.md: sha256:2d4135ce4fafcd391d1782dc3a54f7cbf10cd35ebe9f004b2d6cb256517a8451
10
+ memory_guide.md: sha256:15cd3b24eda853cc458371c6c75809a2d4b5352311d075aaa4384926b5221c27
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -47,6 +47,19 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
47
47
  **Placeholders:**
48
48
  - Content may contain placeholder hints written as double-curly braces around a name (e.g., test_command, language). Detected by `Placeholder` (`Placeholder::PLACEHOLDER_RE`) — install warns but does not fail. Fill them when you write. The placeholder syntax is two opening braces, a name, two closing braces.
49
49
 
50
+ ## Model notes
51
+
52
+ A model note is a memory named `model_notes_<name>` whose first line says which models it is for: `models: ` then `|`-separated globs on the model id or the model key (the `Model:` line names both), or `small`. It goes into the system prompt of every later session on a matching model, after the identity, so it holds habits of a model (or a family), not of a task.
53
+
54
+ ```markdown
55
+ models: deepseek-*|*deepseek-v4*
56
+ - Explore briefly, then act.
57
+ ```
58
+
59
+ - Write one only when the user asks for guidance for a model; it changes that model's prompt for every later session. The guardrails ask the user first, as they do for a model overlay.
60
+ - No dot in the name (`model_notes_qwen3-6`, not `model_notes_qwen3.6`), and the `models:` line first: `memory_write` refuses either.
61
+ - It takes effect at the next session start, resume or `/model`, not in the running session.
62
+
50
63
  ## Skills
51
64
 
52
65
  A skill is a memory named `skill_<name>` (`skill_release`, `skill_deploy_staging`) that holds the steps of a repeatable task done with the user. Next time, follow it; when a step turned out different, fix it in the same turn.
@@ -77,6 +77,8 @@
77
77
  - Installed gem files and bundled memories (`identity`, `memory_guide`,
78
78
  `config_modification_protocol`, `self_map`, `delegated`) are managed: don't edit them
79
79
  (local edits conflict on upgrade).
80
- Put notes in my own memories. A model-only note is an overlay on an existing
81
- entry (`current_model_only: true`), and it needs that base entry to exist.
80
+ Put notes in my own memories. Guidance for some models only is a model note
81
+ (`model_notes_<name>`, first line `models: <glob>`; memory_guide); a model-only
82
+ addition to one memory is an overlay on it (`current_model_only: true`), and it
83
+ needs that base entry to exist.
82
84
  - Config edits: follow `config_modification_protocol`.
@@ -110,9 +110,13 @@ module Samagotchi
110
110
  # thread, the drain inside them).
111
111
  #
112
112
  # A turn takes reports (#take, at each boundary) and the worker settles
113
- # them as the turn ends: #commit when it is kept (completed or canceled):
114
- # the cursors move on (DelegateCursors) and the rings go; #release when it
115
- # failed: the next turn reads the same rings again.
113
+ # them as the turn ends: #commit when the turn stays in the conversation
114
+ # (completed, canceled, or failed but kept: its work stayed, or a reminder
115
+ # or continue turn's): the cursors move on (DelegateCursors) and the rings
116
+ # go; #release when its end rolled it back (a failed turn that got
117
+ # nowhere, a canceled continue turn): the next turn reads the same rings
118
+ # again. So the model gets each report once (a later !rollback that
119
+ # erases a kept turn drops what it read, as before).
116
120
  class ChildReports
117
121
  SOURCE = Steer::DELEGATE_REPORT
118
122
 
@@ -178,7 +182,7 @@ module Samagotchi
178
182
  @taken = {}
179
183
  end
180
184
 
181
- # The turn failed (rolled back): its rings stay for the next one.
185
+ # The turn was rolled back: its rings stay for the next one.
182
186
  def release
183
187
  @taken = {}
184
188
  end
@@ -146,6 +146,9 @@ module Samagotchi
146
146
  Entry.new(key: "context.status", yaml_path: %w[context status], type: :bool, default: true, expose: %i[env config cli]),
147
147
  Entry.new(key: "context.window_tokens", yaml_path: %w[context window_tokens], type: :integer, default: nil, expose: %i[env config cli]),
148
148
  Entry.new(key: "context.chars_per_token", yaml_path: %w[context chars_per_token], type: :float, default: 4.0, expose: %i[env config cli]),
149
+ # The tokens one scope's memory index (sent with every prompt) may hold before a write that crosses it gets a note
150
+ # (MemoryBundle::IndexSize); 0: no note. `memory:` is its own section, not the `memories:` preload list.
151
+ Entry.new(key: "memory.index_warn_tokens", yaml_path: %w[memory index_warn_tokens], type: :integer, default: 2500, expose: %i[env config cli]),
149
152
  Entry.new(key: "context.status_thresholds", yaml_path: %w[context status_thresholds], type: :string, default: "20,40,60,80", expose: %i[env config cli]),
150
153
  Entry.new(key: "context.status_cadence", yaml_path: %w[context status_cadence], type: :integer, default: 0, expose: %i[env config cli]),
151
154
  # Attached context (ContextSources): how often a source's command runs when it names no --every (seconds; less
@@ -75,7 +75,9 @@ module Samagotchi
75
75
  end
76
76
 
77
77
  # @param argv [Array<String>] the arguments after "context"
78
- def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, env: ENV, cwd: Dir.pwd)
78
+ # @param clock [#call] now, for ls's ages ("4s ago")
79
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, env: ENV, cwd: Dir.pwd,
80
+ clock: -> { Time.now })
79
81
  @argv = argv.dup
80
82
  @stdin = stdin
81
83
  @stdout = stdout
@@ -83,6 +85,7 @@ module Samagotchi
83
85
  @state_dir = state_dir || Session.default_state_dir
84
86
  @env = env
85
87
  @cwd = cwd
88
+ @clock = clock
86
89
  end
87
90
 
88
91
  # @return [Integer] exit status: 0 done, 1 refused or failed, 2 usage
@@ -437,7 +440,7 @@ module Samagotchi
437
440
  def age(iso)
438
441
  return "-" unless iso
439
442
 
440
- seconds = (Time.now - Time.iso8601(iso)).to_i
443
+ seconds = (@clock.call - Time.iso8601(iso)).to_i
441
444
  if seconds < 60 then "#{seconds}s ago"
442
445
  elsif seconds < 3600 then "#{seconds / 60}m ago"
443
446
  elsif seconds < 86_400 then "#{seconds / 3600}h ago"
@@ -53,6 +53,8 @@ require_relative "steer_cut"
53
53
  require_relative "engine_peers"
54
54
  require_relative "bundle_needs"
55
55
  require_relative "model_overlay"
56
+ require_relative "model_notes"
57
+ require_relative "prompt_note"
56
58
  require_relative "served_model"
57
59
  require_relative "image_store"
58
60
  require_relative "vision_context"
@@ -256,7 +258,8 @@ module Samagotchi
256
258
  Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
257
259
  @resume_session = session_id ? Session.load(session_id) : nil
258
260
  @prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
259
- thinking: -> { turn_thinking }, model: -> { model_identity }, memories: memories,
261
+ thinking: -> { turn_thinking }, model: -> { model_identity },
262
+ model_notes: -> { model_notes }, memories: memories,
260
263
  muted_memory_names: @muted_memory_names)
261
264
  @session = nil
262
265
  @session_observer = SessionObserver.new
@@ -468,6 +471,34 @@ module Samagotchi
468
471
  served: prompt_served_model(target), fallback_key: @fallback_model_key)
469
472
  end
470
473
 
474
+ # The model notes for the effective model (ModelNotes): matched on its
475
+ # bare id and key, the session's mutes left out.
476
+ # @return [Array<ModelNotes::Note>]
477
+ def model_notes
478
+ ModelNotes.for(name: guardrail_model_name, key: @model_key, fallback_key: @fallback_model_key,
479
+ muted: @muted_memory_names)
480
+ end
481
+
482
+ # The model notes the session's prompt carried at its last build
483
+ # (Session#prompt_notes): a resumed session's or a woken worker's saved
484
+ # ones until this process builds its prompt; without a session, the
485
+ # notes the effective model's prompt would load.
486
+ # @return [Array<PromptNote>]
487
+ def prompt_notes
488
+ return @session.prompt_notes.dup if @session
489
+
490
+ model_notes.map { |note| PromptNote.from_note(note) }
491
+ rescue StandardError
492
+ []
493
+ end
494
+
495
+ # Records +notes+ (ModelNotes::Note) on the session as the ones its
496
+ # prompt carries; saved with it.
497
+ def record_prompt_notes(notes)
498
+ @session.prompt_notes = Array(notes).map { |note| PromptNote.from_note(note) } if @session && notes
499
+ end
500
+ private :record_prompt_notes
501
+
471
502
  # +name+'s resolved ref (ModelRef#ref).
472
503
  def model_ref_for(name)
473
504
  @host_registry.model_ref(name).ref
@@ -846,6 +877,9 @@ module Samagotchi
846
877
  @profile_resolution = nil
847
878
  sync_model_key!
848
879
  @prompt_builder.reset!
880
+ # The notes the new model's prompt loads at its next build, so /model,
881
+ # /stats and the web name them before the next turn does.
882
+ record_prompt_notes(model_notes) if @session
849
883
  sync_kernel_client!
850
884
  @client.invalidate_context_window!
851
885
  @metrics.forget_model_reports!
@@ -911,7 +945,8 @@ module Samagotchi
911
945
  snapshot = @metrics.snapshot
912
946
  target = @host_registry.resolve(@effective_model_name)
913
947
  served, served_for = served_model_for(snapshot, target: target)
914
- snapshot = snapshot.merge(served_model: served, served_model_for: served_for)
948
+ snapshot = snapshot.merge(served_model: served, served_model_for: served_for, llm_context: llm_context_summary,
949
+ prompt_notes: prompt_notes.map(&:to_h))
915
950
  unless snapshot.dig(:context, :window_tokens)
916
951
  window = current_context_window(target)
917
952
  if window
@@ -940,6 +975,39 @@ module Samagotchi
940
975
  .display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
941
976
  end
942
977
 
978
+ # #turn_llm_context with where each value came from
979
+ # (LLMContextStrategy.explain), nil when it can't be resolved.
980
+ # @return [LLMContextStrategy::Explained, nil]
981
+ def llm_context_explained
982
+ target = @host_registry.resolve(@effective_model_name)
983
+ LLMContextStrategy.explain(target, names: model_lookup_names(target), **llm_context_session_args)
984
+ rescue StandardError
985
+ nil
986
+ end
987
+
988
+ # #llm_context_explained's summary (Explained#summary), nil when it
989
+ # can't be resolved: /stats, the snapshot, the web's info bar.
990
+ # @return [Hash, nil]
991
+ def llm_context_summary
992
+ llm_context_explained&.summary(llm_context_override)
993
+ end
994
+
995
+ # The session's own llm_context values, nil when it has none.
996
+ # @return [LLMContextOverride, nil]
997
+ def llm_context_override = @session&.llm_context
998
+
999
+ # Set the session's own llm_context values (an empty override unsets
1000
+ # them); the next turn's start resolves them, never a running one (its
1001
+ # TurnSettings are fixed). The caller saves the session.
1002
+ # @raise [ArgumentError] with no session yet
1003
+ def llm_context_override=(override)
1004
+ raise ArgumentError, "no session yet" unless @session
1005
+
1006
+ @session.llm_context = override
1007
+ # The status a later client reads counts against the new budget.
1008
+ @last_context_status = saved_context_status(@metrics.snapshot[:context]) || @last_context_status if @last_context_status
1009
+ end
1010
+
943
1011
  # The effective model is on a chat host (api: openai), whose loop uses
944
1012
  # no prompt profile.
945
1013
  def chat_model? = @host_registry.resolve(@effective_model_name).entry.chat?
@@ -985,6 +1053,8 @@ module Samagotchi
985
1053
  # :metrics [Hash] @metrics.snapshot (per-session analytics)
986
1054
  # :pending_question [Hash, nil] current pending structured question
987
1055
  # :used_memory_names [Array<String>] deduped memory names active this session
1056
+ # :prompt_notes [Array<Hash>] the model notes the prompt carried
1057
+ # ({name:, scope:, chars:, digest:}; #prompt_notes)
988
1058
  # :parent_id [String, nil] the session that delegated this one
989
1059
  # :model_name [String] the model turns run on now (after /model)
990
1060
  # :served_model, :served_model_for [String, nil] what a generation of
@@ -1005,11 +1075,13 @@ module Samagotchi
1005
1075
  used_memory_names: @used_memories.names,
1006
1076
  preloaded_memory_names: preloaded_memory_names,
1007
1077
  muted_memory_names: @muted_memory_names.dup,
1078
+ prompt_notes: prompt_notes.map(&:to_h),
1008
1079
  parent_id: @session&.parent_id,
1009
1080
  model_name: effective_model_ref,
1010
1081
  served_model: served_pair[0],
1011
1082
  served_model_for: served_pair[1],
1012
1083
  context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
1084
+ llm_context: llm_context_summary,
1013
1085
  recap_enabled: !@recap.nil?,
1014
1086
  recap_min_user_turns: @recap&.min_user_turns,
1015
1087
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1158,7 +1230,9 @@ module Samagotchi
1158
1230
  # cancel's reason and the hook that stopped it. Only what is known.
1159
1231
  def stop_facts(event)
1160
1232
  case event&.dig(:type)
1161
- when :turn_failed then { "error_kind" => event[:error_kind]&.to_s, "retryable" => event[:retryable] }.compact
1233
+ # kept_steps: the turn's work stayed (a parent must not send the task again).
1234
+ when :turn_failed
1235
+ { "error_kind" => event[:error_kind]&.to_s, "retryable" => event[:retryable], "kept_steps" => event[:kept_steps] }.compact
1162
1236
  when :turn_canceled
1163
1237
  { "cancel_reason" => event[:cancellation_reason]&.to_s, "stopped_by" => event[:cancelled_by]&.to_s }.compact
1164
1238
  else {}
@@ -1413,14 +1487,19 @@ module Samagotchi
1413
1487
  target ||= @host_registry.resolve(@effective_model_name)
1414
1488
  chat = target.entry.chat?
1415
1489
  level = chat ? nil : thinking_level(target)
1416
- @prompt_builder.build(chat: chat, thinking: level, layers: llm_context_layers(target))
1490
+ prompt = @prompt_builder.build(chat: chat, thinking: level, layers: llm_context_layers(target))
1491
+ # The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
1492
+ @metrics.memory_index = @prompt_builder.memory_index
1493
+ # The model notes it carries: the session records them (/stats, /model, the web).
1494
+ record_prompt_notes(@prompt_builder.prompt_notes)
1495
+ prompt
1417
1496
  end
1418
1497
 
1419
1498
  # The LLM context layers +target+'s turns run under: the native prompt
1420
1499
  # declares the tools they add (forget_outputs under forget), so a
1421
1500
  # model switch may change the tool list. [] under none.
1422
1501
  def llm_context_layers(target)
1423
- LLMContextStrategy.resolve(target, names: model_lookup_names(target)).active_layers
1502
+ LLMContextStrategy.resolve(target, names: model_lookup_names(target), **llm_context_session_args).active_layers
1424
1503
  rescue StandardError
1425
1504
  []
1426
1505
  end
@@ -1832,8 +1911,10 @@ module Samagotchi
1832
1911
  # The turn's settings, set once here. The chat loop dispatches tools
1833
1912
  # through the kernel without its #run: tag those dumps with this
1834
1913
  # turn's model, not the last native one.
1914
+ explained = llm_context_explained
1835
1915
  @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting,
1836
- llm_context: turn_llm_context)
1916
+ llm_context: explained&.resolved)
1917
+ log_llm_context(explained)
1837
1918
 
1838
1919
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
1839
1920
  backend.complete(
@@ -1848,6 +1929,18 @@ module Samagotchi
1848
1929
  end
1849
1930
  private :generate
1850
1931
 
1932
+ # The turn's LLM context strategy and where it came from, in the turn
1933
+ # log: when it runs a layer, or the session set it (none too).
1934
+ def log_llm_context(explained)
1935
+ return unless explained
1936
+ return if explained.resolved.active_layers.empty? && explained.strategy.source != :session
1937
+
1938
+ summary = explained.summary
1939
+ Log.info(:turn, "llm_context", **summary.slice(:strategy, :strategy_source, :apply, :apply_source, :budget_tokens,
1940
+ :budget_source).compact)
1941
+ end
1942
+ private :log_llm_context
1943
+
1851
1944
  def publish_used_memories(session, on_event)
1852
1945
  # Persist deduped used memories onto the session for Web + reload.
1853
1946
  begin
@@ -1948,20 +2041,30 @@ module Samagotchi
1948
2041
 
1949
2042
  # A failure keeps the loop's partial conversation (else the turn's
1950
2043
  # messages so far) with a failed note; nil when the turn never reached
1951
- # the model. The model reads why on its next turn (a UI that rolls the
1952
- # turn back leaves its own note, TurnFlow#prompt_turn_failed).
2044
+ # the model. The model reads why on its next turn. A turn that got to
2045
+ # tool results (LLM::FailedTurn.progress) says so in the note; on the
2046
+ # error (kept_steps) too unless it failed in a way that rolls back
2047
+ # (FailedTurn.keeps_work?: a context overflow). The UIs keep a marked
2048
+ # turn (TurnFlow#prompt_turn_failed), and roll back the others, with a
2049
+ # note of their own; -p --non-interactive keeps what this keeps.
1953
2050
  def failed_messages(turn, error)
1954
2051
  kept = error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array) ? error.partial_conversation : turn.messages
1955
2052
  return nil unless kept
1956
2053
 
2054
+ steps = LLM::FailedTurn.progress(turn.session.messages, kept)
2055
+ error.kept_steps = steps if steps && error.is_a?(LLM::FailedTurn) && LLM::FailedTurn.keeps_work?(error)
1957
2056
  summary = error.respond_to?(:summary) ? error.summary : error.message
1958
- TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue))
2057
+ TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue, steps: steps))
1959
2058
  end
1960
2059
  private :failed_messages
1961
2060
 
2061
+ # Called after #failed_messages, which marks the error's kept steps.
1962
2062
  def failed_event(error, seconds)
1963
2063
  failed = { type: :turn_failed, error_class: error.class.name, message: error.message,
1964
2064
  duration_ms: (seconds * 1000).round }
2065
+ # Its work stays: the UIs say so, as after a cancel.
2066
+ steps = LLM::FailedTurn.kept_steps(error)
2067
+ failed[:kept_steps] = steps if steps
1965
2068
  # A provider error says what kind it is, for one line per kind in the UIs.
1966
2069
  if error.is_a?(LLM::ProviderError)
1967
2070
  failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: error.summary)
@@ -2345,7 +2448,8 @@ module Samagotchi
2345
2448
  model_name: -> { effective_model_ref },
2346
2449
  model_key: -> { @model_key },
2347
2450
  state_dir: -> { session_state_dir },
2348
- scratch: -> { @scratch }
2451
+ scratch: -> { @scratch },
2452
+ llm_context: -> { @session&.llm_context }
2349
2453
  )
2350
2454
  end
2351
2455
 
@@ -2529,16 +2633,17 @@ module Samagotchi
2529
2633
  nil
2530
2634
  end
2531
2635
 
2532
- # The effective model's LLM context strategy (models:/hosts:
2533
- # llm_context_strategy, else llm_context.strategy), read per turn as
2636
+ # The LLM context strategy the next turn runs under: the session's own
2637
+ # (Session#llm_context), else the effective model's (models:/hosts:
2638
+ # llm_context_strategy), else llm_context.strategy; read per turn as
2534
2639
  # the window is.
2535
2640
  def turn_llm_context
2536
- target = @host_registry.resolve(@effective_model_name)
2537
- LLMContextStrategy.resolve(target, names: model_lookup_names(target))
2538
- rescue StandardError
2539
- nil
2641
+ llm_context_explained&.resolved
2540
2642
  end
2541
2643
 
2644
+ def llm_context_session_args = @session&.llm_context&.resolve_args || {}
2645
+ private :llm_context_session_args
2646
+
2542
2647
  # See #served_model. A report for another name (before a /model switch)
2543
2648
  # doesn't count. Without a target, no probe. A remote chat host has no
2544
2649
  # /props: nil until a turn.
@@ -9,7 +9,8 @@ module Samagotchi
9
9
  # A turn is over: it completed, was canceled, or failed.
10
10
  TURN_END = %i[turn_completed turn_canceled turn_failed].freeze
11
11
  # Turn ends that leave the turn in the conversation: a failed turn's
12
- # prompt goes back to the composer instead.
12
+ # prompt goes back to the composer instead, unless its work stayed
13
+ # (#kept_turn?).
13
14
  TURN_KEPT = %i[turn_completed turn_canceled].freeze
14
15
  # The kinds of a running turn's parts (Bridge::TurnAccumulator), which a
15
16
  # joining UI replays as live events.
@@ -19,5 +20,11 @@ module Samagotchi
19
20
  # turn, turn_events.js snapshotEvents).
20
21
  STREAM_FRAMES = %i[snapshot reset stream_closed].freeze
21
22
  SYNTHETIC = %i[merged_input].freeze
23
+
24
+ # Whether +event+ ends a turn that stays in the conversation: completed,
25
+ # canceled, or failed with its work kept (turn_failed's kept_steps).
26
+ def self.kept_turn?(event)
27
+ TURN_KEPT.include?(event[:type]) || (event[:type] == :turn_failed && !event[:kept_steps].nil?)
28
+ end
22
29
  end
23
30
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../config"
4
+ require_relative "../model_match"
4
5
 
5
6
  module Samagotchi
6
7
  module Guardrails
@@ -15,7 +16,6 @@ module Samagotchi
15
16
  SETTING = "guardrails.small_models"
16
17
  # The largest auto-small size, in billions of parameters.
17
18
  MAX_SMALL = 32
18
- GLOB_FLAGS = File::FNM_CASEFOLD | File::FNM_EXTGLOB
19
19
 
20
20
  # A size starts at the name's start or after a separator, and ends at
21
21
  # a separator or the name's end (so 4bit and v4.1 aren't sizes).
@@ -53,12 +53,11 @@ module Samagotchi
53
53
  def small?(name, key, setting = self.setting)
54
54
  return false if name.nil? || name.to_s.strip.empty?
55
55
 
56
- entries = (setting.nil? ? "auto" : setting.to_s).split("|").map(&:strip).reject(&:empty?)
57
- entries.any? do |entry|
56
+ ModelMatch.parse(setting.nil? ? "auto" : setting.to_s).any? do |entry|
58
57
  if entry.casecmp?("auto")
59
58
  (size = billions(name)) ? size <= MAX_SMALL : false
60
59
  else
61
- [name, key].compact.any? { |candidate| File.fnmatch(entry, candidate.to_s, GLOB_FLAGS) }
60
+ ModelMatch.glob?(entry, name: name, key: key)
62
61
  end
63
62
  end
64
63
  end