samagotchi 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +120 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +43 -12
- data/docs/configuration.md +102 -5
- data/docs/context.md +2 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-live.md +111 -0
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +9 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +19 -0
- data/lib/samagotchi/bridge.rb +22 -10
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/commands/registry.rb +54 -9
- data/lib/samagotchi/config.rb +57 -4
- data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
- data/lib/samagotchi/engine.rb +47 -9
- data/lib/samagotchi/host_model.rb +116 -0
- data/lib/samagotchi/host_registry.rb +43 -12
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +89 -7
- data/lib/samagotchi/llm/turn_settings.rb +5 -3
- data/lib/samagotchi/llm_context_strategy.rb +37 -0
- data/lib/samagotchi/model_catalog.rb +17 -12
- data/lib/samagotchi/model_notes.rb +8 -6
- data/lib/samagotchi/model_overlay.rb +3 -2
- data/lib/samagotchi/model_price.rb +54 -0
- data/lib/samagotchi/model_profile.rb +74 -3
- data/lib/samagotchi/plugin/context.rb +19 -7
- data/lib/samagotchi/process_group.rb +56 -9
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/self_report.rb +33 -3
- data/lib/samagotchi/session_commands.rb +47 -21
- data/lib/samagotchi/session_manager.rb +19 -1
- data/lib/samagotchi/session_metrics.rb +79 -11
- data/lib/samagotchi/sessions_command.rb +15 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +45 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
- data/lib/samagotchi/terminal_ui.rb +97 -12
- data/lib/samagotchi/tools/delegate_wait.rb +5 -4
- data/lib/samagotchi/tools/task_runtime.rb +11 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -11
- data/lib/samagotchi/web/public/app.js +30 -18
- data/lib/samagotchi/web/public/command_complete.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +62 -22
- data/lib/samagotchi/web/public/format.js +7 -2
- data/lib/samagotchi/web/public/index.html +3 -1
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
- data/lib/samagotchi/web/public/model_pick.js +23 -3
- data/lib/samagotchi/web/public/model_picker.js +5 -3
- data/lib/samagotchi/web/public/stage_model.js +18 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +19 -6
- data/lib/samagotchi/web/public/turn_model.js +16 -0
- data/lib/samagotchi/web/public/turn_view.js +19 -0
- data/lib/samagotchi/web/session_hub.rb +9 -1
- data/lib/samagotchi/web/session_summary.rb +19 -2
- data/lib/samagotchi/worker.rb +172 -21
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +4 -1
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
|
|
|
@@ -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. 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?, llm_context?}], 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). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
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 (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
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).
|
|
@@ -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. An id its host declares under `hosts.<name>.models` (see [configuration](configuration.md#models-a-host-serves-but-doesnt-list)) is never warned about or re-listed. A bare id (no `host:`) isn't checked that way — routing sends it to whichever host lists or declares 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]
|
|
@@ -250,6 +264,11 @@ module Samagotchi
|
|
|
250
264
|
else
|
|
251
265
|
append_text(event[:iteration], "text", event[:content])
|
|
252
266
|
end
|
|
267
|
+
when :generation_retrying
|
|
268
|
+
# A dropped stream's step asked again: what it streamed is void.
|
|
269
|
+
if event[:restarted]
|
|
270
|
+
parts.reject! { |part| %w[thinking text].include?(part[:kind]) && part[:iteration] == event[:iteration] }
|
|
271
|
+
end
|
|
253
272
|
when :tool_call_started
|
|
254
273
|
part = { kind: "tool", iteration: event[:iteration], call_index: event[:call_index],
|
|
255
274
|
tool: event[:tool], params: event[:params], status: "running" }
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
732
|
-
|
|
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
|
-
|
|
736
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
|
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)
|
|
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.
|
|
@@ -57,7 +57,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
|
|
|
57
57
|
**Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
|
|
58
58
|
|
|
59
59
|
- `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
|
|
60
|
-
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
|
|
60
|
+
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
|
|
61
61
|
- `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
|
|
62
62
|
- `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
|
|
63
63
|
- `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.46.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
|
-
self_map.md: sha256:
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
8
|
+
self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
|
|
9
|
+
config_modification_protocol.md: sha256:337ad1b4cc9308dc5abb47b13f51a76df4691ba80fe41648f391687b5b929b65
|
|
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
|
|
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, :
|
|
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,
|
|
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:
|
|
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:,
|
|
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
|
|
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
|
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "log"
|
|
|
10
10
|
require_relative "model_ref"
|
|
11
11
|
require_relative "paths"
|
|
12
12
|
require_relative "llm_context_strategy"
|
|
13
|
+
require_relative "host_model"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
15
16
|
# Parses the thinking: levels of host and model entries (it needs Config).
|
|
@@ -263,10 +264,12 @@ module Samagotchi
|
|
|
263
264
|
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
264
265
|
MAP_ENTRY_KEYS = {
|
|
265
266
|
"hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled
|
|
266
|
-
remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens].freeze,
|
|
267
|
+
remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
|
|
267
268
|
"models" => %w[profile vision sampling thinking window_tokens llm_context_strategy llm_context_apply
|
|
268
269
|
llm_context_budget_tokens].freeze
|
|
269
270
|
}.freeze
|
|
271
|
+
# The keys a hosts.<name>.models entry may hold (HostModel.parse_map).
|
|
272
|
+
HOST_MODEL_KEYS = %w[price served].freeze
|
|
270
273
|
# Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
|
|
271
274
|
SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
|
|
272
275
|
|
|
@@ -615,7 +618,29 @@ module Samagotchi
|
|
|
615
618
|
|
|
616
619
|
(entry.keys.map(&:to_s) - allowed).map do |k|
|
|
617
620
|
unknown_key_message("#{map}.#{name}.#{k}", k, allowed, prefix: "#{map}.#{name}.")
|
|
618
|
-
end
|
|
621
|
+
end + (map == "hosts" ? host_model_problems(name, entry["models"]) : [])
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# hosts.<name>.models' entries, one level further down.
|
|
626
|
+
def host_model_problems(host, models)
|
|
627
|
+
return [] unless models.is_a?(Hash)
|
|
628
|
+
|
|
629
|
+
models.flat_map do |id, model|
|
|
630
|
+
next [] unless model.is_a?(Hash)
|
|
631
|
+
|
|
632
|
+
prefix = "hosts.#{host}.models.#{id}."
|
|
633
|
+
(model.keys.map(&:to_s) - HOST_MODEL_KEYS).map do |k|
|
|
634
|
+
unknown_key_message("#{prefix}#{k}", k, HOST_MODEL_KEYS, prefix: prefix)
|
|
635
|
+
end + price_key_problems("#{prefix}price.", model["price"])
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def price_key_problems(prefix, price)
|
|
640
|
+
return [] unless price.is_a?(Hash)
|
|
641
|
+
|
|
642
|
+
(price.keys.map(&:to_s) - ModelPrice::KEYS).map do |k|
|
|
643
|
+
unknown_key_message("#{prefix}#{k}", k, ModelPrice::KEYS, prefix: prefix)
|
|
619
644
|
end
|
|
620
645
|
end
|
|
621
646
|
|
|
@@ -853,6 +878,7 @@ module Samagotchi
|
|
|
853
878
|
"hosts entry '#{name}'")
|
|
854
879
|
llm_context_apply = LLMContextStrategy.parse_apply(raw_cfg.key?(LLMContextStrategy::APPLY_KEY) ? raw_cfg[LLMContextStrategy::APPLY_KEY] : raw_cfg[:llm_context_apply],
|
|
855
880
|
"hosts entry '#{name}'")
|
|
881
|
+
models = HostModel.parse_map(raw_cfg.key?("models") ? raw_cfg["models"] : raw_cfg[:models], name)
|
|
856
882
|
llm_context_budget = LLMContextStrategy.parse_budget(raw_cfg.key?(LLMContextStrategy::BUDGET_KEY) ? raw_cfg[LLMContextStrategy::BUDGET_KEY] : raw_cfg[:llm_context_budget_tokens],
|
|
857
883
|
"hosts entry '#{name}'")
|
|
858
884
|
unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
|
|
@@ -920,8 +946,10 @@ module Samagotchi
|
|
|
920
946
|
profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
|
|
921
947
|
vision: vision, sampling: sampling, thinking: thinking, remote: remote,
|
|
922
948
|
window_tokens: window, llm_context_strategy: llm_context,
|
|
923
|
-
llm_context_apply: llm_context_apply, llm_context_budget_tokens: llm_context_budget
|
|
949
|
+
llm_context_apply: llm_context_apply, llm_context_budget_tokens: llm_context_budget,
|
|
950
|
+
models: models }
|
|
924
951
|
end
|
|
952
|
+
warn_duplicate_host_models(normalized)
|
|
925
953
|
end
|
|
926
954
|
|
|
927
955
|
# If no hosts defined, synthesize "default" from server.host/port/transport.
|
|
@@ -941,6 +969,30 @@ module Samagotchi
|
|
|
941
969
|
{}
|
|
942
970
|
end
|
|
943
971
|
|
|
972
|
+
# hosts.<name>.models as written back (HostModel#to_config), nil when none.
|
|
973
|
+
def host_models_config(models)
|
|
974
|
+
models.values.to_h { |m| [m.id, m.to_config] } unless models.nil? || models.empty?
|
|
975
|
+
end
|
|
976
|
+
private_class_method :host_models_config
|
|
977
|
+
|
|
978
|
+
# Two hosts declaring one id under hosts.<name>.models: a bare id goes
|
|
979
|
+
# where HostRegistry#host_for_model sends it (the default host when it
|
|
980
|
+
# declares the id, else the first in hosts: order); say so once.
|
|
981
|
+
def warn_duplicate_host_models(hosts)
|
|
982
|
+
declared = Hash.new { |h, k| h[k] = [] }
|
|
983
|
+
hosts.each { |name, cfg| cfg[:models].each { |down, model| declared[down] << [name, model.id] } }
|
|
984
|
+
declared.each_value do |owners|
|
|
985
|
+
next if owners.size < 2
|
|
986
|
+
|
|
987
|
+
names = owners.map(&:first)
|
|
988
|
+
id = owners.first.last
|
|
989
|
+
goes = names.include?("default") ? "default" : "#{names.first} unless the default host lists it"
|
|
990
|
+
warn_once "Warning: #{names.map { |n| "hosts.#{n}.models" }.join(" and ")} #{names.size == 2 ? "both" : "all"} declare #{id}; " \
|
|
991
|
+
"a bare #{id} goes to #{goes}"
|
|
992
|
+
end
|
|
993
|
+
end
|
|
994
|
+
private_class_method :warn_duplicate_host_models
|
|
995
|
+
|
|
944
996
|
# The names (lowercased) of the hosts entries with enabled: false, which
|
|
945
997
|
# hosts_config leaves out. Read from the same source: config.yml's
|
|
946
998
|
# hosts:, or SAMAGOTCHI_HOSTS_JSON in a worker.
|
|
@@ -1070,7 +1122,8 @@ module Samagotchi
|
|
|
1070
1122
|
"remote" => v[:remote], "window_tokens" => v[:window_tokens],
|
|
1071
1123
|
"llm_context_strategy" => v[:llm_context_strategy]&.map(&:to_s),
|
|
1072
1124
|
"llm_context_apply" => v[:llm_context_apply]&.to_s,
|
|
1073
|
-
"llm_context_budget_tokens" => v[:llm_context_budget_tokens]
|
|
1125
|
+
"llm_context_budget_tokens" => v[:llm_context_budget_tokens],
|
|
1126
|
+
"models" => host_models_config(v[:models])).compact
|
|
1074
1127
|
end
|
|
1075
1128
|
# Disabled hosts travel as just that, so a worker refuses "box:x"
|
|
1076
1129
|
# the way its parent does instead of sending it to the default host.
|
|
@@ -980,6 +980,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
980
980
|
let kittyText = windows.isEmpty ? "" : Kitty.text(context: model.hasContext ? model.text : "", message: model.trimmedPrompt,
|
|
981
981
|
imagePaths: ImageIntake.keepForPaste(images), pasteOnly: kind == .paste)
|
|
982
982
|
|
|
983
|
+
let open = opens
|
|
983
984
|
let group = DispatchGroup()
|
|
984
985
|
var chiOutcome: SendOutcome?
|
|
985
986
|
var kittyOutcomes: [(KittyWindow, KittyError?)] = []
|
|
@@ -1004,6 +1005,9 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
1004
1005
|
// another try, unless the panel was closed or reopened meanwhile.
|
|
1005
1006
|
if ok { self.model.images.removeAll { images.contains($0) } }
|
|
1006
1007
|
ImageIntake.delete(images.filter { !self.model.images.contains($0) })
|
|
1008
|
+
// Reopened meanwhile (as for a broadcast): the new open keeps its
|
|
1009
|
+
// message and phase, and isn't closed by this send's success.
|
|
1010
|
+
guard self.opens == open else { return }
|
|
1007
1011
|
var parts: [String] = []
|
|
1008
1012
|
if let chiOutcome { parts.append(chiOutcome.message) }
|
|
1009
1013
|
if !pasted.isEmpty {
|
|
@@ -1014,7 +1018,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
1014
1018
|
if ok {
|
|
1015
1019
|
self.model.phase = .sent
|
|
1016
1020
|
DispatchQueue.main.asyncAfter(deadline: .now() + (chiOutcome?.linger ?? 1.0)) { [weak self] in
|
|
1017
|
-
if self?.model.phase == .sent { self?.panel.close() }
|
|
1021
|
+
if self?.opens == open, self?.model.phase == .sent { self?.panel.close() }
|
|
1018
1022
|
}
|
|
1019
1023
|
} else {
|
|
1020
1024
|
self.model.phase = .failed
|
|
@@ -1022,8 +1026,8 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
1022
1026
|
}
|
|
1023
1027
|
}
|
|
1024
1028
|
|
|
1025
|
-
/// Counts the panel's opens: a broadcast that ends after a
|
|
1026
|
-
/// the new open alone.
|
|
1029
|
+
/// Counts the panel's opens: a send or broadcast that ends after a
|
|
1030
|
+
/// reopen leaves the new open alone.
|
|
1027
1031
|
private var opens = 0
|
|
1028
1032
|
|
|
1029
1033
|
/// The Everyone row: `chi broadcast` with the note (the source field is
|