samagotchi 0.54.0 → 0.56.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +6 -2
  5. data/docs/cli.md +29 -7
  6. data/docs/configuration.md +113 -8
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/context-telemetry.md +23 -5
  9. data/docs/internals/engine.md +54 -18
  10. data/docs/internals/prompt-caching.md +8 -0
  11. data/docs/sessions.md +2 -2
  12. data/docs/testing.md +23 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/cache_confirm.rb +51 -0
  17. data/lib/samagotchi/client_id.rb +12 -0
  18. data/lib/samagotchi/config.rb +9 -3
  19. data/lib/samagotchi/engine.rb +102 -8
  20. data/lib/samagotchi/engine_peers.rb +1 -0
  21. data/lib/samagotchi/file_ref.rb +59 -0
  22. data/lib/samagotchi/host_config.rb +6 -3
  23. data/lib/samagotchi/host_registry.rb +4 -3
  24. data/lib/samagotchi/kernel_loop.rb +99 -16
  25. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  26. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  27. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  28. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  29. data/lib/samagotchi/model_settings.rb +5 -2
  30. data/lib/samagotchi/reasoning_history.rb +132 -0
  31. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  32. data/lib/samagotchi/self_report.rb +21 -0
  33. data/lib/samagotchi/session.rb +23 -2
  34. data/lib/samagotchi/session_commands.rb +55 -15
  35. data/lib/samagotchi/session_manager.rb +2 -1
  36. data/lib/samagotchi/session_setup.rb +7 -5
  37. data/lib/samagotchi/setting_explained.rb +20 -0
  38. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
  40. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  41. data/lib/samagotchi/terminal_ui.rb +13 -6
  42. data/lib/samagotchi/thinking.rb +20 -25
  43. data/lib/samagotchi/thinking_command.rb +33 -11
  44. data/lib/samagotchi/tool_row_fields.rb +46 -0
  45. data/lib/samagotchi/tool_runner.rb +7 -11
  46. data/lib/samagotchi/tools/builtins.rb +6 -2
  47. data/lib/samagotchi/tools/peers.rb +4 -2
  48. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +48 -4
  51. data/lib/samagotchi/web/capabilities.rb +13 -9
  52. data/lib/samagotchi/web/editor.rb +38 -0
  53. data/lib/samagotchi/web/message_parts.rb +5 -10
  54. data/lib/samagotchi/web/public/activity.js +33 -18
  55. data/lib/samagotchi/web/public/app.js +256 -1133
  56. data/lib/samagotchi/web/public/commands.js +269 -0
  57. data/lib/samagotchi/web/public/composer.js +105 -0
  58. data/lib/samagotchi/web/public/diff_view.js +30 -4
  59. data/lib/samagotchi/web/public/index.html +11 -3
  60. data/lib/samagotchi/web/public/info_bar.js +80 -0
  61. data/lib/samagotchi/web/public/level_chip.js +174 -0
  62. data/lib/samagotchi/web/public/lists_view.js +725 -0
  63. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  64. data/lib/samagotchi/web/public/model_pick.js +1 -1
  65. data/lib/samagotchi/web/public/question_cards.js +2 -1
  66. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  67. data/lib/samagotchi/web/public/refs.js +134 -0
  68. data/lib/samagotchi/web/public/restore.js +81 -0
  69. data/lib/samagotchi/web/public/session_state.js +57 -0
  70. data/lib/samagotchi/web/public/stream_handlers.js +332 -0
  71. data/lib/samagotchi/web/public/thinking_chip.js +38 -144
  72. data/lib/samagotchi/web/public/turn_events.js +17 -258
  73. data/lib/samagotchi/web/public/turn_html.js +5 -1
  74. data/lib/samagotchi/web/server.rb +4 -4
  75. data/lib/samagotchi/worker.rb +17 -5
  76. metadata +18 -2
  77. data/lib/samagotchi/web/public/stable_html.js +0 -18
data/docs/sessions.md CHANGED
@@ -68,9 +68,9 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
68
68
  **Ordering:**
69
69
 
70
70
  - `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.
71
- - 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 (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), 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 (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); 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, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `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). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). 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 model picked; 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).
71
+ - 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). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). 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. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). 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 (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), 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 (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); 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, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `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). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). 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 model picked; 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).
72
72
  - 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).
73
- - 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), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `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`).
73
+ - 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), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `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), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, the actions per kind, one delegated click). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
74
74
  - 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).
