samagotchi 0.74.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/architecture.md +11 -4
  4. data/docs/cli.md +13 -1
  5. data/docs/configuration.md +4 -3
  6. data/docs/guardrails.md +9 -0
  7. data/docs/internals/anthropic.md +3 -1
  8. data/docs/internals/bundles.md +21 -7
  9. data/docs/internals/context-telemetry.md +22 -0
  10. data/docs/internals/engine.md +14 -3
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +48 -1
  13. data/docs/plugins.md +2 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +7 -5
  16. data/lib/samagotchi/bundle_command.rb +52 -39
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  19. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/desktop/macos.rb +1 -2
  23. data/lib/samagotchi/effective_model.rb +5 -0
  24. data/lib/samagotchi/engine.rb +84 -4
  25. data/lib/samagotchi/kernel_loop.rb +13 -10
  26. data/lib/samagotchi/llm_context_override.rb +8 -6
  27. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  28. data/lib/samagotchi/memory_bundle/builder.rb +11 -14
  29. data/lib/samagotchi/memory_bundle/installer.rb +140 -9
  30. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  31. data/lib/samagotchi/memory_bundle/memory_files.rb +65 -0
  32. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  33. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  34. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  35. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  36. data/lib/samagotchi/model_price.rb +4 -3
  37. data/lib/samagotchi/paths.rb +15 -1
  38. data/lib/samagotchi/session.rb +1 -1
  39. data/lib/samagotchi/session_chain.rb +2 -8
  40. data/lib/samagotchi/session_commands.rb +2 -0
  41. data/lib/samagotchi/session_manager.rb +1 -2
  42. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  43. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  44. data/lib/samagotchi/session_metrics.rb +118 -83
  45. data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
  46. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  47. data/lib/samagotchi/toolkit.rb +9 -8
  48. data/lib/samagotchi/toolkit_command.rb +2 -2
  49. data/lib/samagotchi/tools/builtins.rb +1 -5
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/app.rb +3 -0
  52. data/lib/samagotchi/web/public/app.js +4 -3
  53. data/lib/samagotchi/web/public/chains.js +42 -21
  54. data/lib/samagotchi/web/public/composer.js +9 -5
  55. data/lib/samagotchi/web/public/ctx.js +73 -11
  56. data/lib/samagotchi/web/public/index.html +32 -35
  57. data/lib/samagotchi/web/public/info_bar.js +4 -4
  58. data/lib/samagotchi/web/public/info_bar_model.js +18 -8
  59. data/lib/samagotchi/web/public/lists_view.js +2 -2
  60. data/lib/samagotchi/web/public/question_cards.js +1 -1
  61. data/lib/samagotchi/web/public/session_state.js +7 -0
  62. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  63. data/lib/samagotchi/web/public/stream_handlers.js +32 -1
  64. data/lib/samagotchi/web/public/timing.js +11 -2
  65. metadata +4 -1
data/docs/sessions.md CHANGED
@@ -51,6 +51,8 @@ chi sessions stats ID [--format text|json] # cost, tokens and progres
51
51
 
52
52
  `chi sessions stats ID` prints what `/stats` prints in a terminal, headed by `<id8> <status> <model>`: a live worker answers its Bridge's `GET stats` (no model call, and the command never starts a worker); without one, or when the worker does not answer in time, the same snapshot is rebuilt from the session's saved `analytics.json` (and the line says so when a worker was live). `--format json` prints one object `{session_id, status, live, metrics}` with the snapshot as `metrics` (its `turn_records` / `tool_records` are the recent ones only: a live worker's newest finished turn and any not yet saved, the disk snapshot's newest saved turn; `analytics.json` holds them all); a session with no `analytics.json` yet reports `metrics: null` (`(no metrics yet)` in text) and still exits 0. An unknown or ambiguous id exits 1 with the same wording `chi sessions stop` uses.
53
53
 
