samagotchi 0.44.0 → 0.45.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/docs/cli.md +29 -7
  4. data/docs/context.md +2 -1
  5. data/docs/plugins.md +4 -2
  6. data/docs/sessions.md +8 -4
  7. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
  8. data/lib/samagotchi/bridge.rb +22 -10
  9. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  10. data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
  11. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  13. data/lib/samagotchi/commands/registry.rb +54 -9
  14. data/lib/samagotchi/engine.rb +8 -3
  15. data/lib/samagotchi/host_registry.rb +28 -3
  16. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  17. data/lib/samagotchi/model_notes.rb +8 -6
  18. data/lib/samagotchi/model_overlay.rb +3 -2
  19. data/lib/samagotchi/model_profile.rb +71 -2
  20. data/lib/samagotchi/plugin/context.rb +19 -7
  21. data/lib/samagotchi/self_report.rb +31 -2
  22. data/lib/samagotchi/session_commands.rb +24 -7
  23. data/lib/samagotchi/session_manager.rb +19 -1
  24. data/lib/samagotchi/session_metrics.rb +39 -6
  25. data/lib/samagotchi/sessions_command.rb +15 -5
  26. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
  27. data/lib/samagotchi/terminal_ui/formatting.rb +19 -1
  28. data/lib/samagotchi/terminal_ui.rb +84 -12
  29. data/lib/samagotchi/version.rb +1 -1
  30. data/lib/samagotchi/web/app.rb +7 -9
  31. data/lib/samagotchi/web/public/app.js +23 -15
  32. data/lib/samagotchi/web/public/command_complete.js +3 -1
  33. data/lib/samagotchi/web/public/ctx.js +42 -15
  34. data/lib/samagotchi/web/public/index.html +3 -1
  35. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  36. data/lib/samagotchi/web/public/stage_model.js +18 -4
  37. data/lib/samagotchi/web/public/stage_view.js +3 -3
  38. data/lib/samagotchi/web/public/turn_events.js +16 -4
  39. data/lib/samagotchi/web/session_hub.rb +9 -1
  40. data/lib/samagotchi/web/session_summary.rb +19 -2
  41. data/lib/samagotchi/worker.rb +156 -20
  42. data/lib/samagotchi/worker_inbound.rb +6 -5
  43. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 684d8e5c169b4ce5df4cd21c4c46d0ef8524230e530d3e90ec2076f5b674e905
4
- data.tar.gz: 54dbaf8bd2ad38b78e16aca155414748491912bdaebe1069f128e6182df7bfe5
3
+ metadata.gz: 1dc1da4c0179829c1e78da27358f269fba9fcd1ee2a2412b73493bab37a1d81d
4
+ data.tar.gz: b04878e89e0d634c817e748cc9fe435728e4c6c62863d719a53b4080b1ea5ff0
5
5
  SHA512:
6
- metadata.gz: de459298c279f571db785d374e1752a58060d26a13c4f7a6d22f8c4e4a5c82acbcffecd5784712eda405908cc3a8fd7bf1526952878b3791017088ebb62f120e
7
- data.tar.gz: a99cf35d53b0a9fb7b3ed568a73d3383252ba334e4c8efd50efd9d97ded3cb716432fd51ee069e066ce3d21d137f1fadf49e20f2b99e9a769e430c8274278a64
6
+ metadata.gz: c0331854a38cff99772512941a1ce8227dc10448c00d4d4c758ba532cfdd19b311e7f4d9733bce7745645231794ec93212a658a542563a8f56008c97492f8c90
7
+ data.tar.gz: 984e5ce3436b750c872bf0a7dd10f1282ed7f4eaf9c084d9f1add023bdcee5088c8f48acbd9eb3e3d01e2efa8dad200c3a350a4a903725aa1de6597407c9ee36
data/CHANGELOG.md CHANGED
@@ -8,6 +8,74 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.45.0] - 2026-10-08
12
+
13
+ ### Added
14
+
15
+ - Plugins: `ctx.fork?` says whether the session is a fork (`ctx.sessions.fork`: it has a parent and isn't a
16
+ delegate child). See [docs/plugins.md](docs/plugins.md).
17
+
18
+ ### Changed
19
+
20
+ - Commands sent while a turn runs no longer all answer `busy`. Those that only show something answer at once
21
+ (`/models`, and `/model`, `/llm-context` and `/guardrails` alone). Those that change something (`/model X`,
22
+ `/llm-context strategy …`, `/guardrails revoke N`) and `!cmd` run after the turn, in the order sent with the
23
+ prompts: the terminals say `(queued: runs after this turn)`, the web draws the command's bubble dashed until it
24
+ ran (after a reload too), and the web's `llm ctx` chip can Set mid-turn. A `!cmd` waiting after a canceled or
25
+ failed turn is dropped, so the turn's `!rollback` stays open, and one still waiting when the worker stops is
26
+ answered `dropped`. `!rollback` and `/continue` stay refused (`busy: Ctrl-C the turn first, then !rollback`).
27
+ `/archive` typed during a turn archives once it ends, in the REPL and an attached terminal. `chi --attach ID
28
+ --model X` on a running turn waits for its end. See "Typing during a turn" in [docs/cli.md](docs/cli.md).
29
+
30
+ ### Fixed
31
+
32
+ - `/stats`, `/model` and the web named no model notes for a fresh session (a REPL's before the first turn, a new
33
+ web or worker session), though its first turn's prompt carries them; they now name the notes that prompt will
34
+ load. A session whose prompt carried none keeps saying so.
35
+ - `chi self`'s `model notes` row resolved a note's model overlay from the process's `XDG_CONFIG_HOME` while it
36
+ read the note itself from the env it was given, so the row could count no overlay (or another config's); it now
37
+ reads both from the same env, and passes the fallback key the session's prompt does, so a model typed as an
38
+ alias whose note overlay is keyed by the alias's name shows it — the row agrees with the notes the session's prompt loads.
39
+ - In the plain REPL a session command typed at a continue offer (other than `/continue`) was read as an invalid
40
+ answer and lost; it now runs and the offer stays open, as in a worker.
41
+ - A session command sent as a message behind queued prompts (`chi send -m "/model x"` after `chi send -m "task"`)
42
+ could be answered `busy` by the next queued prompt's turn instead of running before it; it now runs once its
43
+ own message's turn comes.
44
+ - The web's ctx meter under an LLM context budget smaller than the window (`llm_context.budget_tokens`) read the
45
+ percentage of the window after a reload (`ctx 12%`) where live it read the budget's (`ctx 25%`); it now counts
46
+ against the budget both ways, as the `[CONTEXT: …]` line and the status line do, and a generation's final counts
47
+ no longer flip it back to the window mid-turn. The ctx tooltip's context line says so: `context: ~16.0k of the
48
+ 64.0k budget, 128.0k window (server)`.
49
+ - The web's strip of latest sessions, back from All sessions with the pointer still on it, could keep a waiting
50
+ family behind other cards until the pointer moved: an update that came while All sessions opened was drawn in
51
+ the order held for the pointer.
52
+ - github-pr 0.3.1: in `auto_attach: offer` mode a forked session (`/btw keep`, a plugin's fork) now gets its
53
+ `first_prompt` row in `offers.ndjson` (its first prompt after the conversation it started from), as a new
54
+ session does; it got none.
55
+ - A resumed or attached session (`chi --resume`, `chi --attach`) drew a turn that failed with its steps kept as its
56
+ prompt and tool rows followed by an earlier step's text, as if that were the answer; the join now draws the line
57
+ the live view left (`✕ turn failed: HTTP 500: boom · 2.0s`, then ` partial progress kept; !rollback restores the
58
+ pre-turn state` when the steps stayed) after the rows, with no answer text, as the web does.
59
+ - The web's stage trail: when the ✂ row of an applied LLM context edit landed in its step inside the
60
+ closed cloud it was out of sight and, unlike a hook's notice, flashed nothing. It now flashes in the
61
+ trail for a few seconds, as its row reads (`✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k
62
+ tokens (paid off)`), as a hook notice does.
63
+ - The web's stage trail: when a message cut a generation that was still thinking, its row (`↪ cut in
64
+ for your message`, `… a message sent with chi send`, `… the parent agent's message`) landed in its
65
+ step inside the closed cloud and, out of sight, flashed nothing. It now flashes in the trail for a
66
+ few seconds, as its row reads, as the ✂ row and a hook notice do.
67
+ - `chi send --new --model` and `delegate` warned `host 'h' doesn't list model 'x'` for an id the host serves, when
68
+ the on-disk model list still named the model a one-model server served before it was reloaded: a saved list
69
+ older than 10 minutes that misses the id now re-lists that one host once (bounded at 5 s) and judges again
70
+ against what it lists now, saving the fresh ids. A list from the last 10 minutes is taken at its word, so a host
71
+ that serves ids it never lists (a gateway's round-robin aliases) is not asked on every launch; a re-list that
72
+ fails, times out or lists nothing warns from the saved list.
73
+ - The session lists' `ctx %` (the web's session cards, `chi sessions list` and its `ctx 12%` column, the
74
+ `list_sessions` tool's `ctx_pct`) and `/stats`' `context used:` still divided by the window when the session's
75
+ LLM context budget (`llm_context.budget_tokens`) was smaller, where the live meter counts the smaller of the
76
+ two; they now count the same way, and `/stats` says so: `context used: 16000 tokens (25.0% of the 64000
77
+ budget)`.
78
+
11
79
  ## [0.44.0] - 2026-10-08
12
80
 
13
81
  ### Added
@@ -2026,7 +2094,8 @@ and long-lived sessions.
2026
2094
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2027
2095
  hotkey panel that send selected text or the clipboard to your sessions.
2028
2096
 
2029
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.44.0...HEAD
2097
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.45.0...HEAD
2098
+ [0.45.0]: https://github.com/dm1try/samagotchi/compare/v0.44.0...v0.45.0
2030
2099
  [0.44.0]: https://github.com/dm1try/samagotchi/compare/v0.43.0...v0.44.0
2031
2100
  [0.43.0]: https://github.com/dm1try/samagotchi/compare/v0.42.0...v0.43.0
2032
2101
  [0.42.0]: https://github.com/dm1try/samagotchi/compare/v0.41.0...v0.42.0
data/docs/cli.md CHANGED
@@ -429,7 +429,7 @@ request (with the cached tokens, the cache writes, the re-prefilled tokens and t
429
429
  reports them),
430
430
  the decode speed, generation latency, cancellations, retries (the network's),
431
431
  thinking cuts (generations a plugin such as loop-guard cut) and output cap hits
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
+ (generations that ended at the provider's cap), context used and window (the used figure is counted against the smaller of the window and the session's LLM context budget, and says so: `context used: 16000 tokens (25.0% of the 64000 budget)`), 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
433
433
  cost when the provider reports one (OpenRouter's `usage.cost`; no prices are
434
434
  guessed). The speed line is the last generation's and the session's average
435
435
  (weighted by tokens): llama.cpp reports it exactly (`87 tok/s out, 1.9k tok/s
@@ -451,7 +451,8 @@ saved with the session and applies from the next turn's start; the reply says
451
451
  what it costs: `forget` on or off re-reads the whole prompt once, `stale` turned
452
452
  on stages the reads already superseded as one batch under the apply rule, and a
453
453
  layer turned off sends its stubs whole again (turning it back on brings them
454
- back). It runs between turns (a worker answers `busy` mid-turn). `/stats` shows
454
+ back). `/llm-context` alone answers mid-turn too; a change sent while a turn runs
455
+ waits for the turn's end (see *Typing during a turn*). `/stats` shows
455
456
  the same values on its `llm context:` line. The web's info bar shows them as the
456
457
  `llm ctx` chip (`llm ctx stale,forget · session`), whose popover sets them
457
458
  through `/llm-context`. See "LLM context: a session's own strategy" in
@@ -468,11 +469,28 @@ REPL alike:
468
469
  A line that comes after the turn's last step runs as the next turn, and so
469
470
  does one sent after Ctrl-C: it doesn't merge into the turn being cancelled.
470
471
  Reminder turns take merged lines too.
471
- - `/stats` and `/recap` answer at once, and in an attached terminal so do `/help`
472
- and a plugin's anytime command (`/btw`). Other commands (`!cmd`, `/model`, `/models`,
473
- `!rollback`, `/continue`, `/guardrails`) say `busy: wait for the turn to end`
474
- and go back into the prompt, so Enter runs them once the turn ends. A
475
- one-word `/word` no command answers (`/modle`) is not steering text either:
472
+ - Commands answer, wait or are refused, by what they do:
473
+ - At once, beside the turn: `/stats`, `/recap`, `/help`, `/context`, a
474
+ plugin's anytime command (`/btw`), and the commands that only show
475
+ something: `/models` (with a filter too), and `/model`, `/llm-context` and
476
+ `/guardrails` alone.
477
+ - After the turn: the ones that change something (`/model X`, `/model clear`,
478
+ `/llm-context strategy …`, `/guardrails revoke N`) and `!cmd`, whose output
479
+ joins the conversation. `(queued: runs after this turn)` says so at once (the
480
+ web draws the command's bubble dashed, with the same words, until it ran);
481
+ when the turn ends they run in the order sent, a prompt sent before one
482
+ running first and one sent after it running after. A `/model X` sent
483
+ mid-turn doesn't change the running turn, nor a line merged into it later.
484
+ After a canceled or failed turn a waiting `!cmd` is dropped
485
+ (`turn canceled: !ls not run; send it again`): running it would close the
486
+ `!rollback` the turn left; the others still run. A command still waiting
487
+ when the session's worker stops is answered `dropped` too.
488
+ - Refused: `!rollback` (`busy: Ctrl-C the turn first, then !rollback`),
489
+ `/continue` and a plugin's command that isn't anytime
490
+ (`busy: wait for the turn to end`). The line goes back into the prompt, so
491
+ Enter runs it once the turn ends.
492
+
493
+ A one-word `/word` no command answers (`/modle`) is not steering text either:
476
494
  the hint prints and the line is dropped.
477
495
  - A question (`ask_user_question`, a guardrails approval) turns the prompt into
478
496
  a yellow `? ` and lists its choices under it, fitted to the terminal; only a
@@ -486,6 +504,10 @@ REPL alike:
486
504
  (`/exit --delete` deletes the session then too). In an
487
505
  attached terminal it detaches at once and the turn goes on in the worker
488
506
  (`/exit` then says the worker stays up: a turn is running).
507
+ - `/archive` mid-turn archives once the turn ends:
508
+ `(archives after this turn; Ctrl-C cancels it)` in the plain REPL,
509
+ `(archives after this turn)` in an attached terminal, which also waits for
510
+ the commands queued behind the turn.
489
511
 
490
512
  With stdin that isn't a terminal (a pipe), the REPL reads a line only between
491
513
  turns.
data/docs/context.md CHANGED
@@ -225,7 +225,8 @@ later when an offer is wanted: one JSON line per event, `offered` (session,
225
225
  project root, working directory, branch, PR URL, main checkout or worktree,
226
226
  model), `attached` and `declined` (with the seconds since the offer), and
227
227
  `first_prompt` (the first 160 characters of every session's first message,
228
- offered or not; not scratch sessions or delegate children). Over 1 MB it is
228
+ offered or not; a fork's first after the conversation it started from, once;
229
+ not scratch sessions or delegate children). Over 1 MB it is
229
230
  renamed to `offers.ndjson.1` (one old file is kept). Only you can read it
230
231
  (0600), and nothing leaves your machine.
231
232
 
data/docs/plugins.md CHANGED
@@ -94,8 +94,9 @@ commands it knows.
94
94
 
95
95
  #### `anytime: true`
96
96
 
97
- A normal command waits for its turn: typed while a turn runs, it is refused
98
- as busy (the REPL puts it back into the prompt). An `anytime: true` command
97
+ A plugin's normal command waits for its turn: typed while a turn runs, it is
98
+ refused as busy (the REPL puts it back into the prompt; a plugin command can't
99
+ ask to run after the turn, as the built-in `/model X` does). An `anytime: true` command
99
100
  runs **at once, on its own thread, beside the running turn**, and the turn
100
101
  goes on:
101
102
 
@@ -504,6 +505,7 @@ full label, `plugin.rb (bundle my-bundle)`.
504
505
  | `ctx.repo_root` | the git checkout holding `cwd`, or nil |
505
506
  | `ctx.scratch?` | whether the session is a `chi scratch` one (deleted when it ends) |
506
507
  | `ctx.delegate?` | whether the session is a delegate child: a task another session handed over |
508
+ | `ctx.fork?` | whether the session is a fork (`ctx.sessions.fork`): it has a parent but isn't a delegate child, and started from a conversation, so its first prompt isn't its first user message |
507
509
  | `ctx.model` | the model the session runs on now: its resolved ref (`host:id`), right after `/model` too |
508
510
  | `ctx.model_key` | that model's memory overlay key (`<name>.<key>.md`, what `memory_write current_model_only` writes) |
509
511
  | `ctx.settings` | the bundle's settings, frozen |
data/docs/sessions.md CHANGED
@@ -21,11 +21,11 @@ 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`). 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.
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. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
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
 
28
- <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a prompt queued in `input/` (it would run on the next resume) or a live worker's step-limit question still open, in it or a child (409 `busy` too), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn, a queued prompt or an open step-limit question refuses it), and an empty session is discarded instead; `chi scratch` refuses it. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
28
+ <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a prompt queued in `input/` (it would run on the next resume) or a live worker's step-limit question still open, in it or a child (409 `busy` too), a plain REPL owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached or plain terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn, a queued prompt or an open step-limit question refuses it), and an empty session is discarded instead; `chi scratch` refuses it. Typed while a turn runs, `/archive` waits for the turn's end (an attached terminal for the commands queued behind it too) and then archives. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and "include archived" by the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `archive · stop · delete` (`unarchive` on an archived one); archiving the open session stays on it, with an Undo toast.
29
29
 
30
30
  **Lazy sweep:** automatic prune runs only while `chi web` runs, at most once per `session.sweep_interval_hours` (default 24): the web's session hub gives it a chance on its periodic full scan, and `GET /api/sessions` does too. No cron, and a terminal-only chi never sweeps. Manual prune is always available.
31
31
 
@@ -56,7 +56,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
56
56
 
57
57
  `--dry-run` is the safe preview. `--keep N` keeps the N newest sessions that stay: one that goes anyway (a leftover scratch session, one left empty, one older than `--days`) takes no place in the count. Web has no prune endpoint; use the CLI.
58
58
 
59
- `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A session whose last turn a hook stopped carries `[looped]` when the hook was loop-guard (the thinking watch's cut, or its stop) and `[stopped by <hook>]` for any other hook, after `[archived]` (`--format json` has it as `stopped_by`; from `Session#last_turn`, so it stays until a later turn ends differently). The web's session cards wear the same marker as a badge: `looped` or `stopped by <hook>` after the status dot (a session that also waits on you shows only its waiting badge: the stop is behind the need), and the all-view's search matches its words too. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
59
+ `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A session whose last turn a hook stopped carries `[looped]` when the hook was loop-guard (the thinking watch's cut, or its stop) and `[stopped by <hook>]` for any other hook, after `[archived]` (`--format json` has it as `stopped_by`; from `Session#last_turn`, so it stays until a later turn ends differently). The web's session cards wear the same marker as a badge: `looped` or `stopped by <hook>` after the status dot (a session that also waits on you shows only its waiting badge: the stop is behind the need), and the all-view's search matches its words too. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children. After the status, `ctx 12%` says how full the context was after the session's last counted turn (from its `analytics.json`; blank when no turn had a server count or the window is unknown; `ctx_pct` in `--format json`). The fill is counted against the session's LLM context budget when one is set and smaller than the window (its own `--llm-context-budget`, else the model's/host's/config's; see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)), as the live meter counts it; the list, the web card and the session page otherwise agree. The web's session cards show it quietly too (`12%`), and a session's ctx meter shows it on load, before its next turn streams.
60
60
 
61
61
  **Projects:** a session belongs to the git project it was started in: the repository, whichever worktree or subfolder of it (the same project root memories use). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no repo (`~`). `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
62
62
 
@@ -131,6 +131,10 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
131
131
  When the context window fills past 40% (then 60%, 80%), the native loop leaves a similar line (`kind: context`):
132
132
  `[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted
133
133
  and range reads over full-file dumps]`, once per rise; see [context telemetry](internals/context-telemetry.md).
134
+ Under an LLM context budget smaller than the window (`llm_context.budget_tokens`, a session's `/llm-context budget`;
135
+ see [configuration.md](configuration.md#all-settings)), the percentage is of the budget and the line says so
136
+ (`about 62% of the context budget (64k tokens) is in use`); the status line's ctx and the web's ctx meter (live and
137
+ after a reload) count against it too.
134
138
 
135
139
  Failed retries replace the note, they don't pile up. The note's text is escaped like user text (an error may
136
140
  quote what a server sent). A cancelled continue turn and `!rollback` go back to before the turn, note included.
@@ -171,7 +175,7 @@ chi send --wait -m "and the tests?" 3fa2 # a follow-up in the same
171
175
  chi send --wait 3fa2 # sends nothing: waits for its next reply
172
176
  ```
173
177
 
174
- - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one). A host config.yml doesn't have is refused before anything is spawned (exit 1). A `host:id` whose id that host's last saved list doesn't have (`~/.local/state/samagotchi/model_lists.json`, what `chi models` / `/models` / the web last listed for that host, at most a week old) gets one warning line on stderr, with up to three close ids as a hint (`did you mean: gemma-small?`), and the session starts anyway: some hosts serve ids they don't list (a one-model llama.cpp server takes any name). A bare id (no `host:`) isn't checked that way — routing sends it to whichever host lists it — and with no saved list for the host nothing is said; a wrong id then fails the worker's first turn.
178
+ - `--new` takes no ids (one new session per call). `--dir DIR` is its folder, and so its project (default the current one); `--model M` its model (default the configured one). A host config.yml doesn't have is refused before anything is spawned (exit 1). A `host:id` whose id that host's last saved list doesn't have (`~/.local/state/samagotchi/model_lists.json`, what `chi models` / `/models` / the web last listed for that host, at most a week old) gets one warning line on stderr, with up to three close ids as a hint (`did you mean: gemma-small?`), and the session starts anyway: some hosts serve ids they don't list (a one-model llama.cpp server takes any name). A saved list older than 10 minutes that misses the id is re-listed once (that one host, capped at 5 s) before the warning: the list can be stale by content, e.g. a one-model server reloaded with another model; a list re-listed that way names the typed id, nothing is said. A list younger than 10 minutes is taken at its word (a host that serves ids it never lists would otherwise pay the re-list on every launch). A re-list that fails, times out or lists nothing falls back to the saved list. A bare id (no `host:`) isn't checked that way — routing sends it to whichever host lists it — and with no saved list for the host nothing is said; a wrong id then fails the worker's first turn.
175
179
  - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
176
180
  - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would run after it, and its reply would come back as the answer.
177
181
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
@@ -26,6 +26,7 @@ module Samagotchi
26
26
  # When each running tool call started, by [iteration, call_index].
27
27
  @tool_started_at = {}
28
28
  @queued = []
29
+ @queued_commands = []
29
30
  @merged_origins = []
30
31
  @recap = nil
31
32
  @continue_offer = nil
@@ -199,11 +200,24 @@ module Samagotchi
199
200
  @mutex.synchronize { @queued.map(&:dup) }
200
201
  end
201
202
 
203
+ # @return [Array<Hash>] commands queued for after the running turn
204
+ # (command_queued with waits, not a card's action) that haven't run
205
+ # or been dropped yet:
206
+ # {command_id:, client_id:, line:}, in arrival order
207
+ def queued_commands
208
+ @mutex.synchronize { @queued_commands.map(&:dup) }
209
+ end
210
+
202
211
  private
203
212
 
204
213
  def fold(event)
205
214
  type = event[:type]
206
215
  case type
216
+ when :command_queued
217
+ # A card's action shows as its card, never as a line (the UIs skip it live).
218
+ @queued_commands << event.slice(:command_id, :client_id, :line) if event[:waits] && !event[:card]
219
+ when :command_ran
220
+ @queued_commands.reject! { |entry| entry[:command_id] == event[:command_id] } if event[:queued]
207
221
  when :turn_enqueued
208
222
  queued = { enqueued_id: event[:enqueued_id], client_id: event[:client_id], prompt: event[:prompt] }
209
223
  queued[:images] = event[:images] if event[:images]
@@ -75,7 +75,8 @@ module Samagotchi
75
75
  # SessionInbox::INPUT_FORMAT); nil advertises none
76
76
  # @param on_input [#call, nil] called once a turn for this session is
77
77
  # queued, to wake the worker loop (Worker::Waker#wake)
78
- # @param on_command [#call, nil] takes a session command
78
+ # @param on_command [#call, nil] takes a session command, returns its
79
+ # mid-turn policy (:anytime when it runs now)
79
80
  # ({command_id:, client_id:, line:}) for the worker loop to run; without
80
81
  # one, POST /command answers 501
81
82
  # @param on_exit_request [#call, nil] a client asks the worker to exit
@@ -222,7 +223,9 @@ module Samagotchi
222
223
  # doesn't keep it.
223
224
  # chi_version: the chi this worker runs (an attaching TUI compares it
224
225
  # with its own).
225
- # @return [Hash] {messages:, current_turn:, queued:, recap:, saved_recap:, continue_offer:, pending_question:,
226
+ # The commands queued for after the running turn (queued_commands,
227
+ # TurnAccumulator#queued_commands) too.
228
+ # @return [Hash] {messages:, current_turn:, queued:, queued_commands:, recap:, saved_recap:, continue_offer:, pending_question:,
226
229
  # guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, chi_version:, event_seq:, event_id:}
227
230
  def snapshot
228
231
  @engine.synchronize_events do
@@ -231,6 +234,7 @@ module Samagotchi
231
234
  messages: @engine.messages_checkpoint,
232
235
  current_turn: @accumulator.current_turn,
233
236
  queued: @accumulator.queued,
237
+ queued_commands: @accumulator.queued_commands,
234
238
  recap: @accumulator.recap,
235
239
  saved_recap: @engine.saved_recap,
236
240
  continue_offer: @accumulator.continue_offer,
@@ -277,13 +281,15 @@ module Samagotchi
277
281
  # The worker's own way in for a session command that came as a message
278
282
  # (its first prompt, an input file): queued and announced as a POST
279
283
  # /command's is (#handle_command).
284
+ # @param after_file [String, nil] the input file it came in (its
285
+ # basename): it runs once that file is taken (Worker#on_command)
280
286
  # @return [String] its command_id
281
- def queue_command(line, client_id: nil, card: false)
287
+ def queue_command(line, client_id: nil, card: false, after_file: nil)
282
288
  command = { command_id: SecureRandom.uuid, client_id: client_id, line: line.to_s.strip }
283
289
  # A card's action (the step-limit question's answer): the UIs leave
284
290
  # its line out.
285
291
  command[:card] = true if card
286
- @engine.synchronize_events { queue_command_locked(command) }
292
+ @engine.synchronize_events { queue_command_locked(command, after_file: after_file) }
287
293
  command[:command_id]
288
294
  end
289
295
 
@@ -727,13 +733,19 @@ module Samagotchi
727
733
  [{}, 202, { status: "accepted", command_id: command[:command_id], session_id: @session_id }]
728
734
  end
729
735
 
730
- # With the event log held.
731
- def queue_command_locked(command)
732
- @on_command.call(command)
736
+ # With the event log held. +on_command+ answers what it did with the
737
+ # line (Commands::Registry::MID_TURN).
738
+ def queue_command_locked(command, after_file: nil)
739
+ policy = after_file ? @on_command.call(command, after_file: after_file) : @on_command.call(command)
733
740
  # An anytime command runs now, beside a turn (D8): the UIs show its
734
- # line here, so the cards it shows come after it.
735
- anytime = command_registry.lookup(command[:line])&.anytime ? { anytime: true } : {}
736
- @engine.announce(type: :command_queued, **command, **anytime)
741
+ # line here, so the cards it shows come after it. A queued one runs
742
+ # when the turn ends: the UIs show it waiting until its command_ran.
743
+ marks = case policy
744
+ when :anytime then { anytime: true }
745
+ when :queue then { waits: "turn_end" }
746
+ else {}
747
+ end
748
+ @engine.announce(type: :command_queued, **command, **marks)
737
749
  end
738
750
 
739
751
  # A client asks the worker to exit now (`/exit` in the attached TUI), or
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: github-pr
3
- version: 0.3.0
3
+ version: 0.3.1
4
4
  scope: system
5
5
  description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can offer the PR with a card first, or attach nothing
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.43.0"
7
+ requires_chi: ">= 0.45.0"
8
8
  needs:
9
9
  - command: gh
10
10
  why: reads the pull request
11
11
  hint: brew install gh, then gh auth login
12
12
  plugin:
13
13
  file: plugin.rb
14
- sha256: sha256:8c3b1b2de9112320d95d42d19b924a2a6bb0418715d3c78a24ac695c7b825487
14
+ sha256: sha256:eb6207a0aec4267eb15d28e4db2edb10d458c3ff574c2be56f225b24b7afb733
15
15
  scripts:
16
16
  pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
17
17
  context_providers:
@@ -218,13 +218,20 @@ class Plugin
218
218
  # Offer mode: every session's first prompt (offered or not; a web chat's
219
219
  # first turn starts before the init task's gh returns), joined to the
220
220
  # offers by session at analysis time. First = no user message yet (a
221
- # context note may be in messages already).
221
+ # context note may be in messages already); a fork starts from its
222
+ # parent's conversation, user messages and all, so its first is the one
223
+ # the log has no first_prompt row for yet.
222
224
  def log_first_prompt(event, ctx)
223
225
  return if event[:prompt].nil? || auto_attach_mode(ctx) != :offer
224
- return if Array(event[:messages]).any? { |message| message_role(message) == "user" }
225
- return if ctx.scratch? || ctx.delegate? || ctx.session_id.nil?
226
+ return if ctx.scratch? || ctx.session_id.nil?
226
227
 
227
- offers_log(ctx).write("first_prompt", session: ctx.session_id, prompt: event[:prompt].to_s[0, FIRST_PROMPT_CHARS])
228
+ log = offers_log(ctx)
229
+ if Array(event[:messages]).any? { |message| message_role(message) == "user" }
230
+ return unless ctx.fork? && !log.first_prompt?(ctx.session_id)
231
+ elsif ctx.delegate?
232
+ return
233
+ end
234
+ log.write("first_prompt", session: ctx.session_id, prompt: event[:prompt].to_s[0, FIRST_PROMPT_CHARS])
228
235
  rescue StandardError => e
229
236
  ctx.log.debug(:pr_offers_log_failed, error: e.class.name)
230
237
  end
@@ -365,6 +372,17 @@ class OffersLog
365
372
  @log.debug(:pr_offers_log_failed, error: e.class.name)
366
373
  nil
367
374
  end
375
+
376
+ # Whether the log (or its old file) has session +id+'s first_prompt row.
377
+ def first_prompt?(id)
378
+ [@path, "#{@path}.1"].any? do |path|
379
+ File.exist?(path) && File.foreach(path).any? do |line|
380
+ line.include?(%("session":#{JSON.generate(id)})) && JSON.parse(line)["event"] == "first_prompt"
381
+ rescue JSON::ParserError
382
+ false
383
+ end
384
+ end
385
+ end
368
386
  end
369
387
 
370
388
  # A GitHub pull request, by its URL.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.44.0
3
+ version: 0.45.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:9d1b2ff7ff5f237c1b1beed2eb8ecc3069570705a2c1763e533dd15829ff309d
8
+ self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
9
  config_modification_protocol.md: sha256:2d4135ce4fafcd391d1782dc3a54f7cbf10cd35ebe9f004b2d6cb256517a8451
10
10
  memory_guide.md: sha256:15cd3b24eda853cc458371c6c75809a2d4b5352311d075aaa4384926b5221c27
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -3,7 +3,8 @@
3
3
  ## Start here
4
4
  - `chi self` (via `execute`) prints my version, **source dir**, config path, hooks dir,
5
5
  memory dirs, sessions dir, model/host and bundles. Use its source dir; don't hunt via `which`/`gem list`/`find /`.
6
- Its `model` row is this session's model (live after `/model`), `model key` my overlay key.
6
+ Its `model` row is this session's model (live after `/model`), `model key` my overlay key,
7
+ `model notes` the model notes my prompt loads (`/stats` names the ones this session's prompt carried).
7
8
  - My current session id is in the system prompt; resume with `chi --resume <id>`.
8
9
  - My debug log is the `log` line of `chi self` (default `$XDG_STATE_HOME/samagotchi/samagotchi.log`,
9
10
  next to the sessions dir; not `~/.local/state` when `XDG_STATE_HOME` is set). One record per line,
@@ -10,9 +10,15 @@ module Samagotchi
10
10
  # An entry the UI runs itself (/stats, /exit, …) is +local+: it is listed
11
11
  # for Tab completion and help, and #lookup never returns it.
12
12
  class Registry
13
+ # What a line does while a turn runs: runs now on its own thread
14
+ # (:anytime), runs after the turn ends (:queue), or is refused
15
+ # (:refuse, "busy").
16
+ MID_TURN = %i[anytime queue refuse].freeze
17
+
13
18
  # @!attribute id [Symbol] the entry's key (:model, :rollback, …)
14
19
  # @!attribute name [String] "/model", "!rollback", …
15
- # @!attribute anytime [Boolean] may run while a turn runs
20
+ # @!attribute mid_turn [Symbol, #call] what a line of it does while a
21
+ # turn runs (MID_TURN): a symbol, or a lambda line (stripped) → one
16
22
  # @!attribute local [Boolean] the UI runs it; completion and help only
17
23
  # @!attribute uis [Array<Symbol>, nil] a local entry's UIs (:repl,
18
24
  # :attached) for completion; nil means every UI
@@ -21,9 +27,19 @@ module Samagotchi
21
27
  # a built-in's with the line, and calls a bundle's with the text
22
28
  # after the name
23
29
  # @!attribute source [String] "core", or the bundle that added it
24
- Entry = Struct.new(:id, :name, :description, :anytime, :local, :uis, :match, :handler, :source,
30
+ Entry = Struct.new(:id, :name, :description, :mid_turn, :local, :uis, :match, :handler, :source,
25
31
  keyword_init: true) do
26
32
  def match?(text) = match.call(text)
33
+
34
+ # Whether every line of it runs at once, a turn running or not (D8).
35
+ def anytime = mid_turn == :anytime
36
+
37
+ # @param text [String] a line of it, stripped
38
+ # @return [Symbol] one of MID_TURN
39
+ def mid_turn_for(text) = mid_turn.respond_to?(:call) ? mid_turn.call(text) : mid_turn
40
+
41
+ # How a listing names #mid_turn: "depends" when it is the line's.
42
+ def mid_turn_label = mid_turn.respond_to?(:call) ? "depends" : mid_turn.to_s
27
43
  def slash? = name.start_with?("/")
28
44
  def in_ui?(ui) = uis.nil? || uis.include?(ui)
29
45
  end
@@ -49,21 +65,36 @@ module Samagotchi
49
65
  next if name.empty? || known.include?(name)
50
66
 
51
67
  known << name
52
- registry.register(name, item[:description].to_s, anytime: item[:anytime] == true, local: item[:local] == true,
68
+ registry.register(name, item[:description].to_s, mid_turn: listed_mid_turn(item), local: item[:local] == true,
53
69
  uis: item[:uis]&.map(&:to_sym), source: item[:source] || "core")
54
70
  end
55
71
  registry
56
72
  end
57
73
 
74
+ # A listed entry's #mid_turn: its mid_turn, else (an older worker's
75
+ # listing) its anytime. "depends" (a line's own, which only the
76
+ # worker's entry knows) and anything unknown are :refuse: the UI
77
+ # sends the line and the worker decides.
78
+ def self.listed_mid_turn(item)
79
+ label = item[:mid_turn]&.to_sym
80
+ return label if MID_TURN.include?(label)
81
+ return :refuse if label
82
+
83
+ item[:anytime] == true ? :anytime : :refuse
84
+ end
85
+ private_class_method :listed_mid_turn
86
+
87
+ # @param anytime [Boolean] mid_turn: :anytime (a bundle's command)
88
+ # @param mid_turn [Symbol, #call, nil] see Entry; nil takes +anytime+
58
89
  # @param match [#call, nil] the default matches the name alone or the
59
90
  # name, a space and arguments
60
91
  # @return [Entry]
61
- def register(name, description, id: nil, anytime: false, local: false, uis: nil, match: nil,
92
+ def register(name, description, id: nil, anytime: false, mid_turn: nil, local: false, uis: nil, match: nil,
62
93
  source: "core", &handler)
63
94
  raise ArgumentError, "command #{name} is already registered" if @entries.any? { |entry| entry.name == name }
64
95
 
65
96
  entry = Entry.new(id: id || name.delete_prefix("/").to_sym, name: name, description: description,
66
- anytime: anytime, local: local, uis: uis, match: match || default_match(name),
97
+ mid_turn: mid_turn || (anytime ? :anytime : :refuse), local: local, uis: uis, match: match || default_match(name),
67
98
  handler: handler, source: source)
68
99
  @entries << entry
69
100
  entry
@@ -77,6 +108,19 @@ module Samagotchi
77
108
 
78
109
  def command?(line) = !lookup(line).nil?
79
110
 
111
+ # Whether every line of +line+'s command runs at once, a turn running
112
+ # or not (D8). A line whose #mid_turn is :anytime but whose command's
113
+ # isn't always (/model) runs at once only while a turn runs.
114
+ def anytime?(line) = lookup(line)&.anytime == true
115
+
116
+ # What +line+ does while a turn runs (MID_TURN); :refuse for a line
117
+ # no command answers.
118
+ # @return [Symbol]
119
+ def mid_turn(line)
120
+ text = line.to_s.strip
121
+ lookup(text)&.mid_turn_for(text) || :refuse
122
+ end
123
+
80
124
  # A line that is one word: a slash and a name, no spaces, no second
81
125
  # slash. `/modle` is one; `/foo bar` and `/usr/bin/env` are not (they
82
126
  # are prompts).
@@ -115,12 +159,13 @@ module Samagotchi
115
159
  def entries = @entries.dup
116
160
 
117
161
  # What a snapshot tells a UI without an Engine (see .from_listing).
118
- # @return [Array<Hash>] {name:, description:, anytime:, local:, uis:,
119
- # source:} per entry, in registration order
162
+ # @return [Array<Hash>] {name:, description:, anytime:, mid_turn:,
163
+ # local:, uis:, source:} per entry, in registration order; anytime
164
+ # is true only when every line is (mid_turn "anytime")
120
165
  def listing
121
166
  @entries.map do |entry|
122
- { name: entry.name, description: entry.description, anytime: entry.anytime ? true : false,
123
- local: entry.local ? true : false, uis: entry.uis&.map(&:to_s), source: entry.source }
167
+ { name: entry.name, description: entry.description, anytime: entry.anytime,
168
+ mid_turn: entry.mid_turn_label, local: entry.local ? true : false, uis: entry.uis&.map(&:to_s), source: entry.source }
124
169
  end
125
170
  end
126
171
 
@@ -481,11 +481,16 @@ module Samagotchi
481
481
 
482
482
  # The model notes the session's prompt carried at its last build
483
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.
484
+ # ones until this process builds its prompt; a fresh session (no build
485
+ # yet, no messages) has none saved, so the effective model's prompt
486
+ # would load them: the notes #model_notes names, as without a session.
487
+ # The three readers (/model, /stats, the web) share this.
486
488
  # @return [Array<PromptNote>]
487
489
  def prompt_notes
488
- return @session.prompt_notes.dup if @session
490
+ return model_notes.map { |note| PromptNote.from_note(note) } unless @session
491
+
492
+ saved = @session.prompt_notes
493
+ return saved.dup if !@prompt_builder.prompt_notes.nil? || !saved.empty? || @session.messages.to_a.any?
489
494
 
490
495
  model_notes.map { |note| PromptNote.from_note(note) }
491
496
  rescue StandardError