samagotchi 0.18.1 → 0.20.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 +167 -1
- data/README.md +4 -3
- data/docs/architecture.md +21 -2
- data/docs/cli.md +48 -11
- data/docs/configuration.md +12 -5
- data/docs/guardrails.md +92 -14
- data/docs/memory.md +4 -0
- data/docs/plugins.md +5 -0
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +24 -5
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +25 -5
- data/lib/samagotchi/guardrail_wiring.rb +10 -3
- data/lib/samagotchi/guardrails/approval.rb +14 -7
- data/lib/samagotchi/guardrails/approvals.rb +69 -15
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +25 -6
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +79 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +17 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +4 -0
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +260 -32
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +83 -11
- data/lib/samagotchi/web/public/app.js +135 -43
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +15 -2
- data/lib/samagotchi/web/public/index.html +37 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/question_card.js +14 -1
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +108 -4
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +11 -2
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- metadata +7 -1
data/docs/sessions.md
CHANGED
|
@@ -13,17 +13,19 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
|
|
|
13
13
|
|
|
14
14
|
A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
|
|
15
15
|
|
|
16
|
-
**Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up. The session's status stays as it was, not `stopped`.
|
|
16
|
+
**Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, reminders, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
|
|
17
17
|
|
|
18
18
|
**Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session was empty, so it is discarded.` The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
|
|
19
19
|
|
|
20
20
|
**Attached by default:** plain `chi` and `chi --resume ID` run their session in a background worker and attach to it (`session.shared`, default `true`; env `SAMAGOTCHI_SESSION_SHARED`). The worker runs in the session's `working_directory`, so `!commands` and tools see the directory the session was started in. `session.shared: false` keeps the in-process REPL, `--no-shared` does for one run; a session the REPL has open can't be attached ("close it there first"). See [CLI: Sharing a session](cli.md#sharing-a-session).
|
|
21
21
|
|
|
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
|
+
|
|
22
24
|
**Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s.
|
|
23
25
|
|
|
24
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).
|
|
25
27
|
|
|
26
|
-
<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 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 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 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 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 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.
|
|
27
29
|
|
|
28
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.
|
|
29
31
|
|
|
@@ -63,7 +65,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
63
65
|
**Ordering:**
|
|
64
66
|
|
|
65
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.
|
|
66
|
-
- 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; 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); "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). 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. 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}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
68
|
+
- Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again); "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only. The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, sampling?}], warning?}`; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
67
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).
|
|
68
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), `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`).
|
|
69
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).
|
|
@@ -118,8 +120,8 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
|
|
|
118
120
|
before any answer: network error after 2 attempts (host main: Errno::ECONNREFUSED). The message went back to
|
|
119
121
|
the user, who may send it again.]` in the REPL and attached mode, where the prompt is given back;
|
|
120
122
|
`… The user's last message was not answered.]` after `-p`, where the prompt stays in the session;
|
|
121
|
-
- cancelled (Ctrl-C, the web's stop): `[SYSTEM: the previous turn was cancelled (ctrl-c) after 12s; the
|
|
122
|
-
above ends where it was cut off.]
|
|
123
|
+
- cancelled (Ctrl-C, the web's stop): `[SYSTEM: the previous turn was cancelled (ctrl-c) after 12s; the reply
|
|
124
|
+
above ends where it was cut off.]` (an answer or only narration had streamed), or `…; no answer had been shown.]` when only thinking had streamed;
|
|
123
125
|
- ended with nothing visible: `[SYSTEM: the previous turn ended with no visible answer (thinking only, or
|
|
124
126
|
nothing). The user's last message is still unanswered.]`.
|
|
125
127
|
|
|
@@ -95,20 +95,31 @@ module Samagotchi
|
|
|
95
95
|
# +base+, with -2, -3… when the name is taken or is the prefix of a
|
|
96
96
|
# model id (chi would read "qwen3:8b" as host qwen3, model 8b).
|
|
97
97
|
def host_name(base, requested: nil, model_ids: [])
|
|
98
|
+
host_name_with_reason(base, requested: requested, model_ids: model_ids).first
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# [name, reason]: reason is :taken when the name was suffixed because
|
|
102
|
+
# config.yml already has it, :model_prefix when a model id's prefix
|
|
103
|
+
# would read as a host, nil when the name is free as given.
|
|
104
|
+
def host_name_with_reason(base, requested: nil, model_ids: [])
|
|
98
105
|
taken = current_hosts.keys
|
|
99
106
|
if requested
|
|
100
107
|
name = requested.to_s.strip
|
|
101
108
|
raise Error, "--name must match #{ConfigFile::HOST_NAME_RE.source}" unless name.match?(ConfigFile::HOST_NAME_RE)
|
|
102
109
|
raise Error, "config.yml already has a host named '#{name}'; pick another --name" if taken.include?(name.downcase)
|
|
103
110
|
|
|
104
|
-
return name
|
|
111
|
+
return [name, nil]
|
|
105
112
|
end
|
|
106
113
|
|
|
107
114
|
prefixes = [*model_ids, configured_default_model].compact.map { |id| id.to_s.split(":", 2).first.downcase }
|
|
108
115
|
name = base
|
|
109
116
|
n = 1
|
|
110
|
-
|
|
111
|
-
name
|
|
117
|
+
reason = nil
|
|
118
|
+
while taken.include?(name) || prefixes.include?(name)
|
|
119
|
+
reason ||= taken.include?(name) ? :taken : :model_prefix
|
|
120
|
+
name = "#{base}-#{n += 1}"
|
|
121
|
+
end
|
|
122
|
+
[name, reason]
|
|
112
123
|
end
|
|
113
124
|
|
|
114
125
|
# Write the entry. @param fields [Hash] the hosts entry (string keys,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "rbconfig"
|
|
4
4
|
require_relative "config"
|
|
5
5
|
require_relative "model_profile"
|
|
6
|
+
require_relative "host_registry"
|
|
6
7
|
require_relative "bootstrap/probe"
|
|
7
8
|
require_relative "bootstrap/config_writer"
|
|
8
9
|
require_relative "bootstrap/bundles"
|
|
@@ -235,9 +236,11 @@ module Samagotchi
|
|
|
235
236
|
|
|
236
237
|
def test(result, model, key_env)
|
|
237
238
|
done = Queue.new
|
|
238
|
-
waiting =
|
|
239
|
-
|
|
240
|
-
|
|
239
|
+
waiting = if loading_hint?(result)
|
|
240
|
+
Thread.new do
|
|
241
|
+
@stdout.puts("test: waiting for an answer (loading the model?)…") if done.pop(timeout: LOADING_AFTER).nil?
|
|
242
|
+
end
|
|
243
|
+
end
|
|
241
244
|
seconds = @probe.test_turn(result.candidate, model, key_env: key_env)
|
|
242
245
|
@stdout.puts(format("test: answered in %.1f s", seconds))
|
|
243
246
|
true
|
|
@@ -249,17 +252,25 @@ module Samagotchi
|
|
|
249
252
|
waiting&.join
|
|
250
253
|
end
|
|
251
254
|
|
|
255
|
+
# The "loading the model?" hint fits a local server (a llama.cpp loading
|
|
256
|
+
# weights); a remote provider's slow answer is the network, not a load.
|
|
257
|
+
def loading_hint?(result)
|
|
258
|
+
!HostRegistry.remote_address?(result.candidate.scheme, result.candidate.host)
|
|
259
|
+
end
|
|
260
|
+
|
|
252
261
|
# 0 written, 1 written but not as planned, nil with @exit on a refusal.
|
|
253
262
|
def write(result, model, key_env, options)
|
|
254
263
|
writer = Bootstrap::ConfigWriter.new(path: @config_path, env: @env)
|
|
255
264
|
fields = host_fields(result.candidate, result.native?, key_env)
|
|
256
265
|
ids = result.models.map(&:id)
|
|
257
|
-
|
|
258
|
-
|
|
266
|
+
base = Bootstrap::ConfigWriter.derived_name(result.candidate.host)
|
|
267
|
+
name, reason = begin
|
|
268
|
+
writer.host_name_with_reason(base, requested: options[:name], model_ids: ids)
|
|
259
269
|
rescue Bootstrap::ConfigWriter::Error => e
|
|
260
270
|
@exit = usage_error(e.message)
|
|
261
271
|
return nil
|
|
262
272
|
end
|
|
273
|
+
explain_name(base, name, reason)
|
|
263
274
|
outcome = writer.write(name: name, fields: fields, model: model, dry_run: options[:dry_run])
|
|
264
275
|
code = report(outcome, model)
|
|
265
276
|
return code unless INSTALLS_BUNDLES.include?(outcome.kind)
|
|
@@ -269,6 +280,16 @@ module Samagotchi
|
|
|
269
280
|
code
|
|
270
281
|
end
|
|
271
282
|
|
|
283
|
+
# One line when the name wasn't free as derived: why chi picked another.
|
|
284
|
+
def explain_name(base, name, reason)
|
|
285
|
+
case reason
|
|
286
|
+
when :taken
|
|
287
|
+
@stdout.puts("a host named #{base} already exists; saved as #{name}, use --name to choose")
|
|
288
|
+
when :model_prefix
|
|
289
|
+
@stdout.puts("a model id starts with #{base} (chi would read it as a host); saved as #{name}, use --name to choose")
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
272
293
|
# The system bundle, core, and dev when a terminal says yes: one line
|
|
273
294
|
# each. 1 when something failed.
|
|
274
295
|
def install_bundles(dry_run: false)
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -212,8 +212,10 @@ module Samagotchi
|
|
|
212
212
|
# The pending question at the top level too: one asked between turns
|
|
213
213
|
# (the step-limit question) belongs to no turn, so the accumulator
|
|
214
214
|
# doesn't keep it.
|
|
215
|
+
# chi_version: the chi this worker runs (an attaching TUI compares it
|
|
216
|
+
# with its own).
|
|
215
217
|
# @return [Hash] {messages:, current_turn:, queued:, recap:, saved_recap:, continue_offer:, pending_question:,
|
|
216
|
-
# guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, event_seq:, event_id:}
|
|
218
|
+
# guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, chi_version:, event_seq:, event_id:}
|
|
217
219
|
def snapshot
|
|
218
220
|
@engine.synchronize_events do
|
|
219
221
|
seq = @engine.event_count
|
|
@@ -230,6 +232,7 @@ module Samagotchi
|
|
|
230
232
|
init_tasks: @engine.init_tasks,
|
|
231
233
|
cards: @cards.list,
|
|
232
234
|
commands: command_registry.listing,
|
|
235
|
+
chi_version: Samagotchi::VERSION,
|
|
233
236
|
event_seq: seq,
|
|
234
237
|
event_id: event_id(seq)
|
|
235
238
|
}
|
|
@@ -452,6 +455,12 @@ module Samagotchi
|
|
|
452
455
|
!host.empty? && !LOOPBACK_NAMES.include?(host.downcase.sub(/:\d*\z/, ""))
|
|
453
456
|
end
|
|
454
457
|
|
|
458
|
+
# What a client may ask of this worker beyond the routes, named in its
|
|
459
|
+
# sidecar (WorkerSidecar#features): a client checks a name here rather
|
|
460
|
+
# than a version.
|
|
461
|
+
# restart: POST /exit takes "restart": true (WorkerIdleExit#hold_for_restart).
|
|
462
|
+
FEATURES = ["restart"].freeze
|
|
463
|
+
|
|
455
464
|
# The routes, by method and what follows /session/:id/: a handler takes
|
|
456
465
|
# the session id and the request body and returns [headers, status, body].
|
|
457
466
|
# GET stream is served apart (#serve_sse owns the connection).
|
|
@@ -683,11 +692,13 @@ module Samagotchi
|
|
|
683
692
|
@engine.announce(type: :command_queued, **command, **anytime)
|
|
684
693
|
end
|
|
685
694
|
|
|
686
|
-
# A client asks the worker to exit now (`/exit` in the attached TUI)
|
|
687
|
-
#
|
|
688
|
-
#
|
|
689
|
-
#
|
|
690
|
-
#
|
|
695
|
+
# A client asks the worker to exit now (`/exit` in the attached TUI), or
|
|
696
|
+
# with "restart": true to hand the session to a new worker on the newest
|
|
697
|
+
# chi installed (the "restart" feature). The worker decides with the
|
|
698
|
+
# event log held, so no POST /turn lands between its check and its
|
|
699
|
+
# answer; it leaves from its loop after this reply.
|
|
700
|
+
# 200 {status: "exiting", discard?: the session is empty and goes} ({status: "restarting"} for a restart), or 409
|
|
701
|
+
# {status: "held", reason:} naming what keeps it up. Returns [headers, status, body].
|
|
691
702
|
def handle_exit_request(session_id, body)
|
|
692
703
|
return [{}, 501, { error: "exit_unavailable" }] unless @on_exit_request
|
|
693
704
|
|
|
@@ -696,8 +707,12 @@ module Samagotchi
|
|
|
696
707
|
|
|
697
708
|
client_id = fetched(parsed, "client_id")
|
|
698
709
|
delete = fetched(parsed, "delete") == true
|
|
699
|
-
|
|
710
|
+
restart = fetched(parsed, "restart") == true
|
|
711
|
+
options = restart ? { delete: delete, restart: true } : { delete: delete }
|
|
712
|
+
reason = @engine.synchronize_events { @on_exit_request.call(client_id, **options) }
|
|
700
713
|
if reason.nil?
|
|
714
|
+
return [{}, 200, { status: "restarting", session_id: @session_id }] if restart
|
|
715
|
+
|
|
701
716
|
body = { status: "exiting", session_id: @session_id }
|
|
702
717
|
body[:discard] = @exit_discards.call == true if @exit_discards
|
|
703
718
|
return [{}, 200, body]
|
|
@@ -1029,9 +1044,10 @@ module Samagotchi
|
|
|
1029
1044
|
end
|
|
1030
1045
|
|
|
1031
1046
|
def write_sidecar
|
|
1032
|
-
# version: the chi this worker runs (chi update
|
|
1047
|
+
# version: the chi this worker runs (chi update and the web's badge
|
|
1048
|
+
# report older ones); features: what a client may ask of it.
|
|
1033
1049
|
WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
|
|
1034
|
-
version: Samagotchi::VERSION, input_format: @input_format).write(session_dir)
|
|
1050
|
+
version: Samagotchi::VERSION, input_format: @input_format, features: FEATURES).write(session_dir)
|
|
1035
1051
|
rescue StandardError => e
|
|
1036
1052
|
Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{WorkerSidecar::FILE}: #{e.class}: #{e.message}", error: e.class.name)
|
|
1037
1053
|
end
|
|
@@ -17,8 +17,17 @@ module Samagotchi
|
|
|
17
17
|
# not serve the session (404), it yields one synthetic
|
|
18
18
|
# `{"type" => "stream_closed", "reason" => ...}` and stops. #close stops
|
|
19
19
|
# it without that event.
|
|
20
|
+
#
|
|
21
|
+
# With +rediscover+, a failed connection first looks for the session's
|
|
22
|
+
# live worker (its sidecar): a new one (a restart) is followed from its
|
|
23
|
+
# snapshot, announced by a synthetic `{"type" => "worker_changed",
|
|
24
|
+
# "port" => ...}` first, since its event ids start over. (A new worker
|
|
25
|
+
# on the old one's port is the plain reconnect: its Bridge can't replay
|
|
26
|
+
# the old ids and sends a reset.)
|
|
20
27
|
class EventStream
|
|
21
28
|
DEFAULT_RECONNECT_DELAYS = [0.1, 0.25, 0.5, 1.0, 2.0].freeze
|
|
29
|
+
# Long enough for a restarted worker to boot (its Engine, the plugins).
|
|
30
|
+
REDISCOVER_DELAYS = [0.1, 0.25, 0.5, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0].freeze
|
|
22
31
|
HEADER_TIMEOUT = 5.0
|
|
23
32
|
|
|
24
33
|
# @return [String, nil] the id of the last frame yielded (the reconnect cursor)
|
|
@@ -30,9 +39,13 @@ module Samagotchi
|
|
|
30
39
|
# tell this client's stream from others' (Bridge#open_streams_except)
|
|
31
40
|
# @param reconnect_delays [Array<Float>] sleeps between failed attempts;
|
|
32
41
|
# one more failure than there are delays gives up
|
|
42
|
+
# @param rediscover [#call, nil] → a BridgeClient for the session's live
|
|
43
|
+
# worker now, nil when there is none yet, or :gone to give up now
|
|
33
44
|
# @yieldparam event [Hash] string-keyed event
|
|
34
|
-
def initialize(client, snapshot: true, client_id: nil, reconnect_delays: DEFAULT_RECONNECT_DELAYS,
|
|
45
|
+
def initialize(client, snapshot: true, client_id: nil, reconnect_delays: DEFAULT_RECONNECT_DELAYS, rediscover: nil,
|
|
46
|
+
&on_event)
|
|
35
47
|
@client = client
|
|
48
|
+
@rediscover = rediscover
|
|
36
49
|
params = []
|
|
37
50
|
params << ["snapshot", "1"] if snapshot
|
|
38
51
|
params << ["client_id", client_id] if client_id
|
|
@@ -86,6 +99,13 @@ module Samagotchi
|
|
|
86
99
|
next
|
|
87
100
|
end
|
|
88
101
|
|
|
102
|
+
case rediscover_worker
|
|
103
|
+
when :moved
|
|
104
|
+
failures = 0
|
|
105
|
+
next
|
|
106
|
+
when :gone then return finish("unreachable")
|
|
107
|
+
end
|
|
108
|
+
|
|
89
109
|
failures += 1
|
|
90
110
|
return finish("unreachable") if failures > @reconnect_delays.size
|
|
91
111
|
|
|
@@ -148,6 +168,22 @@ module Samagotchi
|
|
|
148
168
|
nil
|
|
149
169
|
end
|
|
150
170
|
|
|
171
|
+
# @return [Symbol, nil] :moved to a new worker's Bridge, :gone, or nil
|
|
172
|
+
def rediscover_worker
|
|
173
|
+
return nil unless @rediscover
|
|
174
|
+
|
|
175
|
+
found = @rediscover.call
|
|
176
|
+
return :gone if found == :gone
|
|
177
|
+
return nil if found.nil? || found.port == @client.port
|
|
178
|
+
|
|
179
|
+
@client = found
|
|
180
|
+
@last_event_id = nil
|
|
181
|
+
@on_event.call({ "type" => "worker_changed", "port" => found.port }) unless closed?
|
|
182
|
+
:moved
|
|
183
|
+
rescue StandardError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
151
187
|
def finish(reason)
|
|
152
188
|
@on_event.call({ "type" => "stream_closed", "reason" => reason }) unless closed?
|
|
153
189
|
end
|
|
@@ -156,14 +156,16 @@ module Samagotchi
|
|
|
156
156
|
post("command", payload, read_body: true)
|
|
157
157
|
end
|
|
158
158
|
|
|
159
|
-
# POST /session/:id/exit: ask the worker to exit now
|
|
160
|
-
#
|
|
159
|
+
# POST /session/:id/exit: ask the worker to exit now (+restart+: hand
|
|
160
|
+
# the session to a new worker, a worker with the "restart" feature). 200
|
|
161
|
+
# = it will (status "exiting"/"restarting"), 409 = something keeps it up (reason), 404 with
|
|
161
162
|
# error "not_found" = a worker older than the route.
|
|
162
163
|
# @param client_id [String] the asking UI, whose own streams don't hold
|
|
163
164
|
# @return [Response]
|
|
164
|
-
def request_exit(client_id:, delete: false)
|
|
165
|
+
def request_exit(client_id:, delete: false, restart: false)
|
|
165
166
|
body = { client_id: client_id }
|
|
166
167
|
body[:delete] = true if delete
|
|
168
|
+
body[:restart] = true if restart
|
|
167
169
|
post("exit", body, read_body: true)
|
|
168
170
|
end
|
|
169
171
|
|
|
@@ -290,11 +292,13 @@ module Samagotchi
|
|
|
290
292
|
# Follow the session's events on a reader thread (see EventStream).
|
|
291
293
|
# @param snapshot [Boolean] join with a snapshot frame
|
|
292
294
|
# @param client_id [String, nil] names the stream (see EventStream)
|
|
295
|
+
# @param rediscover [#call, nil] follows a new worker (see EventStream)
|
|
293
296
|
# @yieldparam event [Hash] string-keyed event
|
|
294
297
|
# @return [EventStream] started
|
|
295
|
-
def follow(snapshot: true, client_id: nil, reconnect_delays: EventStream::DEFAULT_RECONNECT_DELAYS,
|
|
298
|
+
def follow(snapshot: true, client_id: nil, reconnect_delays: EventStream::DEFAULT_RECONNECT_DELAYS, rediscover: nil,
|
|
299
|
+
&on_event)
|
|
296
300
|
EventStream.new(self, snapshot: snapshot, client_id: client_id, reconnect_delays: reconnect_delays,
|
|
297
|
-
&on_event).start
|
|
301
|
+
rediscover: rediscover, &on_event).start
|
|
298
302
|
end
|
|
299
303
|
|
|
300
304
|
# Open GET /session/:id/stream and read past the response headers.
|
|
@@ -263,6 +263,10 @@ module Samagotchi
|
|
|
263
263
|
@stdout.puts "Provenance updated after interactive resolution."
|
|
264
264
|
@stdout.puts "Upgrade resolved interactively."
|
|
265
265
|
0
|
|
266
|
+
ensure
|
|
267
|
+
# An owned source (git/zip/tar) was kept for the incoming files above;
|
|
268
|
+
# the agent step is done with them now.
|
|
269
|
+
installer.cleanup_source!
|
|
266
270
|
end
|
|
267
271
|
|
|
268
272
|
def uninstall(rest)
|
|
@@ -353,15 +357,22 @@ module Samagotchi
|
|
|
353
357
|
@stdout.puts "Bundle '#{name}' not installed."
|
|
354
358
|
return 0
|
|
355
359
|
end
|
|
356
|
-
@stdout.puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{st
|
|
357
|
-
|
|
360
|
+
@stdout.puts "Bundle: #{name} v#{st[:provenance][:version]} scope=#{status_scope(st)} installed=#{st[:provenance][:installed_at]}"
|
|
361
|
+
if st[:scope_error]
|
|
362
|
+
@stdout.puts "Target: (unknown scope: this chi can't resolve it; upgrade chi or reinstall the bundle)"
|
|
363
|
+
else
|
|
364
|
+
@stdout.puts "Target: #{st[:target_dir]}"
|
|
365
|
+
end
|
|
358
366
|
st[:files].each do |k, info|
|
|
367
|
+
next @stdout.puts(" #{k}: unchecked") if info[:unchecked]
|
|
368
|
+
|
|
359
369
|
mods = []
|
|
360
370
|
mods << "conflict (kept your edits over v#{st[:provenance][:version]}: chi bundle diff #{name} #{k})" if info[:conflict]
|
|
361
371
|
mods << "modified" if info[:modified]
|
|
362
372
|
mods << "missing" if info[:missing]
|
|
363
|
-
mods << "no-index" unless info[:index_present]
|
|
373
|
+
mods << "no-index" unless info[:index_present] || info[:overlay]
|
|
364
374
|
label = mods.empty? ? "ok" : mods.join(",")
|
|
375
|
+
label += " (model overlay)" if info[:overlay]
|
|
365
376
|
@stdout.puts " #{k}: #{label}"
|
|
366
377
|
end
|
|
367
378
|
# Hooks
|
|
@@ -408,15 +419,19 @@ module Samagotchi
|
|
|
408
419
|
plugin = st[:plugin]
|
|
409
420
|
mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
|
|
410
421
|
mods += 1 if st[:hooks_requires_failure]
|
|
422
|
+
mods += 1 if st[:scope_error]
|
|
411
423
|
plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
|
|
412
424
|
includes = st[:provenance][:includes]
|
|
413
425
|
includes_info = includes ? " includes=#{includes.join(",")}" : ""
|
|
414
|
-
@stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{st
|
|
426
|
+
@stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
|
|
415
427
|
end
|
|
416
428
|
end
|
|
417
429
|
return 0
|
|
418
430
|
end
|
|
419
431
|
|
|
432
|
+
# "team (unknown)" for a scope this chi can't resolve (Status#bundle_status).
|
|
433
|
+
def status_scope(st) = st[:scope_error] ? "#{st[:scope]} (unknown)" : st[:scope]
|
|
434
|
+
|
|
420
435
|
def diff(rest)
|
|
421
436
|
args = rest.reject { |a| a.start_with?("--") }
|
|
422
437
|
bname = args[0]
|
|
@@ -430,7 +445,11 @@ module Samagotchi
|
|
|
430
445
|
@stderr.puts "Bundle '#{bname}' not installed"; return 1
|
|
431
446
|
end
|
|
432
447
|
scope = data[:scope]&.to_s || "system"
|
|
433
|
-
|
|
448
|
+
begin
|
|
449
|
+
target_dir = Samagotchi::MemoryPaths.scope_dir!(scope)
|
|
450
|
+
rescue ArgumentError => e
|
|
451
|
+
@stderr.puts "Bundle '#{bname}': #{e.message}"; return 1
|
|
452
|
+
end
|
|
434
453
|
files = data[:files] || {}
|
|
435
454
|
hooks = data[:hooks] || {}
|
|
436
455
|
plugin_path = prov.plugin_path(data)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: check-in
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Checks on a long turn: after N tool calls with no answer, a card asks the user (Nudge / Keep going / Stop), or the model is nudged to say what it has found"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:afbd0ecf5a41eefe79786e089aa918728ebb0fed0e3b75f84d754a47ec6ade18
|
|
10
10
|
requires_chi: ">= 0.3.0"
|
|
@@ -103,7 +103,11 @@ class Plugin
|
|
|
103
103
|
def check_in(ctx, count, mode)
|
|
104
104
|
case mode
|
|
105
105
|
when "nudge"
|
|
106
|
-
|
|
106
|
+
text = message(count)
|
|
107
|
+
if ctx.steer(text)
|
|
108
|
+
@mutex.synchronize { @nudge = { text: text, count: count } }
|
|
109
|
+
ctx.notify("nudged the model after #{count} tool calls")
|
|
110
|
+
end
|
|
107
111
|
when "notify"
|
|
108
112
|
ctx.notify("#{count} tool calls in this turn, no answer yet")
|
|
109
113
|
else
|
|
@@ -117,15 +121,20 @@ class Plugin
|
|
|
117
121
|
nudge_not_sent(event, ctx)
|
|
118
122
|
end
|
|
119
123
|
|
|
120
|
-
#
|
|
121
|
-
#
|
|
124
|
+
# A nudge that was accepted but dropped before it joined the turn (the
|
|
125
|
+
# model answered first, or the turn was stopped): the card says so, or,
|
|
126
|
+
# for a mode nudge (no card), a notice does.
|
|
122
127
|
def nudge_not_sent(event, ctx)
|
|
123
128
|
id, nudge = @mutex.synchronize { [@card_id, @nudge] }
|
|
124
|
-
return unless
|
|
129
|
+
return unless nudge
|
|
125
130
|
return if steered?(event[:messages], nudge[:text])
|
|
126
131
|
|
|
127
132
|
first = event[:status].to_s == "canceled" ? "The turn ended first" : "The answer came first"
|
|
128
|
-
|
|
133
|
+
if id
|
|
134
|
+
ctx.card(id: id, title: "check-in", body: "#{first}; nudge not sent.")
|
|
135
|
+
else
|
|
136
|
+
ctx.notify("#{first}; nudge not sent.", level: :warn)
|
|
137
|
+
end
|
|
129
138
|
end
|
|
130
139
|
|
|
131
140
|
# Whether this turn's messages (after its last prompt) have the steer.
|
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Fields: id, tool (a name, a list, or shell = execute + task_create),
|
|
7
7
|
# command (Ruby regex), path (outside_repo or a glob), git (outside_repo:
|
|
8
|
-
# a shell call runs commit/add/reset/… in another checkout),
|
|
9
|
-
#
|
|
8
|
+
# a shell call runs commit/add/reset/… in another checkout), touches
|
|
9
|
+
# (chi_dirs: a shell command names a path in chi's own dirs), skip_read_only
|
|
10
|
+
# (true: a command that only reads, ls/cat/rg/sed -n/git log…, doesn't
|
|
11
|
+
# match), modes (strict: only with guardrails.mode: strict; untagged rules
|
|
12
|
+
# ask in every mode), verdict (ask|deny), reason, scopes (for an ask: once,
|
|
13
|
+
# session, repo, rule; default all).
|
|
10
14
|
# [^;&|\n]* keeps a match inside one command of a chain; the git rules match
|
|
11
15
|
# the subcommand after optional global options (git -C dir push).
|
|
12
16
|
rules:
|
|
@@ -31,9 +35,11 @@ rules:
|
|
|
31
35
|
command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+branch\b[^;&|\n]*\s(-[a-zA-Z]*D|--delete\s+--force|--force\s+--delete)\b'
|
|
32
36
|
verdict: ask
|
|
33
37
|
reason: git branch -D deletes a branch even if it is not merged
|
|
38
|
+
# Strict mode only: a rebase rewrites local history, which the reflog keeps.
|
|
34
39
|
- id: git-rebase
|
|
35
40
|
tool: shell
|
|
36
41
|
command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+rebase\b'
|
|
42
|
+
modes: [strict]
|
|
37
43
|
verdict: ask
|
|
38
44
|
reason: git rebase rewrites history
|
|
39
45
|
- id: git-history-rewrite
|
|
@@ -44,10 +50,13 @@ rules:
|
|
|
44
50
|
|
|
45
51
|
# ── deleting ───────────────────────────────────────────────────────────
|
|
46
52
|
# rm with both -r and -f on an absolute path, ~, $HOME or a path with ..
|
|
47
|
-
# (a text heuristic; rm -rf build/ in the repo is not asked)
|
|
53
|
+
# (a text heuristic; rm -rf build/ in the repo is not asked), unless
|
|
54
|
+
# every rm -rf target is inside a tmp dir (rm: outside_tmp; /tmp itself,
|
|
55
|
+
# /tmp/* and a link out of it still ask).
|
|
48
56
|
- id: rm-rf-wide
|
|
49
57
|
tool: shell
|
|
50
58
|
command: '\brm\b(?=[^;&|\n]*\s(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(\s|$))(?=[^;&|\n]*\s(-[a-zA-Z]*f[a-zA-Z]*|--force)(\s|$))[^;&|\n]*\s(/|~|\$HOME|\S*\.\.)'
|
|
59
|
+
rm: outside_tmp
|
|
51
60
|
verdict: ask
|
|
52
61
|
reason: rm -rf on a path outside the working tree
|
|
53
62
|
|
|
@@ -65,10 +74,13 @@ rules:
|
|
|
65
74
|
|
|
66
75
|
# ── work outside this session's repo, and git hooks ───────────────────
|
|
67
76
|
# outside_repo: not in the session's repo (its folder outside a repo);
|
|
68
|
-
# tmp dirs and memory files don't count.
|
|
77
|
+
# tmp dirs and memory files don't count. Strict mode only, as is
|
|
78
|
+
# git-outside-repo; chi's own config and hooks still ask (core protected
|
|
79
|
+
# paths, shell-touches-chi).
|
|
69
80
|
- id: write-outside-repo
|
|
70
81
|
tool: [write, edit]
|
|
71
82
|
path: outside_repo
|
|
83
|
+
modes: [strict]
|
|
72
84
|
verdict: ask
|
|
73
85
|
reason: writes outside this session's repo
|
|
74
86
|
# cd/git -C into another checkout, then commit, add, reset, checkout,
|
|
@@ -80,6 +92,7 @@ rules:
|
|
|
80
92
|
- id: git-outside-repo
|
|
81
93
|
tool: shell
|
|
82
94
|
git: outside_repo
|
|
95
|
+
modes: [strict]
|
|
83
96
|
verdict: ask
|
|
84
97
|
scopes: [once, session, rule]
|
|
85
98
|
reason: runs git that changes another checkout, outside this session's repo
|
|
@@ -90,9 +103,15 @@ rules:
|
|
|
90
103
|
reason: git hooks run code on every git command
|
|
91
104
|
|
|
92
105
|
# ── chi's own config, hooks, rules and approvals, from the shell ───────
|
|
106
|
+
# The real dirs (config.yml's dir, hooks, the approval store, installed
|
|
107
|
+
# bundles, this repo's git hooks), not a look-alike under /tmp or chi's
|
|
108
|
+
# own lib/samagotchi/hooks source; a word that can't be resolved ($VAR,
|
|
109
|
+
# $(…), a glob) is matched as text. A command that only reads them (ls,
|
|
110
|
+
# cat, rg, sed -n) isn't asked about.
|
|
93
111
|
- id: shell-touches-chi
|
|
94
112
|
tool: shell
|
|
95
|
-
|
|
113
|
+
touches: chi_dirs
|
|
114
|
+
skip_read_only: true
|
|
96
115
|
verdict: ask
|
|
97
116
|
reason: the command names chi's config, hooks, guardrails or git hooks
|
|
98
117
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: guardrails
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.5.0
|
|
4
4
|
scope: system
|
|
5
|
-
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes and git in another checkout outside the session's repo
|
|
5
|
+
description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config from the shell, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
|
|
6
6
|
trust_level: reviewed
|
|
7
|
-
# models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0
|
|
8
|
-
|
|
7
|
+
# models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0;
|
|
8
|
+
# skip_read_only:, touches:, rm: and modes: need 0.20.0
|
|
9
|
+
requires_chi: ">= 0.20.0"
|
|
9
10
|
files:
|
|
10
11
|
guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.4.
|
|
3
|
+
version: 0.4.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files: {}
|
|
8
8
|
plugin:
|
|
9
9
|
file: plugin.rb
|
|
10
|
-
sha256: sha256:
|
|
10
|
+
sha256: sha256:7c87e14dbdb20d32c37ef2a83f8d56448807ad26564a4034db4f6afbcf8a0323
|
|
11
11
|
requires_chi: ">= 0.8.0"
|