54
+ Each turn record in `analytics.json` names the model the turn ran on as `model_ref` (the asked `host:model`, from its first generation; `model` stays the name the server says it served); a turn stopped before it asked a model has none. The first turn after a `/model` switch (or a hook's) to another model also records what its first generation prefilled on the new model: `switch_prefill_tokens` (the prompt less the cached reads; none under 128 tokens, or for a generation with estimated counts) and `switch_prefill_cost_estimate` (from the new model's configured price, no completion; none without a price). A session saved before this has no `model_ref` on its records.
55
+
54
56
  A session whose worker waits for an answer (a question, a hook's question, a guardrail approval, or the step-limit question of a turn that ran out of steps, which waits between turns) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"|"continue"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). A delegate's approval its parent relayed to its own card (see *Delegating*) reads `waiting (in parent ab12)`, json `relayed_to: "ab12…"` (the parent's short id, else null). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
55
57
 
56
58
  Examples:
@@ -74,9 +76,9 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
74
76
  **Ordering:**
75
77
 
76
78
  - `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.
77
- - 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)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. 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, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); 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).
79
+ - 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)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. 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)`); after a `/model` switch the info bar's tooltip adds a `per model:` row per run of turns on one model above those totals (`splash:qwen3-35b · 6 turns · 96.0k in / 9.0k out · ~$0.02`, a switch's prefill under its row: `└ switch prefill 58.0k uncached prompt ≈$0.01`; the timing's `model_segments`, replaced by each `generation_completed`'s; the session cards stay as they were), and right after the switch its ctx % is the new model's window's; 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, and a fresh chain link's (see [Session chains](#session-chains); there a pick shows as a `/model` command bubble in the link); 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).
78
80
  - 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).
79
- - 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; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `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), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `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), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `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), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `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), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `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, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
81
+ - 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; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `#N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue, and the fresh-link rule), `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), `bubbles.js` (the history's bubbles between the turns' rows: output chunks, end lines, every client's prompt bubbles by `enqueued_id` with their queued/steered badges, the idle recap, guardrail warnings, hooks' notices, plugins' steers, command and note bubbles; lit-html templates, put in the history with the sticky follow by `createBubbles`), `cards_place.js` (where plugin cards and a snapshot's notices go in the history: a card added or updated in place by its id, a running turn's into its step, a snapshot's each above the first turn after it, a finished turn's notice in its step's rows and its question resolved where it was asked; a card's action buttons), `format.js` (pure formatters such as `previewOf`), `plain_line.js` (markdown out of the plain one-line places: the stage headline, a closed step's title, an answered question card's kept-reply line), `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), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `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), `stage_view.js` (the stage view: the running turn pinned above the composer, then handed off into the history; its pure rules and texts in `stage_model.js`, its live slots (headline, now row, trail, ticks) in `stage_slots.js`, its cap and held height in `stage_frame.js`, the in-use watch that times the hand-off in `stage_watch.js`), `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), `info_bar_model.js` (that model and the bar's buttons' states, built from the page's session state, its list and its in-flight continues and restarts as pure functions; `app.js`'s `updateInfoBar` applies them every second while a turn runs), `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, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`spec/web/public/*.test.js`).
80
82
  - 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).
81
83
 
82
84
  **Test-session hygiene:**
@@ -204,19 +206,19 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
204
206
 
205
207
  ## Session chains
206
208
 
207
- A session can **continue** an earlier one: "the next day of the same routine" (a daily coordinator, say). The new session's `continues` field names the previous link; the chain is derived from it, never stored: follow `continues` back for the earlier links, forward for the later ones. It is not a parent: archiving one link leaves the next alone, and lists fold delegates, not links. A link is continued at most once, so a chain has no forks and its latest link is the one nobody continues.
209
+ A session can **continue** an earlier one: "the next run of the same routine" (a daily coordinator, say, continued once a day or several times). The new session's `continues` field names the previous link; the chain is derived from it, never stored: follow `continues` back for the earlier links, forward for the later ones. It is not a parent: archiving one link leaves the next alone, and lists fold delegates, not links. A link is continued at most once, so a chain has no forks and its latest link is the one nobody continues.
208
210
 
209
211
  `SessionManager.continue_session` makes the next link:
210
212
 
211
213
  - **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), and its own LLM context values. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message.
