samagotchi 0.41.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0f829b7b7835aab33a8b0f8a69609acc2e1989743c5f77664ab54e76cee9ee2
4
- data.tar.gz: d0b1ab5cd60519258beb069528aed7f50d7404d5aa8c28ff30a5a1c5b78e3418
3
+ metadata.gz: d41b91ef2514e877a756d7f66ba02f58b65c7d85f7446728b02ec06f652299b3
4
+ data.tar.gz: 37e0869508863e5db69395b847ac147101f60a79208a5ba9e789e3427eb9716e
5
5
  SHA512:
6
- metadata.gz: 1a0d363fc07a55b7bfbbdb4263ecb630554376e1cbd4bf641e914933046bfb136ecd36f85386bc9a328c63803db9a49a4c57a0a6425849b90c59f223888b68a1
7
- data.tar.gz: ae547e2229da947e57095ac6730b5e0549e8e03633beb79ccd47922d836b2a2a00c04be3385000ef230161a74ac88a02621ef84152253d7c5cb017505cfd1853
6
+ metadata.gz: abb02fdd05aec927500fbb42a750b9da3dc9e1c8e24cbc55bfdc933e797e206ee8e3a3502d0a4074804b0342f856726d2b04d2eb15c2aa61682e23439bca38ea
7
+ data.tar.gz: 681406d11e96949fd168b252a6717fe2f6dcfaa765400bf66a43a7131dc4440ff7f077312f3ccd219ddce8c05e88f15e4bc808c52652d265beed59eed89c8c1c
data/CHANGELOG.md CHANGED
@@ -8,6 +8,33 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.42.0] - 2026-10-08
12
+
13
+ ### Added
14
+
15
+ - Which model notes a session's prompt carried: each prompt build (and `/model`) records them in the session file
16
+ (`prompt_notes`: name, scope, size and a short digest), and they are shown in `/stats` (`model notes:
17
+ model_notes_deepseek (system, 612 chars)`), at the end of `/model`'s line (`; notes: …`, after a switch the new
18
+ model's), as a `notes: deepseek` chip in the web's info bar and as a `model notes` row in `chi self`. Nothing shows
19
+ without notes. See [docs/memory.md](docs/memory.md) ("Model notes").
20
+ - For development: `script/model_notes_report.rb`, which groups stored sessions by model and by the model notes
21
+ their prompt carried and compares the calls to the first edit and commit, commits per 100 steps, bare `&` in
22
+ `execute`, the longest run without an edit and the Continues and steers each session needed (`--model`,
23
+ `--since`, `--min-steps`, `--json`). It isn't part of the gem. See [docs/testing.md](docs/testing.md).
24
+
25
+ ### Fixed
26
+
27
+ - The web showed no failure line after a reload for a failed turn whose steps stayed (new in 0.41.0), so the turn
28
+ looked unfinished; it ends with the same `✕ turn failed: <why>; partial progress kept (!rollback restores the
29
+ pre-turn state)` line as live (from the turn record, which keeps the failure now; a turn recorded before this
30
+ shows none).
31
+ - A delegate report could reach the parent's model twice: a reminder or continue turn that failed before any
32
+ progress kept the report it had read, and the next turn brought it again. A canceled continue turn's report was
33
+ never delivered. A report's ring now goes only when the turn that read it stays in the conversation.
34
+ - `/llm-context` (the web's `llm ctx` chip, `--llm-context` on `--attach`) against a worker on an older chi answered
35
+ `400 not a session command`; the web (`501 not_supported`) and the attached terminal say the worker runs an older
36
+ chi, with the line that restarts it.
37
+
11
38
  ## [0.41.0] - 2026-10-08
12
39
 
13
40
  ### Added
@@ -1955,7 +1982,8 @@ and long-lived sessions.
1955
1982
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
1956
1983
  hotkey panel that send selected text or the clipboard to your sessions.
1957
1984
 
1958
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.41.0...HEAD
1985
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.42.0...HEAD
1986
+ [0.42.0]: https://github.com/dm1try/samagotchi/compare/v0.41.0...v0.42.0
1959
1987
  [0.41.0]: https://github.com/dm1try/samagotchi/compare/v0.40.0...v0.41.0
1960
1988
  [0.40.0]: https://github.com/dm1try/samagotchi/compare/v0.39.0...v0.40.0
1961
1989
  [0.39.0]: https://github.com/dm1try/samagotchi/compare/v0.38.0...v0.39.0
data/docs/cli.md CHANGED
@@ -22,7 +22,7 @@
22
22
  - `chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]` (or `--dismiss`) — answer the question a session waits on (the one `chi send --wait` exited 3 with), then wait and print what comes next as `chi send --wait` does: the reply (0), the next question (3), still running after `--timeout` (4). `--option` is 1-based or the label, repeated on a multi-select question; `--text` is free text, or a Deny's reason; `--dismiss` leaves it unanswered and the model finishes its reply. A question no longer open (the web answered first) isn't answered again: it waits for that turn's reply. An option the question doesn't offer exits 2; a worker that is gone exits 1 (`send the task again: chi send --wait -m "…" ID`). An approval can be denied, not allowed, unless `guardrails.parent_approvals: once` (a convention, not a security boundary; see [Guardrails](guardrails.md#approvals-from-a-parent-agent))
23
23
  - `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text or images to a session (live, stopped or new) as a message (⏎, a turn runs) or a context note (⌘⏎) (see [Desktop helper](desktop.md))
24
24
  - `chi models [--format text|json] [--timeout S] [TEXT]` — list the models every configured host offers, as the names `--model` takes (see [Listing the models](#listing-the-models))
25
- - `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles. Run by a session's `execute`, the `model` row (and host, loop, profile, thinking, served model) is **that session's** model, labelled `(this session <id8>; default …)`; elsewhere it is the default, `(default)`. `model key` is the memory overlay key (`<name>.<key>.md`). `chi self --model` prints only the model a new session starts on, an alias resolved to its target (the desktop helper's hint)
25
+ - `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles. Run by a session's `execute`, the `model` row (and host, loop, profile, thinking, served model) is **that session's** model, labelled `(this session <id8>; default …)`; elsewhere it is the default, `(default)`. `model key` is the memory overlay key (`<name>.<key>.md`), and `model notes` the model notes a prompt of that model loads (`model_notes_deepseek (system, 612 chars)`, `none`; inside a session its `--mute` list is left out). `chi self --model` prints only the model a new session starts on, an alias resolved to its target (the desktop helper's hint)
26
26
  - `chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, in one table (see [Updating](#updating))
27
27
  - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Plugins](plugins.md#the-btw-bundle), [the mcp bundle](plugins.md#the-mcp-bundle) [the loop-guard bundle](plugins.md#the-loop-guard-bundle), [the check-in bundle](plugins.md#the-check-in-bundle) and [the skills bundle](plugins.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument
28
28
 
@@ -340,7 +340,8 @@ note or image) is deleted as its worker exits, and `/exit` says so; set
340
340
 
341
341
  `chi sessions stop ID...` stops each session's worker and waits for it to exit, so
342
342
  a `chi --resume ID` after it starts a fresh one. A worker still running an
343
- older chi (from before an upgrade) takes turns but not commands; the attached
343
+ older chi (from before an upgrade) takes turns, but not commands, or not the
344
+ ones newer than it (`/llm-context`, `--llm-context` on `--attach`); the attached
344
345
  terminal and the Web UI say so, with that restart line.
345
346
 
346
347
  `chi sessions restart ID...` hands each session to a new worker on the newest
@@ -428,7 +429,7 @@ request (with the cached tokens, the cache writes, the re-prefilled tokens and t
428
429
  reports them),
429
430
  the decode speed, generation latency, cancellations, retries (the network's),
430
431
  thinking cuts (generations a plugin such as loop-guard cut) and output cap hits
431
- (generations that ended at the provider's cap), context used and window, the memory indexes the session's prompt holds ([Memory](memory.md#the-indexs-size)), the prompt profile and the model the server says it ran, and the
432
+ (generations that ended at the provider's cap), context used and window, the memory indexes the session's prompt holds ([Memory](memory.md#the-indexs-size)), the model notes it carried (`model notes: model_notes_deepseek (system, 612 chars)`, none without any; [Memory](memory.md#model-notes)), the prompt profile and the model the server says it ran, and the
432
433
  cost when the provider reports one (OpenRouter's `usage.cost`; no prices are
433
434
  guessed). The speed line is the last generation's and the session's average
434
435
  (weighted by tokens): llama.cpp reports it exactly (`87 tok/s out, 1.9k tok/s
@@ -708,7 +709,7 @@ In interactive assist mode, you can switch the request model without restarting:
708
709
  - `/model host:model` or `/model host:alias`: qualified host routing (only `:` names a host; `openai/gpt-4o` is a model id). `host:alias` applies the alias; an alias whose target names another host is refused (`alias 'tiny' names host 'box', not 'openrouter'`). Aliases apply once: an alias pointing to another alias sends that name as written.
709
710
  - `/model --default <name>`: set session model and persist as new default (`default.model`) in `config.yml` for future sessions (supports `host:model` full ref). It writes only the file: an exported `SAMAGOTCHI_DEFAULT_MODEL` (or `--model`) still wins over it.
710
711
  - `/model <name> --alias <alias>`: create alias for current effective model (alias value may be bare or `host:model`).
711
- - `/model`: show the effective model (and default when diverged: `runtime model: <effective> (default: <default>, profile=<name>, <source>)`, e.g. `profile=qwen36, server (chat_template)`).
712
+ - `/model`: show the effective model (and default when diverged: `runtime model: <effective> (default: <default>, profile=<name>, <source>)`, e.g. `profile=qwen36, server (chat_template)`). When the session's prompt carries model notes the line ends with `; notes: model_notes_deepseek (system, 612 chars)`; a switch's `runtime model set to …` names the new model's ([Memory](memory.md#model-notes)).
712
713
  - `/model clear` (or `default`/`none`/`off`): clear the session override, reverting to the configured default.
713
714
  - `/guardrails`: the guardrail rules (by source), what failed to load, and your stored approvals, numbered; `/guardrails revoke N` removes approval N (see [Guardrails](guardrails.md)).
714
715
  - `/models`: list model ids aggregated across all `hosts:` (an alias shows next to its id: a bare alias on every host listing the id, a `host:model` one only under its host; grouped `host (host:port):` with per-host `unreachable` warnings, e.g. an unset `api_key_env`; lists cached 60s, 10 minutes for a remote host; lazy — no startup prefill). At most 20 ids per host, then `… and N more`; `/models <text>` lists every id containing `<text>` (any case), e.g. `/models qwen` on OpenRouter.
@@ -885,8 +886,9 @@ Behavior notes:
885
886
  tool steps keeps them, as a cancel does, with the dim `partial progress kept; !rollback restores the pre-turn state`
886
887
  (the model reads a note saying how many steps ran and why it stopped; `chi send --wait` and a delegating parent
887
888
  read `the turn failed after N tool steps: <why>; its work so far stays`). The
888
- REPL and attached mode say the same; the web says `■ canceled (Ctrl-C)` (`stopped` for its Stop button,
889
- `by a hook` for a hook's). A turn a bundle's plugin stopped names it: `■ turn stopped by loop-guard` (the web:
889
+ REPL and attached mode say the same; the web ends a failed turn whose steps stayed with `✕ turn failed: <summary>;
890
+ partial progress kept (!rollback restores the pre-turn state)`, after a reload too, and says `■ canceled (Ctrl-C)`
891
+ (`stopped` for its Stop button, `by a hook` for a hook's). A turn a bundle's plugin stopped names it: `■ turn stopped by loop-guard` (the web:
890
892
  `■ stopped by loop-guard`).
891
893
  - Visible text the canceled request had streamed stays in the conversation, marked `[interrupted]`, so the next
892
894
  message (or a continue) picks up from the half-finished reply; the canceled request's thinking and any unfinished
data/docs/memory.md CHANGED
@@ -88,6 +88,24 @@ Working habits for this model:
88
88
  - **Timing**: a note takes effect at the next prompt build: a session's start,
89
89
  a resume, a web worker waking, `/model`. A note written mid-session doesn't
90
90
  change the running prompt.
91
+ - **Recorded and shown**: each prompt build writes the notes it carried to the
92
+ session file (`prompt_notes`: name, scope, size in characters and a short
93
+ digest of the text), so a session says later what it ran with; `/model`
94
+ switches record the new model's. `/stats` has a
95
+ `model notes: model_notes_deepseek (system, 612 chars)` line, `/model`
96
+ ends its model line with `; notes: …` (after a switch, the new model's), the
97
+ web's info bar a `notes: deepseek` chip (the full line in its tooltip), and
98
+ `chi self` a `model notes` row after `model key`: the notes a prompt of the
99
+ reported model loads now, the session's `--mute` list left out when it runs
100
+ in one, `none` without any. None of the others shows a line without notes.
101
+ A resumed session, or a web worker that woke, shows the saved ones until its
102
+ first turn builds the prompt again.
103
+ - **Judged**: `ruby script/model_notes_report.rb` (a development script, not
104
+ shipped) groups stored sessions by model and recorded notes and compares the
105
+ calls to the first edit and commit, commits per 100 steps, bare `&`, the
106
+ longest run without an edit and the Continues and steers each needed; run a
107
+ note on and muted for the same kind of task. See
108
+ [testing.md](testing.md) ("The model notes report").
91
109
  - Without a model note the system prompt is what it was.
92
110
 
93
111
 
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. 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).
68
+ - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only, and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, sampling?}], warning?}`; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
69
69
  - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
70
70
  - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
71
71
  - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
@@ -198,8 +198,8 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
198
198
  - **Delegate reports: a child's reply comes back by itself.** When a child ends a turn that was its parent's (its task, a follow-up, a continue the parent answered, a reminder: not a turn its user typed into it directly), it rings the parent: a small file in the parent's `children/` dir, written after the child's turn is saved. So does a question the parent's model decides (the child's own `ask_user_question`, the step-limit continue; not an approval or a hook's question, which the user answers on the child's card), the turn after such a question whoever starts it, and the child's worker crashing. The parent reads the child as `delegate_result` would and hands its model a **report**: the same text (`session:`, `status:`, the reply), under its own header (`[Delegate report, delivered by chi …; not your user's message. Tell your user what it found …]`), as an input message with `source: "delegate_report"`.
199
199
  - Parent mid-turn: the report joins the turn at its next iteration boundary (a "delegate report" bubble in the web, `delegate report> ab12cd34 answered: …` in the attached terminal).
200
200
  - Parent idle: it runs a turn for the report (a *wake turn*, `last_turn.origin: "delegate_report"`) within the worker's 5 s tick, or wakes a worker for it when the parent's has idle-exited. Several reports that wait together are one turn and one message. A stopped, archived or scratch parent, or one open in a `--no-shared` REPL, is not woken: the rings wait on disk and join the next turn it runs (the user's message first). A worker just started (`chi send` to a stopped parent) runs no wake turn in its first 2 s, so the message it was started for goes first.
201
- - The parent's cursor per child (the newest reply it was given and the child as it was then) is in its `delegates.json`, shared with `delegate_result` and `wait: true`: a reply the model already had is never reported again, also after the parent's worker restarts. A ring is deleted only when the turn that took its report is kept; a turn that fails leaves it for the next one (and the report isn't handed back to the composer).
202
- - Limits: `session.max_wakes` (default `10`) wake turns in a row with no human input; past it the reports wait for the next message and the UIs show `N delegate reports waiting; they join your next message`. A wake turn that fails pauses wakes until the next human input; its reports' rings stay, so they join that input's turn, and the failure note tells the model so. A continue offer waiting in the parent takes the reports into its continue turn instead.
201
+ - The parent's cursor per child (the newest reply it was given and the child as it was then) is in its `delegates.json`, shared with `delegate_result` and `wait: true`: a reply the model already had is never reported again, also after the parent's worker restarts. A ring is deleted only when the turn that took its report stays in the conversation (completed, canceled, or failed but kept: one that got to tool steps, or a reminder or continue turn, which keep what they read); a turn rolled back (a failed one that got nowhere, a canceled continue turn) leaves it for the next one (and the report isn't handed back to the composer). So the model reads each report once, unless a later `!rollback` erases a kept turn (what it read goes with it).
202
+ - Limits: `session.max_wakes` (default `10`) wake turns in a row with no human input; past it the reports wait for the next message and the UIs show `N delegate reports waiting; they join your next message`. A wake turn that fails, or is stopped before the model call (a Stop, a `before_turn` hook's cancel), pauses wakes until the next human input; its reports' rings stay, so they join that input's turn, and a failure's note tells the model so. A continue offer waiting in the parent takes the reports into its continue turn instead.
203
203
  - `session.delegate_reports`: `wake` (default), `queue` (no wake turns: reports join the parent's next turn) or `off` (no rings; only `delegate_result` asks, as before). Env `SAMAGOTCHI_SESSION_DELEGATE_REPORTS`, `SAMAGOTCHI_SESSION_MAX_WAKES`.
204
204
  - The web's bell rings at a wake turn's end however short (nobody started it, so nobody watched it). The text a model writes about a report mid-turn, before its next tool call, stays visible after the report's bubble once the turn ends.
205
205
  - Every child starts with the shipped `delegated` system memory preloaded (`preloaded_memory_names: ["system/delegated"]`; `--mute delegated` would hide it): put everything in the final reply, don't delegate further, don't write memories or change config, stay in the folder. Its system prompt also says `Delegated by session <parent id>`. The parent's own `--mute` list does not carry over.
data/docs/testing.md CHANGED
@@ -71,3 +71,19 @@ strategies (freed tokens, wrongly forgotten outputs, one-step re-reads, re-prefi
71
71
  model's answers to chi's offer, with its call rate and notes). It is opt-in: the suite
72
72
  runs its code only on small synthetic sessions, never on yours. See
73
73
  [internals/llm-context-bench.md](internals/llm-context-bench.md).
74
+
75
+ ## The model notes report
76
+
77
+ `ruby script/model_notes_report.rb` reads a sessions folder (`--sessions DIR`, else chi's own) and groups the
78
+ sessions by model and by the model notes their prompt carried (the session file's `prompt_notes`, as
79
+ `name@digest`; `none` without notes, or in a file from before they were recorded). For each session and group it
80
+ prints the numbers a model note is meant to move: the place of the first edit (an `edit` or `write` call) and of the
81
+ first commit (an `execute` call that runs `git [options] commit`) among the tool calls, commits per 100 steps (model
82
+ requests), `execute` calls with a bare `&`, the longest run of calls with no edit, and what the session needed from
83
+ outside: Continues (the turns in its `analytics.json` that had no prompt of their own), steers (lines that reached a
84
+ running turn from the user, `chi send` or a parent, and a Continue's text), follow-up prompts and plugin nudges. A
85
+ group has the medians of the places and runs, commits per 100 steps pooled and the counts summed. `--help` defines
86
+ each column; `--model GLOB`, `--since DATE` and `--min-steps N` filter, `--json` gives JSON. It reads only and prints
87
+ no session text (ids, model names, note names and digests, dates, numbers), and the suite runs it only on small
88
+ synthetic sessions. It is how a note is judged on and off (`chi --mute model_notes_<name>`) for the same kind of
89
+ task: see [memory.md](memory.md) ("Model notes").
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.41.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:
@@ -110,9 +110,13 @@ module Samagotchi
110
110
  # thread, the drain inside them).
111
111
  #
112
112
  # A turn takes reports (#take, at each boundary) and the worker settles
113
- # them as the turn ends: #commit when it is kept (completed or canceled):
114
- # the cursors move on (DelegateCursors) and the rings go; #release when it
115
- # failed: the next turn reads the same rings again.
113
+ # them as the turn ends: #commit when the turn stays in the conversation
114
+ # (completed, canceled, or failed but kept: its work stayed, or a reminder
115
+ # or continue turn's): the cursors move on (DelegateCursors) and the rings
116
+ # go; #release when its end rolled it back (a failed turn that got
117
+ # nowhere, a canceled continue turn): the next turn reads the same rings
118
+ # again. So the model gets each report once (a later !rollback that
119
+ # erases a kept turn drops what it read, as before).
116
120
  class ChildReports
117
121
  SOURCE = Steer::DELEGATE_REPORT
118
122
 
@@ -178,7 +182,7 @@ module Samagotchi
178
182
  @taken = {}
179
183
  end
180
184
 
181
- # The turn failed (rolled back): its rings stay for the next one.
185
+ # The turn was rolled back: its rings stay for the next one.
182
186
  def release
183
187
  @taken = {}
184
188
  end
@@ -75,7 +75,9 @@ module Samagotchi
75
75
  end
76
76
 
77
77
  # @param argv [Array<String>] the arguments after "context"
78
- def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, env: ENV, cwd: Dir.pwd)
78
+ # @param clock [#call] now, for ls's ages ("4s ago")
79
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, env: ENV, cwd: Dir.pwd,
80
+ clock: -> { Time.now })
79
81
  @argv = argv.dup
80
82
  @stdin = stdin
81
83
  @stdout = stdout
@@ -83,6 +85,7 @@ module Samagotchi
83
85
  @state_dir = state_dir || Session.default_state_dir
84
86
  @env = env
85
87
  @cwd = cwd
88
+ @clock = clock
86
89
  end
87
90
 
88
91
  # @return [Integer] exit status: 0 done, 1 refused or failed, 2 usage
@@ -437,7 +440,7 @@ module Samagotchi
437
440
  def age(iso)
438
441
  return "-" unless iso
439
442
 
440
- seconds = (Time.now - Time.iso8601(iso)).to_i
443
+ seconds = (@clock.call - Time.iso8601(iso)).to_i
441
444
  if seconds < 60 then "#{seconds}s ago"
442
445
  elsif seconds < 3600 then "#{seconds / 60}m ago"
443
446
  elsif seconds < 86_400 then "#{seconds / 3600}h ago"
@@ -54,6 +54,7 @@ require_relative "engine_peers"
54
54
  require_relative "bundle_needs"
55
55
  require_relative "model_overlay"
56
56
  require_relative "model_notes"
57
+ require_relative "prompt_note"
57
58
  require_relative "served_model"
58
59
  require_relative "image_store"
59
60
  require_relative "vision_context"
@@ -478,6 +479,26 @@ module Samagotchi
478
479
  muted: @muted_memory_names)
479
480
  end
480
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
+
481
502
  # +name+'s resolved ref (ModelRef#ref).
482
503
  def model_ref_for(name)
483
504
  @host_registry.model_ref(name).ref
@@ -856,6 +877,9 @@ module Samagotchi
856
877
  @profile_resolution = nil
857
878
  sync_model_key!
858
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
859
883
  sync_kernel_client!
860
884
  @client.invalidate_context_window!
861
885
  @metrics.forget_model_reports!
@@ -921,7 +945,8 @@ module Samagotchi
921
945
  snapshot = @metrics.snapshot
922
946
  target = @host_registry.resolve(@effective_model_name)
923
947
  served, served_for = served_model_for(snapshot, target: target)
924
- snapshot = snapshot.merge(served_model: served, served_model_for: served_for, llm_context: llm_context_summary)
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))
925
950
  unless snapshot.dig(:context, :window_tokens)
926
951
  window = current_context_window(target)
927
952
  if window
@@ -1028,6 +1053,8 @@ module Samagotchi
1028
1053
  # :metrics [Hash] @metrics.snapshot (per-session analytics)
1029
1054
  # :pending_question [Hash, nil] current pending structured question
1030
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)
1031
1058
  # :parent_id [String, nil] the session that delegated this one
1032
1059
  # :model_name [String] the model turns run on now (after /model)
1033
1060
  # :served_model, :served_model_for [String, nil] what a generation of
@@ -1048,6 +1075,7 @@ module Samagotchi
1048
1075
  used_memory_names: @used_memories.names,
1049
1076
  preloaded_memory_names: preloaded_memory_names,
1050
1077
  muted_memory_names: @muted_memory_names.dup,
1078
+ prompt_notes: prompt_notes.map(&:to_h),
1051
1079
  parent_id: @session&.parent_id,
1052
1080
  model_name: effective_model_ref,
1053
1081
  served_model: served_pair[0],
@@ -1462,6 +1490,8 @@ module Samagotchi
1462
1490
  prompt = @prompt_builder.build(chat: chat, thinking: level, layers: llm_context_layers(target))
1463
1491
  # The memory indexes this prompt holds: /stats, the snapshot, analytics.json.
1464
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)
1465
1495
  prompt
1466
1496
  end
1467
1497
 
@@ -49,21 +49,24 @@ module Samagotchi
49
49
  # @param muted [Array<String>] the session's muted names (MutedMemories)
50
50
  # @param small [#call, nil] → whether the model is a small one; nil:
51
51
  # guardrails.small_models (Guardrails::ModelSize)
52
+ # @param env [Hash] the XDG env the memories dirs resolve from
53
+ # @param warn [Boolean] false: no size warnings (a read-only report such
54
+ # as chi self; the prompt's build gives them)
52
55
  # @return [Array<Note>]
53
- def for(name:, key:, fallback_key: nil, muted: [], small: nil)
56
+ def for(name:, key:, fallback_key: nil, muted: [], small: nil, env: ENV, warn: true)
54
57
  return [] if name.nil? || name.to_s.strip.empty?
55
58
 
56
59
  small ||= -> { Guardrails::ModelSize.small?(name, key) }
57
60
  small_once = memo(small)
58
61
  seen = Set.new
59
62
  notes = SCOPES.flat_map do |scope|
60
- files(scope).filter_map do |path|
63
+ files(scope, env).filter_map do |path|
61
64
  next unless seen.add?(File.expand_path(path))
62
65
 
63
66
  note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once)
64
67
  end
65
68
  end
66
- warn_sizes(notes)
69
+ warn_sizes(notes) if warn
67
70
  notes
68
71
  end
69
72
 
@@ -96,7 +99,7 @@ module Samagotchi
96
99
  @warn_mutex.synchronize { @warned.clear }
97
100
  end
98
101
 
99
- def files(scope) = files_in(MemoryPaths.scope_dir(scope))
102
+ def files(scope, env = ENV) = files_in(MemoryPaths.scope_dir(scope, env: env))
100
103
 
101
104
  # The `model_notes_*.md` files in +dir+, by their names on disk: the
102
105
  # prefix exactly as written (a case-insensitive disk's glob matches
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A model note a session's system prompt carried (ModelNotes::Note
5
+ # without its body): what the session file's `prompt_notes` records at
6
+ # each prompt build, so /stats, /model, the web and a later comparison
7
+ # read what the prompt really held. name: the memory name; scope:
8
+ # "system" or "project"; chars: the body's length in the prompt; digest:
9
+ # a short SHA-256 of that body (ModelNotes::Note#digest).
10
+ PromptNote = Data.define(:name, :scope, :chars, :digest) do
11
+ # @param note [ModelNotes::Note]
12
+ def self.from_note(note)
13
+ new(name: note.name, scope: note.scope, chars: note.chars, digest: note.digest)
14
+ end
15
+
16
+ # A saved or wire record (string or symbol keys); nil without a name.
17
+ def self.from_h(raw)
18
+ return raw if raw.is_a?(PromptNote)
19
+ return nil unless raw.is_a?(Hash)
20
+
21
+ get = ->(key) { raw.key?(key.to_s) ? raw[key.to_s] : raw[key] }
22
+ name = get.call(:name).to_s.strip
23
+ return nil if name.empty?
24
+
25
+ chars = get.call(:chars)
26
+ new(name: name, scope: get.call(:scope)&.to_s, chars: chars.is_a?(Numeric) ? chars.to_i : nil,
27
+ digest: get.call(:digest)&.to_s)
28
+ end
29
+
30
+ # The records of a saved list (others dropped).
31
+ # @return [Array<PromptNote>]
32
+ def self.list(raw)
33
+ Array(raw).filter_map { |entry| from_h(entry) }
34
+ end
35
+
36
+ # "model_notes_deepseek (system, 612 chars), model_notes_x (project,
37
+ # 80 chars)": the line /model, /stats and chi self show; "" without
38
+ # notes. As format.js promptNotesText (spec/shared/labels_matrix.json).
39
+ # @param notes [Array<PromptNote, Hash>]
40
+ def self.text(notes)
41
+ list(notes).map(&:label).join(", ")
42
+ end
43
+
44
+ # "model_notes_deepseek (system, 612 chars)".
45
+ def label
46
+ details = [scope, chars && "#{chars} chars"].compact.reject { |part| part.to_s.empty? }
47
+ details.empty? ? name : "#{name} (#{details.join(", ")})"
48
+ end
49
+
50
+ # The session file's record (string keys).
51
+ def to_file = to_h.transform_keys(&:to_s)
52
+ end
53
+ end
@@ -10,6 +10,8 @@ require_relative "session"
10
10
  require_relative "log_path"
11
11
  require_relative "model_profile"
12
12
  require_relative "model_overlay"
13
+ require_relative "model_notes"
14
+ require_relative "prompt_note"
13
15
  require_relative "thinking"
14
16
  require_relative "served_model"
15
17
  require_relative "host_registry"
@@ -59,6 +61,7 @@ module Samagotchi
59
61
  ["log", log_path(env)],
60
62
  ["model", model ? model_label(model, default, env) : "(not configured)"],
61
63
  ["model key", model ? model_key_for(model, env) : "-"],
64
+ ["model notes", model ? model_notes_for(model, env) : "-"],
62
65
  ["host", model ? host_for(model, env) : "-"],
63
66
  ["api key", model ? api_key_for(model, env) : "-"],
64
67
  ["loop", model ? loop_for(model, env) : "-"],
@@ -187,6 +190,35 @@ module Samagotchi
187
190
  ModelOverlay.key_for(model) || "-"
188
191
  end
189
192
 
193
+ # "model_notes_deepseek (system, 612 chars)": the model notes a prompt
194
+ # of +model+ loads (ModelNotes: matched on its bare id and key, the
195
+ # session's --mute list left out when chi self runs in one), "none"
196
+ # without any. Read from the memories dirs now: a session's prompt
197
+ # takes them at its next build (/stats names the ones it carries).
198
+ def model_notes_for(model, env)
199
+ bare = HostRegistry.new(env: env).bare_name(model)
200
+ notes = ModelNotes.for(name: bare, key: ModelOverlay.key_for(bare), muted: session_muted(env), env: env,
201
+ warn: false)
202
+ text = PromptNote.text(notes.map { |note| PromptNote.from_note(note) })
203
+ text.empty? ? "none" : text
204
+ rescue StandardError => e
205
+ "(unreadable: #{e.message})"
206
+ end
207
+
208
+ # The --mute list of the session chi self runs in (its execute exports
209
+ # SAMAGOTCHI_PARENT_SESSION); [] outside one.
210
+ def session_muted(env)
211
+ id = env[PARENT_SESSION_ENV].to_s
212
+ return [] if id.empty? || !Session.valid_id?(id)
213
+
214
+ state_dir = Session.default_state_dir(env: env)
215
+ return [] unless Session.exist?(id, state_dir: state_dir)
216
+
217
+ MutedMemories.normalize_list(Session.load(id, state_dir: state_dir).muted_memory_names)
218
+ rescue StandardError
219
+ []
220
+ end
221
+
190
222
  def model_name
191
223
  ModelProfile.required_model_name
192
224
  rescue ArgumentError
@@ -7,6 +7,7 @@ require "time"
7
7
  require_relative "atomic_file"
8
8
  require_relative "context_note"
9
9
  require_relative "llm_context_override"
10
+ require_relative "prompt_note"
10
11
 
11
12
  require_relative "paths"
12
13
  require_relative "project_scope"
@@ -44,6 +45,17 @@ module Samagotchi
44
45
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
45
46
  # Names as given; the engine normalizes them.
46
47
  attr_accessor :preloaded_memory_names, :muted_memory_names
48
+ # The model notes the session's system prompt carried at its last build
49
+ # (Array<PromptNote>: name, scope, chars, digest; ModelNotes), set by the
50
+ # Engine at each build (a start, a resume's or a woken worker's first
51
+ # turn, /model) and saved, so what a session ran with can be read later.
52
+ attr_reader :prompt_notes
53
+
54
+ # @param notes [Array<PromptNote, Hash>] a file's hashes are read
55
+ # (PromptNote.list)
56
+ def prompt_notes=(notes)
57
+ @prompt_notes = PromptNote.list(notes)
58
+ end
47
59
  # The name model_name was typed as when that differs from the resolved
48
60
  # ref stored in model_name (an alias: `small` for `gemma-small`), else
49
61
  # nil. The models: lookup takes it (HostRegistry#lookup_names).
@@ -118,7 +130,7 @@ module Samagotchi
118
130
  first_preview: "", test_run: false, pending_question: nil,
119
131
  used_memory_names: [], project_root: nil,
120
132
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
121
- last_turn: nil, model_typed: nil, delegate: false, llm_context: nil)
133
+ last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [])
122
134
  @id = id
123
135
  @metadata_version = metadata_version
124
136
  @mode = mode
@@ -136,6 +148,7 @@ module Samagotchi
136
148
  @used_memory_names = self.class.name_list(used_memory_names)
137
149
  @preloaded_memory_names = self.class.name_list(preloaded_memory_names)
138
150
  @muted_memory_names = self.class.name_list(muted_memory_names)
151
+ self.prompt_notes = prompt_notes
139
152
  @project_root = project_root
140
153
  @parent_id = parent_id&.to_s
141
154
  @scratch = !!scratch
@@ -269,6 +282,7 @@ module Samagotchi
269
282
  "project_root" => nil,
270
283
  "preloaded_memory_names" => [],
271
284
  "muted_memory_names" => [],
285
+ "prompt_notes" => [],
272
286
  "parent_id" => nil,
273
287
  "scratch" => false,
274
288
  "last_turn" => nil,
@@ -393,6 +407,7 @@ module Samagotchi
393
407
  when "test_run" then !!@test_run
394
408
  when "delegate" then @delegate
395
409
  when "llm_context" then llm_context_file
410
+ when "prompt_notes" then @prompt_notes.map(&:to_file)
396
411
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names"
397
412
  Array(instance_variable_get(:"@#{key}"))
398
413
  else instance_variable_get(:"@#{key}")
@@ -3,6 +3,7 @@
3
3
  require_relative "config"
4
4
  require_relative "model_profile"
5
5
  require_relative "served_model"
6
+ require_relative "prompt_note"
6
7
  require_relative "turn_flow"
7
8
  require_relative "tools/execute"
8
9
  require_relative "tools/builtins"
@@ -110,6 +111,14 @@ module Samagotchi
110
111
  @builtin_registry ||= register_builtins(Commands::Registry.new).freeze
111
112
  end
112
113
 
114
+ # The name of this chi's own session command +line+ is (one a worker
115
+ # runs), or nil. A worker that answers such a line unknown_command runs
116
+ # an older chi that doesn't have it yet (/llm-context against 0.39.0):
117
+ # the UIs say so (BridgeClient.stale_worker_message) instead of "not a
118
+ # session command".
119
+ # @return [String, nil]
120
+ def self.builtin_name(line) = builtin_registry.lookup(line)&.name
121
+
113
122
  # @return [String] the model /model clear goes back to
114
123
  attr_reader :default_model
115
124
 
@@ -362,9 +371,9 @@ module Samagotchi
362
371
  def model_command(input)
363
372
  suffix = input.delete_prefix(MODEL_COMMAND).strip
364
373
  if suffix.empty?
365
- return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}", false] if model_name == @engine.model_ref_for(@default_model)
374
+ return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}#{notes_note}", false] if model_name == @engine.model_ref_for(@default_model)
366
375
 
367
- return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}", false]
376
+ return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}#{notes_note}", false]
368
377
  end
369
378
 
370
379
  # Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
@@ -434,14 +443,14 @@ module Samagotchi
434
443
  return ["--default cannot be combined with clear/default/none/off", false] if persist_default
435
444
 
436
445
  switch_model(@default_model)
437
- return ["runtime model reset to #{model_name}#{model_note}", true]
446
+ return ["runtime model reset to #{model_name}#{model_note}#{notes_note}", true]
438
447
  end
439
448
 
440
449
  switch_model(arg, persist_default: persist_default)
441
450
  if persist_default
442
- ["runtime model set to #{model_name}#{model_note} and default updated", true]
451
+ ["runtime model set to #{model_name}#{model_note} and default updated#{notes_note}", true]
443
452
  else
444
- ["runtime model set to #{model_name}#{model_note}", true]
453
+ ["runtime model set to #{model_name}#{model_note}#{notes_note}", true]
445
454
  end
446
455
  end
447
456
  end
@@ -482,6 +491,16 @@ module Samagotchi
482
491
  ""
483
492
  end
484
493
 
494
+ # "; notes: model_notes_deepseek (system, 612 chars)": the model notes
495
+ # the session's prompt carries (after a switch, the ones the new
496
+ # model's prompt loads); "" without any.
497
+ def notes_note
498
+ text = PromptNote.text(@engine.prompt_notes)
499
+ text.empty? ? "" : "; notes: #{text}"
500
+ rescue StandardError
501
+ ""
502
+ end
503
+
485
504
  # "; thinking: off (models: qwen)" when the model has a level set.
486
505
  def thinking_note
487
506
  summary = @engine.thinking_summary
@@ -37,6 +37,8 @@ module Samagotchi
37
37
  # The most turns (with their tool calls) a live snapshot carries: the
38
38
  # unsaved ones of a collector whose saves keep failing stay bounded.
39
39
  LIVE_RECORDS_CAP = 20
40
+ # The most characters of a failed turn's error message its record keeps.
41
+ FAILURE_MESSAGE_CHARS = 2000
40
42
 
41
43
  # A generation shorter than this gets no decode speed of its own: one
42
44
  # tiny generation would skew the session's average.
@@ -308,7 +310,7 @@ module Samagotchi
308
310
  when :turn_canceled
309
311
  end_turn(status: "canceled", reason: event[:cancellation_reason], by: event[:cancelled_by])
310
312
  when :turn_failed
311
- end_turn(status: "failed")
313
+ end_turn(status: "failed", failure: failure_fields(event))
312
314
  end
313
315
 
314
316
  @mutex.synchronize { @last_activity_at = now.iso8601(3) }
@@ -497,9 +499,10 @@ module Samagotchi
497
499
  end
498
500
 
499
501
  # A canceled turn's record keeps why (+reason+: "user", "ctrl_c", ...)
500
- # and who stopped it (+by+: a hook's bundle), so a reloaded web history
501
- # can show the cancel line the live one did.
502
- def end_turn(status: "completed", reason: nil, by: nil)
502
+ # and who stopped it (+by+: a hook's bundle), a failed one what its line
503
+ # says (+failure+, #failure_fields), so a reloaded web history can show
504
+ # the cancel or failure line the live one did.
505
+ def end_turn(status: "completed", reason: nil, by: nil, failure: nil)
503
506
  @mutex.synchronize do
504
507
  if @turn
505
508
  # A turn that failed mid-stream never saw its generation end:
@@ -522,6 +525,7 @@ module Samagotchi
522
525
  }
523
526
  record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
524
527
  record[:cancelled_by] = by.to_s unless by.nil? || by.to_s.empty?
528
+ record[:failure] = failure if failure
525
529
  record.merge!(turn_token_fields(@turn))
526
530
  @turn_records << record
527
531
  count_turn(record)
@@ -530,6 +534,19 @@ module Samagotchi
530
534
  end
531
535
  end
532
536
 
537
+ # What turn_failed's line is made of (format.js failedTurnText): a
538
+ # provider error's summary, else the message (capped: a server's error
539
+ # page can be long) and class, and the steps that stayed (kept_steps).
540
+ def failure_fields(event)
541
+ summary = event[:summary].to_s
542
+ return { summary: summary, kept_steps: event[:kept_steps] }.compact unless summary.empty?
543
+
544
+ message = event[:message].to_s
545
+ message = "#{message[0, FAILURE_MESSAGE_CHARS - 1]}…" if message.length > FAILURE_MESSAGE_CHARS
546
+ { message: message.empty? ? nil : message, error_class: event[:error_class]&.to_s,
547
+ kept_steps: event[:kept_steps] }.compact
548
+ end
549
+
533
550
  # The model the server says answered, and the name asked for then (a
534
551
  # /model switch makes it stale until the next generation reports).
535
552
  def record_served_model(event)
@@ -80,13 +80,17 @@ module Samagotchi
80
80
  key = [chat, thinking, layers]
81
81
  @built ||= {}
82
82
  @index_sizes ||= {}
83
+ @notes_loaded ||= {}
83
84
  unless @built.key?(key)
85
+ @section_notes = nil
84
86
  @built[key] = system_prompt_with_index(
85
87
  assist_system_prompt(chat: chat, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
86
88
  )
87
89
  @index_sizes[key] = @measured_index
90
+ @notes_loaded[key] = @section_notes || []
88
91
  end
89
92
  @memory_index = @index_sizes[key]
93
+ @prompt_notes = @notes_loaded[key]
90
94
  @built[key]
91
95
  end
92
96
 
@@ -98,11 +102,19 @@ module Samagotchi
98
102
  # @return [Hash, nil]
99
103
  attr_reader :memory_index
100
104
 
105
+ # The model notes the prompt #build returned last carries
106
+ # (Array<ModelNotes::Note>, [] with none), nil before a build and after
107
+ # #reset!. Read when the prompt is built, as the section is.
108
+ # @return [Array<ModelNotes::Note>, nil]
109
+ attr_reader :prompt_notes
110
+
101
111
  # Drops the built prompts: the next #build reads the profile, tools,
102
112
  # indexes and memories again (a model or profile switch, changed tools).
103
113
  def reset!
104
114
  @built = nil
105
115
  @index_sizes = nil
116
+ @notes_loaded = nil
117
+ @prompt_notes = nil
106
118
  @stable_lengths = nil
107
119
  end
108
120
 
@@ -323,6 +335,7 @@ module Samagotchi
323
335
  # identity: a heading per scope, each note under its name, the models:
324
336
  # line left out. nil without any, so the prompt stays as it was.
325
337
  def model_notes_section
338
+ @section_notes = []
326
339
  notes = Array(@model_notes_lookup.call)
327
340
  return nil if notes.empty?
328
341
 
@@ -330,7 +343,7 @@ module Samagotchi
330
343
  notes.group_by(&:scope).map do |scope, list|
331
344
  heading = ref.to_s.empty? ? "Model notes (scope=#{scope}):" : "Model notes (for #{ref}, scope=#{scope}):"
332
345
  [heading, *list.map { |note| "## #{note.name}\n#{note.body}" }].join("\n\n")
333
- end.join("\n\n")
346
+ end.join("\n\n").tap { @section_notes = notes }
334
347
  rescue StandardError
335
348
  nil
336
349
  end
@@ -522,6 +522,9 @@ module Samagotchi
522
522
  return
523
523
  end
524
524
  return @screen.commit(stale_worker("run commands")) if reply.status == 404
525
+
526
+ name = older_worker_command(reply, line)
527
+ return @screen.commit(stale_worker("run #{name}")) if name
525
528
  return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
526
529
 
527
530
  detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
@@ -828,6 +831,14 @@ module Samagotchi
828
831
  # so it shows above what it got.
829
832
  def echo_answer(text) = @screen.commit("#{paint(QUESTION_PROMPT, 33)}#{text}")
830
833
 
834
+ # The name of this chi's own command +line+ when the worker answered it
835
+ # unknown_command (it runs an older chi without it), else nil.
836
+ def older_worker_command(reply, line)
837
+ return nil unless reply.status == 400 && reply.json&.fetch("error", nil) == "unknown_command"
838
+
839
+ SessionCommands.builtin_name(line)
840
+ end
841
+
831
842
  def stale_worker(cant)
832
843
  BridgeClient.stale_worker_message(@client.session_id, cant: cant)
833
844
  end
@@ -1002,6 +1013,7 @@ module Samagotchi
1002
1013
 
1003
1014
  why =
1004
1015
  if reply.status == 404 then stale_worker("run commands")
1016
+ elsif (name = older_worker_command(reply, line)) then stale_worker("run #{name}")
1005
1017
  elsif too_late?(reply) then LATE
1006
1018
  else "the worker answered #{reply.status}"
1007
1019
  end
@@ -6,6 +6,7 @@ require_relative "../image_store"
6
6
  require_relative "../turn_note"
7
7
  require_relative "../steer"
8
8
  require_relative "../memory_bundle/index_size"
9
+ require_relative "../prompt_note"
9
10
 
10
11
  module Samagotchi
11
12
  class TerminalUI
@@ -485,6 +486,9 @@ module Samagotchi
485
486
  lines << "prompt profile: #{snapshot[:profile]} (#{snapshot[:profile_source]})" if snapshot[:profile]
486
487
  memory_index = memory_index_text(snapshot[:memory_index])
487
488
  lines << "memory index: #{memory_index}" unless memory_index.empty?
489
+ # The model notes the session's prompt carried (Engine#prompt_notes).
490
+ notes = PromptNote.text(snapshot[:prompt_notes])
491
+ lines << "model notes: #{notes}" unless notes.empty?
488
492
  llm_context = llm_context_stats_text(snapshot[:llm_context])
489
493
  lines << "llm context: #{llm_context}" if llm_context
490
494
  if snapshot[:served_model]
@@ -46,11 +46,18 @@ module Samagotchi
46
46
  # (context summarizes the interrupted turn for a "no, <reason>")
47
47
  attr_reader :offer
48
48
 
49
+ # How many times the conversation went back to a checkpoint (a failed
50
+ # or canceled turn's restore, !rollback): a worker compares it across a
51
+ # turn to tell whether the turn's messages (the delegate reports it
52
+ # merged) left the conversation.
53
+ attr_reader :restores
54
+
49
55
  def initialize(engine:)
50
56
  @engine = engine
51
57
  @checkpoint = nil
52
58
  @continue_checkpoint = nil
53
59
  @offer = nil
60
+ @restores = 0
54
61
  end
55
62
 
56
63
  def awaiting_continue? = !@offer.nil?
@@ -198,6 +205,7 @@ module Samagotchi
198
205
  restored = Array(checkpoint) + arrived
199
206
  restored = TurnNote.replace_trailing(restored, note) if note
200
207
  @engine.rollback_to(restored)
208
+ @restores += 1
201
209
  end
202
210
 
203
211
  def conversation_of(result)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.41.0"
4
+ VERSION = "0.42.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -626,6 +626,8 @@ module Samagotchi
626
626
  # What the worker's server said it served for that model (after a turn).
627
627
  if snapshot
628
628
  session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"])
629
+ # The model notes its prompt carries now (a /model there changes them).
630
+ session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
629
631
  end
630
632
  # The LLM context strategy the next turn runs under (the info bar's
631
633
  # chip): the worker's, else worked out here from the session file.
@@ -680,8 +682,9 @@ module Samagotchi
680
682
  # history again (nor a render of every earlier answer). Live: the
681
683
  # worker's GET tail alone (no session file read); else the file. The
682
684
  # session part is what the page reads of it (id, status, used memory
683
- # names). ?turn_id=: that turn's answer (a queued turn's page re-reads
684
- # after the next one started). ?recent=1 (the page since this
685
+ # names, the model notes its prompt carried: the first turn's build or
686
+ # a /model records them). ?turn_id=: that turn's answer (a queued
687
+ # turn's page re-reads after the next one started). ?recent=1 (the page since this
685
688
  # release): the timing's records are the newest turn's (#recent_timing),
686
689
  # which the page merges by id; without it, the whole timing as before.
687
690
  def handle_tail_read(req, id)
@@ -692,12 +695,14 @@ module Samagotchi
692
695
  tail = live_tail(id, turn_id: turn_id)
693
696
  state = tail && tail["session_state_snapshot"]
694
697
  if state.is_a?(Hash)
695
- session_json = { id: id, status: state["status"], used_memory_names: Array(state["used_memory_names"]) }
698
+ session_json = { id: id, status: state["status"], used_memory_names: Array(state["used_memory_names"]),
699
+ prompt_notes: Array(state["prompt_notes"]) }
696
700
  messages = last_assistant_for_display([tail["answer"]].compact)
697
701
  else
698
702
  session = @session_class.load(id, state_dir: default_state_dir)
699
703
  state = nil
700
- session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names) }
704
+ session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names),
705
+ prompt_notes: session.prompt_notes.map(&:to_h) }
701
706
  messages = last_assistant_for_display(session.messages, turn_id: turn_id)
702
707
  end
703
708
  live_metrics = state && state["metrics"]
@@ -839,7 +844,12 @@ module Samagotchi
839
844
  when 202
840
845
  record_history(body, line) if PromptHistory.shell_line?(line)
841
846
  json_response(202, reply.json || { status: "accepted" })
842
- when 400 then error_response(400, reply.json&.dig("error") || "unknown_command", reply_detail(reply, "not a session command"))
847
+ when 400
848
+ # One of this chi's own commands the worker doesn't know: it runs an older chi.
849
+ name = reply.json&.dig("error") == "unknown_command" && SessionCommands.builtin_name(line)
850
+ next error_response(501, "not_supported", BridgeClient.stale_worker_message(id, cant: "run #{name}")) if name
851
+
852
+ error_response(400, reply.json&.dig("error") || "unknown_command", reply_detail(reply, "not a session command"))
843
853
  end
844
854
  end
845
855
  rescue SessionManager::OwnedByTUI => e
@@ -28,7 +28,7 @@ import { MODEL_KEY } from "./model_pick.js";
28
28
  import { emptySessionState } from "./session_state.js";
29
29
  import { createModelPicker } from "./model_picker.js";
30
30
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
31
- import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary } from "./format.js";
31
+ import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
32
32
  import { createTurnView, turnHistoryHtml } from "./turn_view.js";
33
33
  import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
34
34
  import { createStageView } from "./stage_view.js";
@@ -1126,6 +1126,7 @@ function renderSessionView(data) {
1126
1126
  current.usedMemories = s.used_memory_names || [];
1127
1127
  current.preloadedMemories = s.preloaded_memory_names || [];
1128
1128
  current.mutedMemories = s.muted_memory_names || [];
1129
+ current.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
1129
1130
  current.llmContext = s.llm_context || null;
1130
1131
  llmContextControl.refresh();
1131
1132
  current.parentId = s.parent_id || null;
@@ -1232,6 +1233,8 @@ async function readTurnEnd() {
1232
1233
  if (id !== selected) return;
1233
1234
  if (d.session) {
1234
1235
  if (Array.isArray(d.session.used_memory_names)) current.usedMemories = d.session.used_memory_names;
1236
+ // The notes chip: the first turn's build (or a /model) records them.
1237
+ if (Array.isArray(d.session.prompt_notes)) current.promptNotes = d.session.prompt_notes;
1235
1238
  if (d.session.status) current.status = d.session.status;
1236
1239
  if (!(await mergeTurnTiming(id, d.timing))) return;
1237
1240
  liveTurn.timing.finishLine(line);
@@ -1778,11 +1781,14 @@ function updateInfoBar() {
1778
1781
  const parentHtml = parentChip ? `<a class="model parent" href="${escapeHtml(sessionHash(current.parentId))}" title="${escapeHtml(parentChip.title)}">delegated by ${escapeHtml(String(current.parentId).slice(0, 8))}</a>` : "";
1779
1782
  // A parent names its delegates; the link opens all sessions.
1780
1783
  const kids = childrenSummary({ id: selected }, allSessions);
1784
+ // The model notes the session's prompt carried.
1785
+ const notes = promptNotesChip(current.promptNotes);
1786
+ const notesHtml = notes ? `<span class="model notes-chip" title="${escapeHtml(notes.title)}">${escapeHtml(notes.text)}</span>` : "";
1781
1787
  const kidsHtml = kids ? `<a class="model children-chip${kids.waitingText ? " waiting" : ""}" href="${escapeHtml(ALL_SESSIONS_HASH)}" title="${escapeHtml(`${kids.title} (all sessions)`)}">${escapeHtml(kids.text)}${kids.waitingText ? ` · <span class="kids-waiting">${escapeHtml(kids.waitingText)}</span>` : ""}</a>` : "";
1782
1788
  const attachCmd = `chi --attach ${selected}`;
1783
1789
  const copied = Date.now() - attachCopiedAt < 2000;
1784
1790
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1785
- const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1791
+ const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${notesHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1786
1792
  const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
1787
1793
  // The session clock ticks every second while a turn runs: only its text
1788
1794
  // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
@@ -301,3 +301,27 @@ export function memChipTitle(names) {
301
301
  const list = names || [];
302
302
  return list.length ? `memories: ${list.join(", ")}` : "no memories in this session";
303
303
  }
304
+
305
+ // "model_notes_deepseek (system, 612 chars), model_notes_x (project, 80
306
+ // chars)": the model notes the session's prompt carried (its prompt_notes);
307
+ // "" without any. As PromptNote.text (spec/shared/labels_matrix.json).
308
+ export function promptNotesText(notes) {
309
+ return (Array.isArray(notes) ? notes : [])
310
+ .filter((note) => note && typeof note.name === "string" && note.name.trim())
311
+ .map((note) => {
312
+ const details = [note.scope, Number.isFinite(note.chars) ? `${Math.trunc(note.chars)} chars` : null].filter(Boolean);
313
+ return details.length ? `${note.name.trim()} (${details.join(", ")})` : note.name.trim();
314
+ })
315
+ .join(", ");
316
+ }
317
+
318
+ // The info bar's model notes chip, for a session whose prompt carried
319
+ // some: "notes: deepseek" (the names without model_notes_), the full line
320
+ // its tooltip; null without notes.
321
+ export function promptNotesChip(notes) {
322
+ const text = promptNotesText(notes);
323
+ if (!text) return null;
324
+ const names = notes.filter((note) => note && typeof note.name === "string" && note.name.trim())
325
+ .map((note) => note.name.trim().replace(/^model_notes_/, ""));
326
+ return { text: `notes: ${names.join(", ")}`, title: `model notes in this session's prompt: ${text}` };
327
+ }
@@ -14,6 +14,9 @@ export function emptySessionState() {
14
14
  // in the info bar's tooltip).
15
15
  preloadedMemories: [],
16
16
  mutedMemories: [],
17
+ // The model notes the session's prompt carried (its prompt_notes: name,
18
+ // scope, chars, digest; the info bar's notes chip).
19
+ promptNotes: [],
17
20
  // A delegated session's parent (the info bar's "delegated by" chip).
18
21
  parentId: null,
19
22
  ctxPct: null,
@@ -233,8 +233,10 @@ function turnGroup(g, items, timing) {
233
233
 
234
234
  // A canceled turn that ran more than one step or any call has no answer:
235
235
  // its partial text stays in the block, as it does live (turn_view.js
236
- // turnEnded promotes only a completed turn or a lone step with no tools).
236
+ // turnEnded promotes only a completed turn or a lone step with no tools). A
237
+ // failed turn never answered: its texts are steps.
237
238
  function canceledSteps(g, record, tools) {
239
+ if (record?.status === "failed") return true;
238
240
  return record?.status === "canceled" && (g.texts.length > 1 || tools.length > 0);
239
241
  }
240
242
 
@@ -11,7 +11,7 @@
11
11
  // removed at the end: the answer bubble takes its place.
12
12
  import { leavesBlock } from "./card.js";
13
13
  import { copySourceAttr } from "./copy.js";
14
- import { escapeHtml, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
14
+ import { escapeHtml, failedTurnText, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
15
15
  import { thumbsHtml } from "./images.js";
16
16
  import { shouldFollowScroll } from "./scroll.js";
17
17
  import { hookNoticeLabel } from "./turn_events.js";
@@ -701,6 +701,17 @@ function setLabel(summary, gen) {
701
701
  summary.innerHTML = html;
702
702
  }
703
703
 
704
+ // A re-rendered failed turn's line (a .bubble.cancel, as live's
705
+ // addEndBubble), in turn_failed's words (failedTurnText) from what the turn
706
+ // record kept of it (SessionMetrics' failure: summary, message,
707
+ // error_class, kept_steps); "" for any other record, or a failed one saved
708
+ // before records kept that.
709
+ function failedLineHtml(record) {
710
+ const failure = record?.status === "failed" ? record.failure : null;
711
+ if (!failure || typeof failure !== "object") return "";
712
+ return `<div class="bubble cancel">${escapeHtml(failedTurnText(failure))}</div>`;
713
+ }
714
+
704
715
  // A reloaded history in the turn view (renderHistory's object path): per
705
716
  // turn the prompt, a collapsed block of its steps when it had any (texts,
706
717
  // and with the server's parts the thinking, params and output, from the
@@ -789,8 +800,10 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
789
800
  // timing after its prompt and steps, where the live view leaves it.
790
801
  else if (group.answer === null) parts.push(timingHtml(group));
791
802
  else parts.push(answerHtml(items[group.answer], timingHtml(group)));
792
- // A canceled turn ends with its cancel line, as the live view left it.
803
+ // A canceled turn ends with its cancel line, a failed one whose steps
804
+ // stayed with its failure line, as the live view left them.
793
805
  parts.push(cancelLineHtml(group.record, escapeHtml));
806
+ parts.push(failedLineHtml(group.record));
794
807
  return parts.join("");
795
808
  }).join("");
796
809
  }
@@ -47,6 +47,8 @@ module Samagotchi
47
47
  # The session's --memory and --mute lists (info-bar tooltip).
48
48
  preloaded_memory_names: Array(session.preloaded_memory_names),
49
49
  muted_memory_names: Array(session.muted_memory_names),
50
+ # The model notes its prompt carried (the info bar's notes chip).
51
+ prompt_notes: Array(session.prompt_notes).map(&:to_h),
50
52
  # The session that delegated this one (the `delegate` tool), else nil.
51
53
  parent_id: session.parent_id,
52
54
  # Started by the delegate tool (Session#delegate?); a fork has a
@@ -507,6 +507,7 @@ module Samagotchi
507
507
  @wakes.hand_over(reports)
508
508
  Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes.count!)
509
509
  @turn_flow.before_prompt_turn
510
+ restores = @turn_flow.restores
510
511
  run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
511
512
  if error
512
513
  # Rolled back, the rings stay (settle_child_reports): the user's
@@ -519,6 +520,11 @@ module Samagotchi
519
520
  @wakes.pause!
520
521
  else
521
522
  @continue_offer.after_turn(result)
523
+ # Stopped before it got anywhere (a Stop or a before_turn hook's
524
+ # cancel before the model call) and restored: its rings stay, and
525
+ # wait for the user's next message as after a failure, rather
526
+ # than waking again at once.
527
+ @wakes.pause! if result.canceled? && @turn_flow.restores != restores
522
528
  end
523
529
  end
524
530
  true
@@ -656,6 +662,7 @@ module Samagotchi
656
662
  # a fresh worker's first), so the list is this turn's from the start.
657
663
  @merged_this_turn = []
658
664
  @reporting_turn = reports_to_parent?(turn_args[:origin])
665
+ restores = @turn_flow.restores
659
666
  begin
660
667
  @session.status = Session::STATUS_RUNNING
661
668
  @session.save(state_dir: @state_dir)
@@ -682,15 +689,20 @@ module Samagotchi
682
689
  SessionInbox.write_output(@session_dir, response)
683
690
  end
684
691
  save_or_log(:turn) { save_session }
685
- # A failed turn whose work stayed (TurnFlow#prompt_turn_failed) read
686
- # the reports it merged, as a turn that ended does.
687
- settle_child_reports(rolled_back: error && !LLM::FailedTurn.kept_steps(error))
692
+ # The reports the turn merged were read unless its end took the turn
693
+ # back out of the conversation (TurnFlow restored a checkpoint: a
694
+ # failed prompt or wake turn that got nowhere, a canceled continue
695
+ # turn). A failed turn whose work stayed, and a reminder or continue
696
+ # turn that failed (kept with its failed note), read them.
697
+ settle_child_reports(rolled_back: @turn_flow.restores != restores)
688
698
  ring_parent_after_turn
689
699
  end
690
700
 
691
701
  # The delegate reports this turn took: a kept turn moves the cursors on
692
702
  # and deletes their rings; a rolled-back one leaves the rings for the
693
- # next turn.
703
+ # next turn. Once either way: the model reads a report in the
704
+ # conversation, or the next turn brings it (unless a later !rollback
705
+ # erases a kept turn, which drops what it read).
694
706
  def settle_child_reports(rolled_back:)
695
707
  rolled_back ? @child_reports.release : @child_reports.commit
696
708
  end
@@ -73,7 +73,8 @@ module Samagotchi
73
73
  # A wake turn starts. @return [Integer] the wake turns in a row now
74
74
  def count! = @in_a_row += 1
75
75
 
76
- # A wake turn failed: none starts again until a human's input.
76
+ # A wake turn failed (or was stopped before it got anywhere): none
77
+ # starts again until a human's input.
77
78
  def pause!
78
79
  @paused = true
79
80
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.41.0
4
+ version: 0.42.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -373,6 +373,7 @@ files:
373
373
  - lib/samagotchi/prompt.rb
374
374
  - lib/samagotchi/prompt_history.rb
375
375
  - lib/samagotchi/prompt_literal_guard.rb
376
+ - lib/samagotchi/prompt_note.rb
376
377
  - lib/samagotchi/prompt_warmup.rb
377
378
  - lib/samagotchi/question_desk.rb
378
379
  - lib/samagotchi/recap_setup.rb