75
75
 
76
76
  **Test-session hygiene:**
data/docs/testing.md CHANGED
@@ -15,12 +15,32 @@ bug) or a broken spec: fix it, or disable that line with the reason (`# rubocop:
15
15
  No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
16
16
  clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
17
17
 
18
- The web specs run in plain node. One that renders with lit-html (`turn_view.js`, `thinking_chip.js`,
19
- `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
20
- never shipped; about 0.3 s per spec file that loads it). The shim's `htmlOf(nodes)` and `canon(html)` compare markup
18
+ The web specs run in plain node. One that loads lit-html (`turn_view.js`, `thinking_chip.js`, `llm_context_chip.js`,
19
+ `info_bar.js`, `lists_view.js`, `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a
20
+ devDependency only, never shipped; about 0.3 s per spec file that loads it). The shim's `htmlOf(nodes)` and `canon(html)` compare markup
21
21
  without lit's marker comments. happy-dom 20 picks the wrong option of a `<select>` whose options lit inserted with
22
22
  `?selected`: read the option marked `selected` (`option[selected]`), not `select.value`; the browser follows the mark.
23
23
 
24
+ ## While you work: affected specs
25
+
26
+ ```sh
27
+ script/affected-specs # the specs for this branch's change, best first, at most 10
28
+ bundle exec rspec $(script/affected-specs)
29
+ script/affected-specs --why # with the reason for each pick; --all: no cap; --run: runs them
30
+ script/affected-specs A..B # another range (A...B: from their merge-base); a single REV: vs the working tree
31
+ ```
32
+
33
+ A builder runs these while working, not the full suite: the specs the change touched, the specs named after the
34
+ files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…), the specs that read
35
+ docs or source as text (its `RULES`: add one when a full run catches what the names missed), then the specs that
36
+ name a changed file's class. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
37
+ The Ruby specs go to stdout; the node tests to run (`npm test` targets), "web changed: also run npm run e2e" and a
38
+ hint for a hub class (named in too many specs to list) go to stderr. Nothing printed means nothing matched.
39
+
40
+ It finds a change's own specs, not the regressions it causes elsewhere. So before committing, run the full suite
41
+ (`bundle exec parallel_rspec -n 8`), unless your task says the coordinator runs it; the coordinator runs it at merge
42
+ either way.
43
+
24
44
  ## Worktrees
25
45
 
26
46
  Each fix or feature gets its own worktree next to the main checkout:
@@ -3,6 +3,7 @@
3
3
  require_relative "../events"
4
4
  require_relative "../kernel_loop"
5
5
  require_relative "../steer"
6
+ require_relative "../tool_row_fields"
6
7
  require_relative "turn_notice"
7
8
 
8
9
  module Samagotchi
@@ -158,9 +159,10 @@ module Samagotchi
158
159
  def self.tool_replay_events(part)
159
160
  call = { iteration: part[:iteration], call_index: part[:call_index], tool: part[:tool] }
160
161
  call[:label] = part[:label] if part[:label]
161
- # Its title and, for an alias's call, the name the model used.
162
- title = part.slice(:title, :called_as).compact
163
- view = part[:view] ? { view: part[:view] } : {}
162
+ # Its row fields (ToolRowFields), each in its place: title and, for
163
+ # an alias's call, the name the model used, also in the activity.
164
+ title = part.slice(*ToolRowFields::ACTIVITY_KEYS).compact
165
+ view = part.slice(*ToolRowFields::EVENT_KEYS).compact
164
166
  events = [{ type: :tool_call_started, **call, params: part[:params], **title, **view }]
165
167
  if part[:status] == "running"
166
168
  events.first[:elapsed_ms] = part[:elapsed_ms] unless part[:elapsed_ms].nil?
@@ -275,9 +277,7 @@ module Samagotchi
275
277
  part = { kind: "tool", iteration: event[:iteration], call_index: event[:call_index],
276
278
  tool: event[:tool], params: event[:params], status: "running" }
277
279
  part[:label] = event[:label] if event[:label]
278
- part[:title] = event[:title] if event[:title]
279
- part[:called_as] = event[:called_as] if event[:called_as]
280
- part[:view] = event[:view] if event[:view]
280
+ ToolRowFields::KEYS.each { |key| part[key] = event[key] if event[key] }
281
281
  parts << part
282
282
  @tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
283
283
  when :tool_call_completed
@@ -58,7 +58,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
58
58
 
59
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, models}` (`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; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
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: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --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).
61
+ - `models:` map of model id or alias → `{profile, vision, sampling, thinking, reasoning_history}` (`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: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --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". `reasoning_history:` (here or on a host) is `drop|turn|all`, which of its reasoning a chat host's model gets back. Precedence: the session's own level (`/reasoning-history LEVEL`, saved in the session) > `SAMAGOTCHI_REASONING_HISTORY` > `models:` > `hosts.<name>.reasoning_history` > `turn`; `docs/configuration.md` "Reasoning history". `/thinking` and `/reasoning-history` ask before a change that restarts the prompt cache; `--yes` applies it (`chi send` commands apply directly). `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 `~`.
63
63
  - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
64
64
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.54.0
3
+ version: 0.56.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:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
- config_modification_protocol.md: sha256:45064ac6b19863e1646d15891d0e9cc7a8f10ab981a0d25d6e13891591538b8a
9
+ config_modification_protocol.md: sha256:d908921b5988244fb2a03fe04e693d0e7a1dfadf8cd77704e9232826db7eb2d7
10
10
  memory_guide.md: sha256:15cd3b24eda853cc458371c6c75809a2d4b5352311d075aaa4384926b5221c27
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The confirm step before a session setting change that restarts the
5
+ # prompt cache (/thinking, /reasoning-history): such a change, sent
6
+ # without --yes from a person at a web tab or a chi TUI, changes nothing
7
+ # and replies with what it costs and the line that applies it. Two steps,
8
+ # no stored state: the reply carries a Confirm (SessionCommands::Result,
9
+ # the worker's command_ran), the TUI prefills its line, the web draws an
10
+ # Apply button that sends it.
11
+ module CacheConfirm
12
+ YES_FLAGS = %w[--yes -y].freeze
13
+ # The costs worth asking about (Thinking.cache_cost,
14
+ # ReasoningHistory.cache_cost); :tail and nil are not.
15
+ COSTLY = %i[full provider history].freeze
16
+
17
+ # The line that applies a change, and the question the reply ends with.
18
+ Confirm = Data.define(:line, :text) do
19
+ # The plain form the worker's command_ran carries.
20
+ # @return [Hash] symbol keys
21
+ def to_h = { line: line, text: text }
22
+ end
23
+
24
+ module_function
25
+
26
+ # A command's argument words with --yes / -y (anywhere) taken out.
27
+ # @param args [String] what follows the command name
28
+ # @return [Array(String, Boolean)] the rest, stripped and downcased, and
29
+ # whether --yes was there
30
+ def split(args)
31
+ words = args.to_s.strip.downcase.split
32
+ yes = words.any? { |word| YES_FLAGS.include?(word) }
33
+ [(words - YES_FLAGS).join(" "), yes]
34
+ end
35
+
36
+ # Whether a change must ask first: the effective level changes, the
37
+ # cost is one of COSTLY, and the session has a model reply (a cache to
38
+ # restart at all).
39
+ def needed?(cost:, history:, changed:)
40
+ changed && history && COSTLY.include?(cost)
41
+ end
42
+
43
+ # The Confirm for +name+ set to +word+: the line with --yes, and the
44
+ # question naming it.
45
+ # @return [Confirm]
46
+ def confirm(name, word)
47
+ line = "#{name} #{word} --yes"
48
+ Confirm.new(line: line, text: "Nothing changed yet: send `#{line}` to apply.")
49
+ end
50
+ end
51
+ end
@@ -39,8 +39,20 @@ module Samagotchi
39
39
  HUMAN_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
40
40
  HUMAN_IDS = [CLI_SEND].freeze
41
41
 
42
+ # A person at a screen that shows a command's reply and can answer it
43
+ # (#interactive?): a web tab, an attached chi TUI.
44
+ INTERACTIVE_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
45
+
42
46
  module_function
43
47
 
48
+ # Whether +client_id+ is a web tab or a chi TUI, which see a command's
49
+ # reply and can send the line it asks for (CacheConfirm); chi send, a
50
+ # delegate, a plugin, chi itself and nil are not.
51
+ def interactive?(client_id)
52
+ id = client_id.to_s
53
+ INTERACTIVE_PREFIXES.any? { |prefix| id.start_with?(prefix) }
54
+ end
55
+
44
56
  # Whether +client_id+ (a turn's or a line's origin) is a human's input.
45
57
  def human?(client_id)
46
58
  return true if client_id.nil?
@@ -187,6 +187,10 @@ module Samagotchi
187
187
  # is bin/chi's own --thinking; models: and hosts: entries rank below
188
188
  # the flag and the env, above config.yml's value.
189
189
  Entry.new(key: "thinking.level", yaml_path: %w[thinking level], type: :string, default: "default", expose: %i[env config]),
190
+ # Which of its reasoning a chat host's model gets back (ReasoningHistory):
191
+ # the environment only, for A/B runs; models: and hosts: entries are
192
+ # the config surface.
193
+ Entry.new(key: "reasoning_history", yaml_path: %w[reasoning_history], type: :string, default: nil, expose: %i[env]),
190
194
 
191
195
  Entry.new(key: "default.max_tokens", yaml_path: %w[default max_tokens], type: :integer, default: nil, expose: %i[env config cli]),
192
196
  Entry.new(key: "max_tool_output_chars", yaml_path: %w[max_tool_output_chars], type: :integer, default: 10_000, expose: %i[env config cli]),
@@ -250,6 +254,8 @@ module Samagotchi
250
254
  Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn]),