212
- - **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Never more than the recap: no transcript. An idle new link holding only the note is kept, not discarded as empty.
214
+ - **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08 09:12), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Never more than the recap: no transcript. An idle new link holding only the note is kept, not discarded as empty.
213
215
  - **The recap first:** a previous link with a live worker is asked for a recap (the Bridge's `POST /recap`) and given up to 30 s to write it; one with none wrote its recap when its worker left.
214
216
  - **The previous link is archived** before the new one starts, by the archive's own rules (see *Archiving a session*): a turn running, a prompt queued or the step-limit question refuse it, a live idle worker is stopped, a plain REPL's session is refused. Its finished delegates are archived with it, as any archive does.
215
217
  - **Its open delegates move to the new link** instead: each direct delegate that is running, waiting for an answer, has a live worker or a reply its parent wasn't given, or has such a delegate below it (checked again after the recap wait, so one delegated meanwhile moves too). It moves with its own delegates, stays unarchived, and from then on is the new link's: its card folds under the new link, its reports ring the new link (an idle one runs a wake turn for them, as for its own delegates), `delegate_result` and the `⑂` chip count it there, and what its parent was already told (`delegates.json`) and any report still waiting come along, so nothing is reported twice. A moved delegate gets a context note from `session chain`, `Your parent session is now 4b7e9a01 (…), the next link of its session chain: …`, as its system prompt keeps naming the parent it started with. The move writes `<child>/parent.json` (`{parent_id, from, at}`) beside the child's session file, never the file itself, so a live child worker saving its own copy changes nothing; every reader applies it (a later move overwrites it; it is never removed). A continue is **refused for an open delegate that can't move** (`SessionContinue::Refused`, naming it): one whose live worker runs an older chi (`older chi worker 0.46.1; restart it`: `chi sessions restart <id>`, or stop it) or that is open in a chi REPL (close it there). An older `chi web` or CLI still running shows a moved delegate under the archived link until it is restarted.
216
218
  - **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
217
219
  - **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model's host can't run it when no default model is configured to fall back to. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
218
220
  - **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context` or `thinking` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
219
- - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft already there stays instead): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its preview and id in the tooltip. A link made by `continue →` inherits the previous link's title when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ day N` chip (`day N of M` when later links aren't listed) opens a popover of the other links, newest first (`day N`, its date, its recap's first sentence or its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `day N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
221
+ - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft already there stays instead): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a chi REPL owns, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `open in a chi REPL; close it there`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's title when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its recap's first sentence or its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or "include archived" is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
220
222
  - **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
221
223
  - **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain.
222
224
 
@@ -146,8 +146,12 @@ module Samagotchi
146
146
  return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
147
147
  end
148
148
 
149
- bundle_name = bundle_name_or_fail(expanded_source, "Install") or return 1
150
- run_installer(installer_for(expanded_source, bundle_name, scope: scope, force: force), failed: "Install") do |manifest|
149
+ prepared, bundle_name = prepare_or_fail(expanded_source, "Install")
150
+ return 1 unless prepared
151
+ return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Install")
152
+
153
+ installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force)
154
+ run_installer(installer, failed: "Install") do |manifest|
151
155
  print_hooks_and_plugin(manifest)
152
156
  print_provenance(bundle_name, manifest)
153
157
  end
@@ -164,7 +168,7 @@ module Samagotchi
164
168
  dry_run = opts.fetch(:dry_run, false)
165
169
  agent = opts[:agent]
166
170
  if source.nil? || source.empty?
167
- @stderr.puts "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run]"
171
+ @stderr.puts UPGRADE_HELP
168
172
  return CLI::Exit::USAGE
169
173
  end
170
174
  expanded_source = expand_source(source)
@@ -172,18 +176,21 @@ module Samagotchi
172
176
  return install_profile(expanded_source, scope: scope, force: force, dry_run: dry_run, word: "Upgrade")
173
177
  end
174
178
 
175
- bundle_name = bundle_name_or_fail(expanded_source, "Upgrade") or return 1
179
+ prepared, bundle_name = prepare_or_fail(expanded_source, "Upgrade")
180
+ return 1 unless prepared
181
+ return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Upgrade")
182
+
176
183
  unless Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).installed?
177
184
  @stderr.puts "Bundle '#{bundle_name}' not installed — falling back to install"
178
185
  # (no Hooks/Plugin lines here)
179
- installer = installer_for(expanded_source, bundle_name, scope: scope, force: force, dry_run: dry_run)
186
+ installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, dry_run: dry_run)
180
187
  return run_installer(installer, failed: "Install") do |manifest|
181
188
  next dry_run_done if dry_run
182
189
 
183
190
  print_provenance(bundle_name, manifest)
184
191
  end
185
192
  end
186
- installer = installer_for(expanded_source, bundle_name, scope: scope, force: force, upgrade: true, dry_run: dry_run)
193
+ installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, upgrade: true, dry_run: dry_run)
187
194
  run_installer(installer, failed: "Upgrade") do |manifest|
188
195
  print_hooks_and_plugin(manifest, plugin: !dry_run)
189
196
  next dry_run_done if dry_run
@@ -193,12 +200,44 @@ module Samagotchi
193
200
  end
194
201
  end
195
202
 
196
- # The bundle's name, or nil after "<word> failed: …".
197
- def bundle_name_or_fail(expanded_source, word)
198
- bundle_name_for(expanded_source)
199
- rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError => e
203
+ # The source normalized once (a git clone, a zip or tar extract) and the
204
+ # bundle's name, its manifest's name: for every kind of source, a git URL
205
+ # too. The Installer installs from that extract and removes it. Nil
206
+ # after "<word> failed: …" (an unknown source, or a missing or invalid
207
+ # manifest: an install needs one anyway), the extract removed.
208
+ # @return [Array(MemoryBundle::PreparedSource, String), nil]
209
+ def prepare_or_fail(expanded_source, word)
210
+ prepared = Samagotchi::MemoryBundle::PreparedSource.prepare(expanded_source)
211
+ named = [prepared, Samagotchi::MemoryBundle::Manifest.read(dir: prepared.dir).name]
212
+ rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError,
213
+ Samagotchi::MemoryBundle::Manifest::ValidationError => e
200
214
  @stderr.puts "#{word} failed: #{e.message}"
201
- nil
215
+ ensure
216
+ # Any failure (an unexpected error too) removes the extract.
217
+ prepared&.cleanup unless named
218
+ end
219
+
220
+ # A bundle installed from this source under another name: a git URL
221
+ # bundle recorded before chi named it by its manifest (it took the
222
+ # repo's name, chi-foo for name: foo). Installing it again would load
223
+ # both copies' plugin and hooks, so "<word> failed: … installed as
224
+ # chi-foo" instead (the extract removed). Only when no bundle has the
225
+ # manifest's name; sources compare by SourceIdentity.
226
+ # @return [Boolean] refused
227
+ def installed_under_another_name?(expanded_source, bundle_name, prepared, word)
228
+ return false if Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).installed?
229
+
230
+ identity = Samagotchi::MemoryBundle::SourceIdentity.of(expanded_source)
231
+ return false unless identity
232
+
233
+ other, = Samagotchi::MemoryBundle::Provenance.each_installed.find do |name, bundle|
234
+ name != bundle_name && !bundle.error? && Samagotchi::MemoryBundle::SourceIdentity.of(bundle.source) == identity
235
+ end
236
+ return false unless other
237
+
238
+ prepared.cleanup
239
+ @stderr.puts "#{word} failed: #{expanded_source} is installed as #{other}: `chi bundle uninstall #{other}`, then install it again"
240
+ true
202
241
  end
203
242
 
204
243
  def installer_for(source, name, scope:, force:, **)
@@ -728,40 +767,14 @@ module Samagotchi
728
767
  parsed
729
768
  end
730
769
 
731
- # helpers for bundle_name derivation
770
+ # The source as given: a git URL as is, a shipped bundle's name as its
771
+ # dir, anything else as an expanded path.
732
772
  def expand_source(src)
733
773
  return src if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(src)
734
774
 
735
775
  Samagotchi::MemoryBundle::SourceNormalizer.shipped_bundle_dir(src) || File.expand_path(src)
736
776
  end
737
777
 
738
- def bundle_name_for(expanded_src)
739
- # Try to derive from manifest if possible (skip git clone for speed if possible)
740
- if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(expanded_src)
741
- # For git URLs, avoid double clone: derive name from URL basename if no manifest yet
742
- # Let Installer do the real manifest read; fallback to URL basename
743
- base = expanded_src.split("/").last.to_s.split("#").first.to_s
744
- base = base.sub(/\.git\z/i, "")
745
- return base.empty? ? "bundle" : base
746
- end
747
- require "yaml"
748
- nd = nil
749
- owned = false
750
- begin
751
- nd, owned = Samagotchi::MemoryBundle::SourceNormalizer.normalize(expanded_src)
752
- m = Samagotchi::MemoryBundle::Manifest.read(dir: nd)
753
- m.name
754
- rescue Samagotchi::MemoryBundle::Manifest::ValidationError
755
- # Fallback to source basename without archive extensions
756
- src = expanded_src
757
- name = File.basename(src)
758
- name = name.sub(/\.tar\.gz\z/i, "").sub(/\.tgz\z/i, "").sub(/\.zip\z/i, "").sub(/\.tar\z/i, "")
759
- name.empty? ? File.basename(nd || src) : name
760
- ensure
761
- Samagotchi::MemoryBundle::SourceNormalizer.cleanup(nd) if owned
762
- end
763
- end
764
-
765
778
  def build_conflict_prompt(bundle_name, conflicts, expanded_source)
766
779
  lines = []
767
780
  lines << "Memory bundle upgrade has conflicts that need interactive resolution."
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-peer
3
- version: 0.3.1
3
+ version: 0.3.2
4
4
  scope: system
5
5
  description: "Pair this session with an OpenAI Codex CLI session you work with: the codex_send tool and /codex send it a part of the work (a plan step, a diff, a finding) to validate or as context, with codex queue, and Codex's answer comes back as the codex-outbox context; /codex install-skill installs Codex's chi-peer skill, which pairs"
6
6
  trust_level: reviewed
@@ -10,7 +10,7 @@ files:
10
10
  description: "Send a part of the work to the paired Codex CLI session (codex_send): only when the user asks, or to reply to its answer in a peer turn; validate, context or question; Codex's answer comes as the codex-outbox context"
11
11
  plugin:
12
12
  file: plugin.rb
13
- sha256: sha256:eb6888e3611b8ed42498cfecab5b430e0ef7a4c2dc7b3c090d015336aa039b11
13
+ sha256: sha256:b03465566272891237ac1906980ff6956579a4703d3a42c414ce50a81de551f6
14
14
  scripts:
15
15
  pair: sha256:251448a31959b5c916400eff7909e85c696855d5c16b6b10973ada40915c8051
16
16
  chi-peer-SKILL.md: sha256:7c392ac04c115ea1e762ad562b5c4c44c3eca71558ba5d9dfeaeabdd533b6a82
@@ -168,10 +168,16 @@ class Plugin < Samagotchi::Plugin::Peer
168
168
  # [stdout, stderr, status]; both pipes read on threads, so a full one
169
169
  # can't block the command.
170
170
  def run(*command)
171
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + QUEUE_SECONDS
171
172
  Open3.popen3(*command, pgroup: true) do |stdin, stdout, stderr, wait|
172
173
  stdin.close
173
174
  out, err = [stdout, stderr].map { |io| Thread.new { io.read }.tap { |t| t.report_on_exception = false } }
174
- unless wait.join(QUEUE_SECONDS)
175
+ threads = [wait, out, err]
176
+ within_deadline = lambda do |thread|
177
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
178
+ thread.join([remaining, 0].max)
179
+ end
180
+ unless threads.all?(&within_deadline)
175
181
  begin
176
182
  Process.kill("KILL", -wait.pid)
177
183
  rescue SystemCallError
@@ -179,7 +185,18 @@ class Plugin < Samagotchi::Plugin::Peer
179
185
  end
180
186
  # The group is gone, so the pipes end: let the readers finish
181
187
  # before popen3 closes them.
182
- [wait, out, err].each { |t| t.join(1) }
188
+ cleanup_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
189
+ threads.each do |thread|
190
+ remaining = cleanup_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
191
+ thread.join([remaining, 0].max)
192
+ end
193
+ # The queue itself finished fine, and only a process it left held
194
+ # the pipes: the message is likely queued, so don't say "try again"
195
+ # (a retry would deliver it twice).
196
+ if wait.join(0) && wait.value.success?
197
+ raise SendError, "codex queue exited but left a process holding its output (stopped after " \
198
+ "#{QUEUE_SECONDS} s): the message may already be queued, check Codex before sending again"
199
+ end
183
200
  raise SendError, "Codex didn't take the message in #{QUEUE_SECONDS} s (codex queue was stopped): try " \
184
201
  "again, or pair again from Codex"
185
202
  end
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: core
3
- version: 0.3.0
3
+ version: 0.3.1
4
4
  scope: system
5
5
  description: "Recommended safety set"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.8.0"
7
+ requires_chi: ">= 0.60.0"
8
8
  includes:
9
9
  - loop-guard
10
10
  - check-in
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: dev
3
- version: 0.5.0
3
+ version: 0.5.1
4
4
  scope: system
5
5
  description: "Developer tools"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.34.0"
7
+ requires_chi: ">= 0.72.0"
8
8
  includes:
9
9
  - known-names
10
10
  - mcp
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.74.0
3
+ version: 0.76.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:
@@ -152,8 +152,7 @@ module Samagotchi
152
152
  def launch_config
153
153
  env = { "LANG" => LANG }
154
154
  ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
155
- env["XDG_CONFIG_HOME"] = Paths.config_home(env: @env) if env.key?("XDG_CONFIG_HOME")
156
- env["XDG_STATE_HOME"] = Paths.state_home(env: @env) if env.key?("XDG_STATE_HOME")
155
+ env.merge!(Paths.absolute_xdg(env: env, set: :key))
157
156
  config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
158
157
  TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
159
158
  config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
@@ -168,6 +168,11 @@ module Samagotchi
168
168
  unresolved(:window_unresolved, e, nil)
169
169
  end
170
170
 
171
+ # The window config.yml gives this model or its host
172
+ # (ContextWindow.setting), never asking a server: a session loaded on
173
+ # it (Engine#session=, before a worker's Bridge serves). nil when none.
174
+ def configured_context_window = ContextWindow.setting(target, names: names)
175
+
171
176
  # The window as its loop would see it (ChatLoop#context_window: a remote
172
177
  # chat host has no /props, only its model list); nil when it can't be
173
178
  # resolved.
@@ -1005,6 +1005,9 @@ module Samagotchi
1005
1005
  sync_kernel_client!
1006
1006
  @client.invalidate_context_window!
1007
1007
  @metrics.forget_model_reports!
1008
+ # The last turn's ctx % was the old model's window's
1009
+ # (#announce_switched_context! sends the new one's).
1010
+ @last_context_status = nil
1008
1011
  ref = effective_model_ref
1009
1012
  if persist_default
1010
1013
  ConfigFile.write_default_model!(ref)
@@ -1013,6 +1016,42 @@ module Samagotchi
1013
1016
  ref
1014
1017
  end
1015
1018
 
1019
+ # After /model: how full the context is on the new model's window,
1020
+ # before its first turn: the last turn's count (the old model's
1021
+ # tokenizer's, replaced at the next generation) against the new model's
1022
+ # window (EffectiveModel#context_window, ContextWindow.resolve: the
1023
+ # running server first, one /props GET that a remote chat host is
1024
+ # spared; else the host's cached model list; else the configured
1025
+ # window) and the
1026
+ # session's budget. Sent as a :context_status event (the web's ctx
1027
+ # meter, the attached TUI's status row) with context_window_tokens, and
1028
+ # kept for a client that joins later (#session_state_snapshot; the
1029
+ # window in the metrics' context too, which a page counts from). A window
1030
+ # that can't be resolved sends usage nil: the UIs clear the % until the
1031
+ # next generation. Nothing before the session's first counted turn.
1032
+ # +configured_only+: the configured window only, never asking a server
1033
+ # (EffectiveModel#configured_context_window): a loaded session's (#session=).
1034
+ # @return [Hash, nil] the event sent
1035
+ def announce_switched_context!(configured_only: false)
1036
+ used = @metrics.snapshot.dig(:context, :used_tokens)
1037
+ return nil unless used.is_a?(Integer)
1038
+
1039
+ model = effective_model
1040
+ window = configured_only ? model.configured_context_window : current_context_window(model)
1041
+ display = window && saved_context_status({ used_tokens: used, window_tokens: window.tokens })
1042
+ @last_context_status = display&.dup
1043
+ @metrics.note_context_window(window.tokens, window.source) if window
1044
+ usage = display && { estimated_pct: display[:est_pct], estimated_used_tokens: used, window_tokens: window.tokens,
1045
+ window_source: window.source.to_s, source: "switch" }
1046
+ event = { type: :context_status, usage: usage, bucket: display&.dig(:bucket),
1047
+ context_window_tokens: window&.tokens, context_window_source: window&.source&.to_s }
1048
+ @session_observer.notify(event)
1049
+ event
1050
+ rescue StandardError => e
1051
+ Log.warn(:model, "switched_context_failed", error: e.class.name, msg: e.message.to_s[0, 200])
1052
+ nil
1053
+ end
1054
+
1016
1055
  # ── Hooks API ──────────────────────────────────────────────────────────────
1017
1056
 
1018
1057
  # Register a hook callback for a named lifecycle event.
@@ -1640,8 +1679,30 @@ module Samagotchi
1640
1679
  # A woken worker's /stats and status line count the turns before it.
1641
1680
  bind_metrics(session)
1642
1681
  @used_memories.absorb(session)
1682
+ # Loaded on another model than its last turn ran on (a --resume'd
1683
+ # REPL's --model, a worker woken after a /model with no turn since):
1684
+ # the saved ctx % was that model's window's. Drop it and count it as
1685
+ # /model does, on a configured window only: this runs before a
1686
+ # worker's Bridge serves, so no /props GET or model list here (a slow
1687
+ # or hung host would hold up the worker's start). No configured
1688
+ # window: no % (ctx ?) until the first generation reports one.
1689
+ return unless model_switched_since_last_turn?
1690
+
1691
+ @metrics.forget_model_reports!
1692
+ announce_switched_context!(configured_only: true)
1643
1693
  end
1644
1694
 
1695
+ # Whether the last turn that named its model ran on another one than
1696
+ # the effective model; false before any (or when the ref can't be told).
1697
+ def model_switched_since_last_turn?
1698
+ last = @metrics.last_model_ref
1699
+ !last.nil? && last != effective_model_ref
1700
+ rescue StandardError => e
1701
+ Log.warn(:model, "switch_check_failed", error: e.class.name, msg: e.message.to_s[0, 200])
1702
+ false
1703
+ end
1704
+ private :model_switched_since_last_turn?
1705
+
1645
1706
  # @return [IdleRecap, nil] the idle recap detector, or nil when disabled
1646
1707
  attr_reader :recap
1647
1708
 
@@ -2047,7 +2108,8 @@ module Samagotchi
2047
2108
  result = backend(model).complete(
2048
2109
  messages: turn.messages,
2049
2110
  max_iterations: turn.limit,
2050
- on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
2111
+ on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller,
2112
+ model_ref: generation_model_ref(model)),
2051
2113
  cancel_controller: turn.controller,
2052
2114
  model_name: bare_for_backend,
2053
2115
  max_tool_output_chars: max_tool_output_chars,
@@ -2059,6 +2121,18 @@ module Samagotchi
2059
2121
  end
2060
2122
  private :generate
2061
2123
 
2124
+ # The ref (host:model, as asked) a turn's generations ran on: the model
2125
+ # #generate resolved, after the before_turn hooks (which may switch it).
2126
+ # SessionMetrics keys its per-model segments on it. nil when it can't be
2127
+ # named (the turn's record then has none).
2128
+ def generation_model_ref(model)
2129
+ model.ref
2130
+ rescue StandardError => e
2131
+ Log.warn(:turn, "model_ref_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2132
+ nil
2133
+ end
2134
+ private :generation_model_ref
2135
+
2062
2136
  # A step-limit Continue's resume_from, when it is inside the messages the
2063
2137
  # run gets (a wrong one only drops reasoning: it is ignored, logged).
2064
2138
  # Entries before it never move between the turns (#turn_messages adds
@@ -2594,13 +2668,17 @@ module Samagotchi
2594
2668
  #
2595
2669
  # With a :generation_progress hook registered, a Hooks::StreamWatch gets
2596
2670
  # each chunk's thinking and text after the UIs had it.
2597
- def build_stream_event_handler(on_event, cancel_controller: nil)
2671
+ #
2672
+ # +model_ref+: the turn's model (#generation_model_ref), added to each
2673
+ # :generation_started as `model_ref:` (both loops send through here).
2674
+ def build_stream_event_handler(on_event, cancel_controller: nil, model_ref: nil)
2598
2675
  watch = stream_watch(cancel_controller)
2599
2676
  proc do |event|
2600
2677
  progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
2601
2678
  @steer_cut.observe(event, progress)
2602
2679
  case event[:type]
2603
2680
  when :generation_started
2681
+ event = event.merge(model_ref: model_ref) if model_ref
2604
2682
  @thinking_notices.generation_started
2605
2683
  watch&.started(event[:iteration])
2606
2684
  when :generation_retrying
@@ -2626,13 +2704,15 @@ module Samagotchi
2626
2704
 
2627
2705
  # A :generation_completed with the generation's decode speed (`speed:`
2628
2706
  # {decode_tps:, source:}, nil without one), the session's running
2629
- # token totals (`tokens:`, the snapshot's block) and whether its served
2707
+ # token totals (`tokens:`, the snapshot's block) and its per-model rows
2708
+ # (`model_segments:`, the snapshot's) and whether its served
2630
2709
  # model is an expected one (`served_expected_by:`, its host): the web's info bar
2631
2710
  # reads them from the stream. SessionMetrics closes the generation here,
2632
2711
  # so the event it gets next finds it closed.
2633
2712
  def with_generation_report(event)
2634
2713
  report = @metrics.finish_generation
2635
- event.merge(speed: report.speed&.to_h, tokens: report.tokens, served_expected_by: served_expected_by(event[:served_model]))
2714
+ event.merge(speed: report.speed&.to_h, tokens: report.tokens, model_segments: report.model_segments,
2715
+ served_expected_by: served_expected_by(event[:served_model]))
2636
2716
  rescue StandardError => e
2637
2717
  Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2638
2718
  event
@@ -943,19 +943,22 @@ module Samagotchi
943
943
  private :emit_llm_context
944
944
 
945
945
  # A request's re-prefilled tokens, for both loops: of the prompt the
946
- # last request on +model+ sent (this kernel's, any turn), what the
947
- # server didn't reuse from its cache this time (that prompt less
948
- # +cached+), so an LLM context edit's cache break shows, and so does
949
- # anything else that rewrote the prompt's middle or lost the cache. nil
950
- # under TokenUsage::REPREFILL_MIN_TOKENS (a cache block's rounding), for
951
- # a server that reports no cached count (or 0: a cold cache isn't told
952
- # from no cache), the first request, a model switch, and the first
953
- # request after a warm-up (#warmed_up!).
946
+ # last request on +model+ through this client (the host) sent (this
947
+ # kernel's, any turn), what the server didn't reuse from its cache this
948
+ # time (that prompt less +cached+), so an LLM context edit's cache break
949
+ # shows, and so does anything else that rewrote the prompt's middle or
950
+ # lost the cache. nil under TokenUsage::REPREFILL_MIN_TOKENS (a cache
951
+ # block's rounding), for a server that reports no cached count (or 0: a
952
+ # cold cache isn't told from no cache), the first request, a model
953
+ # switch (another bare model, or the same one on another host: the
954
+ # Engine points #client at the new host's; SessionMetrics counts that
955
+ # prefill as the switch's), and the first request after a warm-up
956
+ # (#warmed_up!).
954
957
  # @return [Integer, nil]
955
958
  def reprefilled_tokens(model, prompt:, cached:)
956
959
  previous = @reprefill_baseline
957
- @reprefill_baseline = { model: model, prompt: prompt.to_i } if prompt.to_i.positive?
958
- return nil unless previous && previous[:model] == model && cached.to_i.positive?
960
+ @reprefill_baseline = { model: model, client: @client, prompt: prompt.to_i } if prompt.to_i.positive?
961
+ return nil unless previous && previous[:model] == model && previous[:client].equal?(@client) && cached.to_i.positive?
959
962
 
960
963
  tokens = previous[:prompt] - cached.to_i
961
964
  tokens >= TokenUsage::REPREFILL_MIN_TOKENS ? tokens : nil
@@ -125,7 +125,14 @@ module Samagotchi
125
125
  raise ArgumentError, "unknown llm_context field #{field}" unless FIELDS.include?(field)
126
126
 
127
127
  text = word.to_s.strip
128
- [field, text.casecmp?(DEFAULT_WORD) ? nil : send(:"parse_#{field}", text)]
128
+ value = if text.casecmp?(DEFAULT_WORD)
129
+ nil
130
+ elsif field == :budget_tokens
131
+ LLMContextStrategy.budget_tokens(text)
132
+ else
133
+ send(:"parse_#{field}", text)
134
+ end
135
+ [field, value]
129
136
  end
130
137
  (current || new).with(**fields)
131
138
  end
@@ -152,11 +159,6 @@ module Samagotchi
152
159
  rule || raise(ArgumentError, "unknown llm_context apply #{text} (#{LLMContextStrategy::APPLIES.join(", ")})")
153
160
  end
154
161
 
155
- # "64000", "64k" or "off" (0): LLMContextStrategy.budget_tokens.
156
- # .update reaches it (and the other parse_<field>s) by its name.
157
- # @return [Integer] 0 for off
158
- def self.parse_budget_tokens(text) = LLMContextStrategy.budget_tokens(text)
159
-
160
162
  private_class_method :file_value, :file_strategy, :file_budget
161
163
  end
162
164
  end