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