251
255
  # Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
252
256
  Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli]),
257
+ # What a file ref (a tool row's file, a diff line) opens on this machine: a preset (vscode, vscode-insiders, cursor, zed, none) or a URL template with {path} and an optional {line} (Web::Editor).
258
+ Entry.new(key: "web.editor", yaml_path: %w[web editor], type: :string, default: "vscode", expose: %i[env config cli]),
253
259
 
254
260
  Entry.new(key: "no_interrupt", yaml_path: %w[no_interrupt], type: :bool, default: false, expose: %i[env config cli]),
255
261
  Entry.new(key: "no_default_input", yaml_path: %w[no_default_input], type: :bool, default: false, expose: %i[env config cli]),
@@ -267,9 +273,9 @@ module Samagotchi
267
273
  # Maps of named entries and the keys an entry may hold
268
274
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
269
275
  MAP_ENTRY_KEYS = {
270
- "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled
271
- remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
272
- "models" => %w[profile vision sampling thinking window_tokens llm_context_strategy llm_context_apply
276
+ "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking
277
+ reasoning_history enabled remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
278
+ "models" => %w[profile vision sampling thinking reasoning_history window_tokens llm_context_strategy llm_context_apply
273
279
  llm_context_budget_tokens].freeze
274
280
  }.freeze
275
281
  # The keys a hosts.<name>.models entry may hold (HostModel.parse_map).
@@ -62,6 +62,7 @@ require_relative "vision_context"
62
62
  require_relative "vision_support"
63
63
  require_relative "sampling_settings"
64
64
  require_relative "thinking"
65
+ require_relative "reasoning_history"
65
66
  require_relative "answer_display"
66
67
  require_relative "edit_preview"
67
68
  require_relative "question_desk"
@@ -1010,7 +1011,8 @@ module Samagotchi
1010
1011
  served, served_for = served_model_for(snapshot, target: target)
1011
1012
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
1012
1013
  served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
1013
- thinking: thinking_explained&.summary, prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
1014
+ thinking: thinking_explained&.summary,
1015
+ reasoning_history: reasoning_history_explained&.summary, prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
1014
1016
  unless snapshot.dig(:context, :window_tokens)
1015
1017
  window = current_context_window(target)
1016
1018
  if window
@@ -1093,16 +1095,80 @@ module Samagotchi
1093
1095
  end
1094
1096
 
1095
1097
  # The effective model's thinking level with where it came from and the
1096
- # session's own (Thinking::Explained), as the next turn resolves it;
1098
+ # session's own (SettingExplained), as the next turn resolves it;
1097
1099
  # nil when it can't be resolved.
1098
- # @return [Thinking::Explained, nil]
1099
- def thinking_explained
1100
+ # @return [SettingExplained, nil]
1101
+ # +own+: the session's own level to resolve with (the current one by
1102
+ # default; /thinking asks what a change would resolve to).
1103
+ def thinking_explained(own: thinking_override)
1100
1104
  target = @host_registry.resolve(@effective_model_name)
1101
- Thinking.explain(target, names: model_lookup_names(target), session: thinking_override)
1105
+ Thinking.explain(target, names: model_lookup_names(target), session: own)
1102
1106
  rescue StandardError
1103
1107
  nil
1104
1108
  end
1105
1109
 
1110
+ # "drop (models: qwen)" for /model: the effective chat model's
1111
+ # reasoning_history level and where it came from; nil when it is the
1112
+ # default, the model runs on the native loop (which ignores it) or it
1113
+ # can't be resolved.
1114
+ def reasoning_history_summary
1115
+ target = @host_registry.resolve(@effective_model_name)
1116
+ return nil unless target.entry&.chat?
1117
+
1118
+ level, source = ReasoningHistory.resolve(target, names: model_lookup_names(target),
1119
+ session: reasoning_history_override)
1120
+ source ? ReasoningHistory.label(level, source) : nil
1121
+ rescue StandardError
1122
+ nil
1123
+ end
1124
+
1125
+ # The effective chat model's reasoning_history level with where it came
1126
+ # from and the session's own (SettingExplained), as the next turn
1127
+ # resolves it; nil on a native host (the native loop ignores it; native:
1128
+ # true resolves it there too, for /reasoning-history) or when it can't
1129
+ # be resolved.
1130
+ # @return [SettingExplained, nil]
1131
+ # +own+: the session's own level to resolve with (the current one by
1132
+ # default; a command asks what a change would resolve to).
1133
+ def reasoning_history_explained(native: false, own: reasoning_history_override)
1134
+ target = @host_registry.resolve(@effective_model_name)
1135
+ return nil unless native || target.entry&.chat?
1136
+
1137
+ ReasoningHistory.explain(target, names: model_lookup_names(target), session: own)
1138
+ rescue StandardError
1139
+ nil
1140
+ end
1141
+
1142
+ # What a reasoning_history change from level +from+ to level +to+ costs
1143
+ # the effective model's prompt cache, given the session's conversation
1144
+ # (ReasoningHistory.cache_cost): :history or nil. A host ChatLoop
1145
+ # marked as refusing it may still get :history (one needless ask).
1146
+ # @return [Symbol, nil]
1147
+ def reasoning_history_cache_cost(from, to)
1148
+ target = @host_registry.resolve(@effective_model_name)
1149
+ ReasoningHistory.cache_cost(from, to, target: target, conversation: @session&.messages)
1150
+ rescue StandardError
1151
+ nil
1152
+ end
1153
+
1154
+ # Whether the session has a model reply (a cache to restart at all).
1155
+ def history?
1156
+ Array(@session&.messages).any? { |entry| entry[:role].to_s == "model" }
1157
+ end
1158
+
1159
+ # The session's own reasoning_history level, nil when it has none.
1160
+ # @return [Symbol, nil]
1161
+ def reasoning_history_override = @session&.reasoning_history
1162
+
1163
+ # Set the session's own reasoning_history level (nil unsets it); the
1164
+ # next turn's start resolves it. The caller saves the session.
1165
+ # @raise [ArgumentError] with no session yet
1166
+ def reasoning_history_override=(level)
1167
+ raise ArgumentError, "no session yet" unless @session
1168
+
1169
+ @session.reasoning_history = level
1170
+ end
1171
+
1106
1172
  # The session's own thinking level, nil when it has none.
1107
1173
  # @return [Symbol, nil]
1108
1174
  def thinking_override = @session&.thinking
@@ -1192,6 +1258,7 @@ module Samagotchi
1192
1258
  context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
1193
1259
  llm_context: llm_context_summary,
1194
1260
  thinking: thinking_explained&.summary,
1261
+ reasoning_history: reasoning_history_explained&.summary,
1195
1262
  recap_enabled: !@recap.nil?,
1196
1263
  recap_min_user_turns: @recap&.min_user_turns,
1197
1264
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1926,7 +1993,8 @@ module Samagotchi
1926
1993
  thinking_target = @host_registry.resolve(@effective_model_name)
1927
1994
  @turn_thinking = [thinking_level(thinking_target), thinking_target]
1928
1995
  announce_thinking_level(*@turn_thinking)
1929
- LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil,
1996
+ LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first,
1997
+ reasoning_history: reasoning_history_level(thinking_target), model_name: nil,
1930
1998
  window_setting: nil, llm_context: nil, price: nil)
