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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +6 -2
- data/docs/cli.md +29 -7
- data/docs/configuration.md +113 -8
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/context-telemetry.md +23 -5
- data/docs/internals/engine.md +54 -18
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/sessions.md +2 -2
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cache_confirm.rb +51 -0
- data/lib/samagotchi/client_id.rb +12 -0
- data/lib/samagotchi/config.rb +9 -3
- data/lib/samagotchi/engine.rb +102 -8
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/host_config.rb +6 -3
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/kernel_loop.rb +99 -16
- data/lib/samagotchi/llm/chat_loop.rb +122 -71
- data/lib/samagotchi/llm/openai_chat.rb +2 -1
- data/lib/samagotchi/llm/stream_drops.rb +63 -0
- data/lib/samagotchi/llm/turn_settings.rb +9 -5
- data/lib/samagotchi/model_settings.rb +5 -2
- data/lib/samagotchi/reasoning_history.rb +132 -0
- data/lib/samagotchi/reasoning_history_command.rb +107 -0
- data/lib/samagotchi/self_report.rb +21 -0
- data/lib/samagotchi/session.rb +23 -2
- data/lib/samagotchi/session_commands.rb +55 -15
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_setup.rb +7 -5
- data/lib/samagotchi/setting_explained.rb +20 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/terminal_ui.rb +13 -6
- data/lib/samagotchi/thinking.rb +20 -25
- data/lib/samagotchi/thinking_command.rb +33 -11
- data/lib/samagotchi/tool_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/tools/builtins.rb +6 -2
- data/lib/samagotchi/tools/peers.rb +4 -2
- data/lib/samagotchi/tools/task_runtime.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +48 -4
- data/lib/samagotchi/web/capabilities.rb +13 -9
- data/lib/samagotchi/web/editor.rb +38 -0
- data/lib/samagotchi/web/message_parts.rb +5 -10
- data/lib/samagotchi/web/public/activity.js +33 -18
- data/lib/samagotchi/web/public/app.js +256 -1133
- data/lib/samagotchi/web/public/commands.js +269 -0
- data/lib/samagotchi/web/public/composer.js +105 -0
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +11 -3
- data/lib/samagotchi/web/public/info_bar.js +80 -0
- data/lib/samagotchi/web/public/level_chip.js +174 -0
- data/lib/samagotchi/web/public/lists_view.js +725 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
- data/lib/samagotchi/web/public/model_pick.js +1 -1
- data/lib/samagotchi/web/public/question_cards.js +2 -1
- data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
- data/lib/samagotchi/web/public/refs.js +134 -0
- data/lib/samagotchi/web/public/restore.js +81 -0
- data/lib/samagotchi/web/public/session_state.js +57 -0
- data/lib/samagotchi/web/public/stream_handlers.js +332 -0
- data/lib/samagotchi/web/public/thinking_chip.js +38 -144
- data/lib/samagotchi/web/public/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +5 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/worker.rb +17 -5
- metadata +18 -2
- 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
|
|
19
|
-
`vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
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[
|
|
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.
|
|
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:
|
|
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
|
data/lib/samagotchi/client_id.rb
CHANGED
|
@@ -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?
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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
|
|
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).
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -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,
|
|
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 (
|
|
1098
|
+
# session's own (SettingExplained), as the next turn resolves it;
|
|
1097
1099
|
# nil when it can't be resolved.
|
|
1098
|
-
# @return [
|
|
1099
|
-
|
|
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:
|
|
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,
|
|
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
|
|
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, :
|
|
21
|
-
:llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
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, :
|
|
46
|
-
:llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
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
|