1931
1999
  end
1932
2000
  private :turn_settings
@@ -2317,6 +2385,23 @@ module Samagotchi
2317
2385
  nil
2318
2386
  end
2319
2387
 
2388
+ # The host refused the model's reasoning sent back (reasoning_history)
2389
+ # and the chat loop asked the step again without it, as it will from
2390
+ # now on: logged, and said once per session and host.
2391
+ def reasoning_history_refused(event)
2392
+ _level, target = @turn_thinking
2393
+ return unless target
2394
+
2395
+ Log.warn(:model, "reasoning_history_refused", level: event[:level], host: target.entry.name, model: event[:model],
2396
+ detail: event[:detail])
2397
+ return unless (@thinking_notices ||= Set.new).add?([@session&.id, target.entry.name, :reasoning_history_refused])
2398
+
2399
+ hook_notify("#{target.entry.name} refused reasoning sent back (reasoning_history: #{event[:level]}) for " \
2400
+ "#{event[:model]} (#{event[:detail]}); sent without it from now on", :warn, "reasoning history")
2401
+ rescue StandardError
2402
+ nil
2403
+ end
2404
+
2320
2405
  def thinking_notice_once(kind, target, level, text)
2321
2406
  key = [@session&.id, target.entry.name, kind]
2322
2407
  return unless (@thinking_notices ||= Set.new).add?(key)
@@ -2324,6 +2409,14 @@ module Samagotchi
2324
2409
  hook_notify(text, level, "thinking")
2325
2410
  end
2326
2411
 
2412
+ # +target+'s ReasoningHistory level, the session's own first; the
2413
+ # default when it can't be resolved.
2414
+ def reasoning_history_level(target)
2415
+ ReasoningHistory.resolve(target, names: model_lookup_names(target), session: reasoning_history_override).first
2416
+ rescue StandardError
2417
+ ReasoningHistory::DEFAULT
2418
+ end
2419
+
2327
2420
  # +target+'s thinking level (Thinking.resolve), the session's own first.
2328
2421
  def thinking_level(target)
2329
2422
  Thinking.resolve(target, names: model_lookup_names(target), session: thinking_override).first
@@ -2602,9 +2695,10 @@ module Samagotchi
2602
2695
  end
2603
2696
  when :generation_chunk then note_generation_thought(progress[:thinking])
2604
2697
  end
2605
- # The chat loop asked again without the thinking fields: a notice,
2606
- # not an event of its own.
2698
+ # The chat loop asked again without the thinking fields or the
2699
+ # reasoning sent back: a notice, not an event of its own.
2607
2700
  next thinking_refused(event) if event[:type] == :thinking_refused
2701
+ next reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
2608
2702
 
2609
2703
  event = with_generation_report(event) if event[:type] == :generation_completed
2610
2704
  emit_event(on_event, event)
@@ -18,6 +18,7 @@ module Samagotchi
18
18
  def relay = engine.relay_peer
19
19
  def model_ref = engine.effective_model_ref
20
20
  def thinking = engine.thinking_override
21
+ def reasoning_history = engine.reasoning_history_override
21
22
  end
22
23
 
23
24
  # What the approval relay needs from the parent's Engine (Peers#relay):
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tools/read"
4
+ require_relative "tools/write"
5
+ require_relative "tools/edit"
6
+
7
+ module Samagotchi
8
+ # A ref to the file a read/write/edit call touched, for a UI to trace it
9
+ # (the web opens it in this machine's editor): its absolute path (the
10
+ # call's path against the session's cwd) and the lines the call named (a
11
+ # range read or a range edit). A ref is a kind and a target; what a click
12
+ # does is the UI's (refs.js keeps the actions per kind), so nothing here
13
+ # names an editor.
14
+ FileRef = Data.define(:path, :line, :end_line) do
15
+ # @return [Hash] {kind: "file", path:, line:, end_line:}, the lines only
16
+ # when there are some
17
+ def to_h = { kind: FileRef::KIND, **super.compact }
18
+ end
19
+
20
+ class FileRef
21
+ KIND = "file"
22
+
23
+ TOOLS = [Tools::Read::NAME, Tools::Write::NAME, Tools::Edit::NAME].freeze
24
+
25
+ # @param cwd [String, nil] the session's working directory; without one
26
+ # a relative path gets no ref (an absolute or ~ path still does)
27
+ # @return [FileRef, nil] nil for a tool that isn't a file tool and for a
28
+ # path it can't place. +line+: the call's start_line when positive;
29
+ # +end_line+: its end_line, only when past +line+.
30
+ def self.for(tool_name, call, cwd:)
31
+ return nil unless TOOLS.include?(tool_name) && call.is_a?(Hash)
32
+
33
+ path = absolute((tool_name == Tools::Read::NAME ? call[:content] : call[:path]).to_s.strip, cwd)
34
+ return nil unless path
35
+
36
+ line = positive(call[:start_line])
37
+ end_line = line && positive(call[:end_line])
38
+ new(path: path, line: line, end_line: end_line && end_line > line ? end_line : nil)
39
+ end
40
+
41
+ def self.absolute(raw, cwd)
42
+ return nil if raw.empty?
43
+ return File.expand_path(raw) if raw.start_with?("/", "~")
44
+ return nil if cwd.to_s.empty?
45
+
46
+ File.expand_path(raw, cwd)
47
+ rescue ArgumentError
48
+ # ~user for a user that doesn't exist.
49
+ nil
50
+ end
51
+ private_class_method :absolute
52
+
53
+ def self.positive(value)
54
+ number = Integer(value.to_s.strip, exception: false)
55
+ number&.positive? ? number : nil
56
+ end
57
+ private_class_method :positive
58
+ end
59
+ end
@@ -4,6 +4,7 @@ require "uri"
4
4
  require_relative "config"
5
5
  require_relative "host_model"
6
6
  require_relative "llm_context_strategy"
7
+ require_relative "reasoning_history"
7
8
 
8
9
  module Samagotchi
9
10
  # One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
@@ -17,8 +18,9 @@ module Samagotchi
17
18
  # api: symbols, nil when unset; the rest as HostRegistry::HostEntry
18
19
  # describes them, nil when unset; models: {downcased id => HostModel}.
19
20
  HostConfig = Data.define(:name, :host, :port, :scheme, :url, :transport, :api, :api_key_env, :profile,
20
- :first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
21
- :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens, :models) do
21
+ :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
22
+ :window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
23
+ :models) do
22
24
  # Every field but name may be left out: nil, models {}.
23
25
  def initialize(**fields)
24
26
  super(**members.to_h { |m| [m, nil] }, models: {}, **fields)
@@ -50,6 +52,7 @@ module Samagotchi
50
52
  window_tokens: ConfigFile.window_tokens(value.call(:window_tokens), where),
51
53
  sampling: ConfigFile.sampling_map(value.call(:sampling), where),
52
54
  thinking: Thinking.level(value.call(:thinking), where),
55
+ reasoning_history: ReasoningHistory.level(value.call(:reasoning_history), where),
53
56
  llm_context_strategy: LLMContextStrategy.parse(value.call(LLMContextStrategy::KEY), where),
54
57
  llm_context_apply: LLMContextStrategy.parse_apply(value.call(LLMContextStrategy::APPLY_KEY), where),
55
58
  models: HostModel.parse_map(value.call(:models), name),
@@ -166,7 +169,7 @@ module Samagotchi
166
169
  location.merge("transport" => transport&.to_s, "api" => api&.to_s, "api_key_env" => api_key_env,
167
170
  "profile" => profile, "first_token_timeout" => first_token_timeout,
168
171
  "vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
169
- "remote" => remote, "window_tokens" => window_tokens,
172
+ "reasoning_history" => reasoning_history&.to_s, "remote" => remote, "window_tokens" => window_tokens,
170
173
  "llm_context_strategy" => llm_context_strategy&.map(&:to_s),
171
174
  "llm_context_apply" => llm_context_apply&.to_s,
172
175
  "llm_context_budget_tokens" => llm_context_budget_tokens,
@@ -35,6 +35,7 @@ module Samagotchi
35
35
  # vision: the configured true/false (VisionSupport), nil when unset;
36
36
  # sampling: the configured request parameters (SamplingSettings), nil when unset;
37
37
  # thinking: the configured level (Thinking), nil when unset;
38
+ # reasoning_history: the configured level (ReasoningHistory), nil when unset;
38
39
  # remote: the configured true/false (#remote?), nil when unset;
39
40
  # window_tokens: the configured context window (ContextWindow.setting), nil when unset;
40
41
  # llm_context_strategy: the configured layers (LLMContextStrategy), nil when unset;
@@ -42,9 +43,9 @@ module Samagotchi
42
43
  # llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
43
44
  # models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
44
45
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
45
- :first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
46
- :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens, :models,
47
- keyword_init: true) do
46
+ :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
47
+ :window_tokens, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
48
+ :models, keyword_init: true) do
48
49
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
49
50
  # raw-prompt loop.
50
51
  def chat? = api == :openai