samagotchi 0.16.0 → 0.18.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 +149 -1
- data/README.md +2 -0
- data/docs/architecture.md +38 -2
- data/docs/cli.md +37 -9
- data/docs/configuration.md +8 -6
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +41 -0
- data/docs/plugins.md +5 -2
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +43 -4
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
- data/lib/samagotchi/bridge.rb +86 -14
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +121 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -0
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +73 -19
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +136 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +46 -6
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +137 -48
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +46 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +35 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +5 -4
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +189 -43
- data/lib/samagotchi/web/public/app.js +157 -51
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +12 -5
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/stage_view.js +13 -1
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +67 -12
- data/lib/samagotchi/web/public/turn_events.js +74 -3
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +58 -60
- data/lib/samagotchi/worker_sidecar.rb +7 -1
- metadata +10 -1
data/docs/sessions.md
CHANGED
|
@@ -13,7 +13,7 @@ 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 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`.
|
|
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
|
|
|
@@ -23,7 +23,7 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
23
23
|
|
|
24
24
|
**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
25
|
|
|
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; 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.
|
|
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.
|
|
27
27
|
|
|
28
28
|
**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
29
|
|
|
@@ -40,7 +40,7 @@ chi sessions prune [--dry-run] [--days N] [--keep N] [--keep-status running,...]
|
|
|
40
40
|
chi sessions clean [--dry-run] [--days N] # test sessions: all, or older than N days
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
A session whose worker waits for an answer (a question, a hook's question or a
|
|
43
|
+
A session whose worker waits for an answer (a question, a hook's question, a guardrail approval, or the step-limit question of a turn that ran out of steps, which waits between turns) reads `waiting` in the status column, and json has `waiting: "question"|"approval"|"hook"|"continue"` and `waiting_id`, the question's id for `chi answer --question` (both null otherwise). A delegate's approval its parent relayed to its own card (see *Delegating*) reads `waiting (in parent ab12)`, json `relayed_to: "ab12…"` (the parent's short id, else null). Only while a worker runs it: a question a dead worker left in the file shows the saved status. Answer it with `chi answer`, the web or `chi --attach ID`; `chi sessions stop ID` frees the worker instead.
|
|
44
44
|
|
|
45
45
|
Examples:
|
|
46
46
|
|
|
@@ -63,7 +63,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
63
63
|
**Ordering:**
|
|
64
64
|
|
|
65
65
|
- `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; "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
|
|
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).
|
|
67
67
|
- 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
68
|
- 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
69
|
- 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).
|
|
@@ -149,7 +149,8 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
|
|
|
149
149
|
- A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Only sessions on this machine: Bridges listen on 127.0.0.1.
|
|
150
150
|
- Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
|
|
151
151
|
- `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image stops the send (exit 1; with `--wait --format json`, an `error` line) and nothing is sent; more than 20 is a usage error (exit 2). Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
|
|
152
|
-
-
|
|
152
|
+
- A session command runs as the command, as typed in a terminal: `chi send -m "/model qwen" 3fa2` switches the model (`sent as a session command`), and the model never sees the line. What counts is what the session's worker knows (the built-ins, `!cmd`, its bundles' commands); an unknown `/word` (`/usr/bin/env is missing`) goes in as a message. With `--new` a line starting with `/` or `!` starts the session idle and goes to its worker once it is up, which decides. With `--wait` a command has no reply to wait for: exit 0 at once, `<id> sent as a session command: no reply to wait for …` on stderr (`{"status":"command",…,"detail":…}` with `--format json`); its output shows where the session is open.
|
|
153
|
+
- One line per session: `sent`, `sent as a session command`, `sent with 2 images`, `sent (the running turn picks it up)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
|
|
153
154
|
|
|
154
155
|
The Automator action above works for messages too: swap its last line for `pbpaste | "$chi" send -m "what do you make of this?" $(print -r -- "$picked" | cut -f1)`.
|
|
155
156
|
|
|
@@ -168,7 +169,7 @@ chi send --wait 3fa2 # sends nothing: waits fo
|
|
|
168
169
|
- `--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; the name isn't checked up front, a wrong one fails the worker's first turn).
|
|
169
170
|
- `--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.
|
|
170
171
|
- `--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.
|
|
171
|
-
- Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question
|
|
172
|
+
- 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.
|
|
172
173
|
- `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1). A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
|
|
173
174
|
- `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
|
|
174
175
|
- The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
|
|
@@ -181,6 +182,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
|
|
|
181
182
|
- `delegate(task, model:, session:, wait:, timeout:)`: starts a child and, by default, waits for its reply (`wait: false` returns at once, so several children can run side by side; `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting). With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one.
|
|
182
183
|
- `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one.
|
|
183
184
|
- What a wait returns, `session: <id>` then `status:` and the text, with the status words `chi send --wait --format json` uses: `answered` with the reply after a `---` line; `question` as soon as the child opens a question (the model's own, or a hook's), with the question's text, options and the `chi answer` command for it (nothing is answered for it: `chi answer`, attach or use the web, then wait again); `failed`, `canceled` or `no_answer` when the child's turn ended with nothing to say (its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `worker_gone` when the child has had no worker for 15 seconds (it crashed without saying so, or idle-exited): `delegate` with `session:` starts it again; `running` on timeout, or when the parent's own turn is canceled (the child keeps running; the next turn can wait again). A reply saved before the child's worker idle-exited is returned at once.
|
|
185
|
+
- **A child's step-limit question goes back to the parent model**, not to its user: `delegate_result` returns `status: question` with the `chi answer` commands for Continue and Stop, and says the model may instead send a narrower follow-up (`delegate session:`, which drops the question) or report back. A parent's `chi answer` follows `turn.parent_continue` (see [chi as a sub-agent](sub-agent.md#the-step-limit)).
|
|
184
186
|
- **A child's approvals go to the parent's user** (the approval relay). While the parent waits (`delegate`, `delegate_result`), a guardrail approval the child opens reopens as the parent session's own approval: the same card in the parent's web page and attached terminal, with the same facts and diff, a line naming the delegate and its task (`delegate ab12 · fix the flaky spec…`, linked to its session) and the header `Approve delegate ab12's tool call?`. The options are the child's, by index; "for the session" says it is the delegate's session (the session scope keys on the child; repo and rule approvals are one shared store). The user answers once there; the parent's model never sees the approval, only one line per relayed approval in the tool's result, before the reply: `approval relayed to your user: execute: git push origin main → allowed once` (or `denied ("why")`, `dismissed (denied)`, `answered on the child`, `not delivered (the child's worker is gone)`, `refused by the child …`, `still open (your turn was stopped)`). The time the card is open doesn't count against `timeout`. While the parent waits on one child, its other running children's approvals are relayed too, oldest first, one card at a time (`+1 more delegate waiting`); each outcome comes in that child's next result.
|
|
185
187
|
- The child's own card stays answerable (the first answer wins: answered there, the parent's card closes as "answered in ab12"). While relayed it says `Waiting for approval in parent ab12: answering here works too`; `chi sessions list` shows the child as `waiting (in parent ab12)` (`--format json`: `relayed_to`), and the web's bell rings for the parent's card, not the child's.
|
|
186
188
|
- How the child trusts it: the parent's POST that says "answered" (Bridge `POST /session/<child>/relay`) carries no answer. The child asks its parent session's live Bridge (`POST /session/<parent>/relay/status`), which reports only what the parent's question flow took from a UI (`RelayDesk`, in memory only), and takes it only for this child, the question pending now, answered. A parent agent's answer on the relay card (a piped or marked `chi --attach PARENT`, `chi answer`) is held to `guardrails.parent_approvals` by the parent and again by the child.
|
data/docs/sub-agent.md
CHANGED
|
@@ -33,9 +33,11 @@ Exit codes, both commands:
|
|
|
33
33
|
| Exit | `status` | Meaning |
|
|
34
34
|
|---|---|---|
|
|
35
35
|
| 0 | `answered` | `text` is chi's reply |
|
|
36
|
+
| 0 | `command` | (`chi send`) the message was a session command (`/model x`): it ran as one, no reply comes |
|
|
37
|
+
| 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
|
|
36
38
|
| 3 | `question` | chi waits for an answer: `question`, `answer_with` |
|
|
37
39
|
| 4 | `running` | `--timeout` passed; the turn goes on, wait again |
|
|
38
|
-
| 1 | `failed`, `canceled`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened |
|
|
40
|
+
| 1 | `failed`, `canceled`, `limit`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it) |
|
|
39
41
|
| 2 | (no JSON) | usage error, or an option the question doesn't offer |
|
|
40
42
|
| 130 | `running` | Ctrl-C; the turn goes on |
|
|
41
43
|
|
|
@@ -48,8 +50,33 @@ With `--format json` stdout is one JSON object (see
|
|
|
48
50
|
"allow_freeform":false},"answer_with":"chi answer 297da360-… --question 3f1c… --option N"}
|
|
49
51
|
```
|
|
50
52
|
|
|
51
|
-
`kind` is `question` (the model's), `hook`,
|
|
52
|
-
command, folder, rule and reason.
|
|
53
|
+
`kind` is `question` (the model's), `hook`, `approval`, which adds the tool,
|
|
54
|
+
command, folder, rule and reason, or `continue`, the step-limit question below.
|
|
55
|
+
|
|
56
|
+
## The step limit
|
|
57
|
+
|
|
58
|
+
A turn that runs out of steps (`turn.max_iterations`, default 100) before it
|
|
59
|
+
answers waits as a question of kind `continue` (`"limit": 100`, options
|
|
60
|
+
`Continue` and `Stop`). `answer_with` continues it:
|
|
61
|
+
`chi answer ID --question QID --option Continue`, and the continued turn's
|
|
62
|
+
reply (or its next question) comes back on that same wait. To stop it:
|
|
63
|
+
`--option Stop`, with `--text "why"` for the model (Continue takes no text);
|
|
64
|
+
that wait ends with `status: not_continued`, exit 0, and the turn's work so far
|
|
65
|
+
stays in the session. A message instead (`chi send ID -m "…"`) drops the
|
|
66
|
+
question and starts a new turn. It can't be dismissed.
|
|
67
|
+
|
|
68
|
+
A parent may Continue by default: Continue grants no permission (every tool
|
|
69
|
+
call still meets the guardrails), it only spends time and tokens. With
|
|
70
|
+
`turn.parent_continue: false` in chi's config.yml (config.yml only, no
|
|
71
|
+
environment variable) a parent may only Stop: `chi answer` and the worker
|
|
72
|
+
refuse a Continue (exit 1, `a parent agent may only stop this turn here`), and
|
|
73
|
+
the user continues it from the web or the terminal.
|
|
74
|
+
|
|
75
|
+
A chi parent's `delegate_result` gets a child's step-limit question back as a
|
|
76
|
+
`question` with these commands, for its model to decide: continue it (the
|
|
77
|
+
`chi answer` command, through `execute`), send a narrower follow-up with
|
|
78
|
+
`delegate session:` (which drops the question), or report back. It isn't
|
|
79
|
+
relayed to the parent's user the way approvals are.
|
|
53
80
|
|
|
54
81
|
## Approvals
|
|
55
82
|
|
|
@@ -74,6 +101,10 @@ variable), "Allow once" goes through; the wider scopes never do,
|
|
|
74
101
|
and neither does any allow on chi's own config, hooks or guardrail rules. See
|
|
75
102
|
[Guardrails](guardrails.md#approvals-from-a-parent-agent).
|
|
76
103
|
|
|
104
|
+
Start a child in the folder it should change (`chi send --new --dir PATH`):
|
|
105
|
+
writes and mutating git outside its own repo ask (`write-outside-repo`,
|
|
106
|
+
`git-outside-repo`), which a parent can only deny.
|
|
107
|
+
|
|
77
108
|
An answer typed into `chi --attach` or the REPL counts as a parent's too when
|
|
78
109
|
stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
|
|
79
110
|
`CODEX_THREAD_ID`, `SAMAGOTCHI_PARENT_SESSION`) is set.
|
|
@@ -95,7 +126,11 @@ answers from any local process.
|
|
|
95
126
|
`chi answer` then exits 1 and says to send the task again
|
|
96
127
|
(`chi send --wait -m "…" ID`); a wait ends with `worker_gone` (exit 1).
|
|
97
128
|
- A follow-up (`chi send --wait -m "…" ID`) to a session waiting for an answer
|
|
98
|
-
isn't sent: it exits 1 with the `chi answer` command for the question.
|
|
129
|
+
isn't sent: it exits 1 with the `chi answer` command for the question. The
|
|
130
|
+
step-limit question is the exception: it waits between turns, so a message
|
|
131
|
+
goes in, drops it and starts a new turn.
|
|
132
|
+
- `chi send --wait ID` with no message waits past a question already reported
|
|
133
|
+
(the step-limit question too), until someone answers it.
|
|
99
134
|
- An answer to a question that is no longer open (you answered it in the web
|
|
100
135
|
first) isn't sent; `chi answer` says so and waits for what comes next.
|
|
101
136
|
- `chi send --wait` and `chi answer` don't read stdin unless it's a pipe or a
|
|
@@ -129,6 +164,10 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
|
|
|
129
164
|
`chi answer`, a piped `chi --attach` or chi's web API.
|
|
130
165
|
- `status: running` (exit 4): still working. Wait again:
|
|
131
166
|
`chi send --wait --format json --timeout 500 <session_id>`.
|
|
167
|
+
- `question.kind: continue`: chi ran out of steps before it answered. If it is
|
|
168
|
+
getting somewhere, continue it (`answer_with`, `--option Continue`); if not,
|
|
169
|
+
stop it (`--option Stop --text "why"`, exit 0, status `not_continued`) or send
|
|
170
|
+
a narrower follow-up instead. Ask your user when unsure.
|
|
132
171
|
- Anything else (exit 1): report `detail` to your user. `chi --attach <session_id>`
|
|
133
172
|
shows the session.
|
|
134
173
|
|
data/docs/testing.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
bundle exec parallel_rspec -n 8 # Ruby specs, in parallel (or plain: bundle exec rspec)
|
|
5
|
+
npm test # web frontend specs
|
|
6
|
+
npm run e2e # web UI happy paths in Chromium, fake model (once: npx playwright install chromium)
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
|
|
10
|
+
clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
|
|
11
|
+
|
|
12
|
+
## Integration specs
|
|
13
|
+
|
|
14
|
+
Examples tagged `:integration` (`spec/integration/`, the "with LLM access" part of `spec/hooks/integration_spec.rb`)
|
|
15
|
+
run turns against a live model server. They are skipped unless `SAMAGOTCHI_INTEGRATION=1` is set, and they need the
|
|
16
|
+
server and the model it serves:
|
|
17
|
+
|
|
18
|
+
| Variable | Meaning | Default |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `SAMAGOTCHI_INTEGRATION` | `1` runs them | off |
|
|
21
|
+
| `SAMAGOTCHI_INTEGRATION_HOST` | model server host | `localhost` |
|
|
22
|
+
| `SAMAGOTCHI_INTEGRATION_PORT` | model server port | `8080` |
|
|
23
|
+
| `SAMAGOTCHI_INTEGRATION_MODEL` | the served model id | none: the examples skip |
|
|
24
|
+
| `SAMAGOTCHI_INTEGRATION_API` | `openai` for the chat loop (`hosts.<name>.api`) | none: the native loop |
|
|
25
|
+
| `SAMAGOTCHI_INTEGRATION_TRANSPORT` | `llama_cpp`, `mlx` or `omlx` (`server.transport`) | none: `llama_cpp` |
|
|
26
|
+
|
|
27
|
+
They run under a config built from these variables alone (`default.model`, `server.host`/`port`, and one
|
|
28
|
+
`hosts.integration` entry; `spec/support/integration_server.rb`), never your `~/.config/samagotchi/config.yml`: no
|
|
29
|
+
aliases, memories, hooks, guardrails or other hosts of yours. Sessions and logs go to the suite's temp state folder.
|
|
30
|
+
|
|
31
|
+
Run them one at a time (plain `rspec`, not `parallel_rspec`): each example is a real model turn, and the server may be
|
|
32
|
+
shared.
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
SAMAGOTCHI_INTEGRATION=1 \
|
|
36
|
+
SAMAGOTCHI_INTEGRATION_HOST=192.0.2.10 SAMAGOTCHI_INTEGRATION_PORT=8081 \
|
|
37
|
+
SAMAGOTCHI_INTEGRATION_MODEL=org/Model-GGUF:Q4_K_M \
|
|
38
|
+
bundle exec rspec --tag integration spec/integration spec/hooks/integration_spec.rb spec/integration_config_spec.rb
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A run takes one to four minutes against a 35B MoE model on llama.cpp (25 examples). `ECONNREFUSED` means the server is down. The
|
|
42
|
+
examples assert what the model did (the tool it called, the file it edited), not its wording, but they still depend on
|
|
43
|
+
a model that follows tool instructions: a small model may fail some of them.
|
|
44
|
+
|
|
45
|
+
`spec/integration/omlx_spec.rb` tests oMLX's model-id resolution and runs only with
|
|
46
|
+
`SAMAGOTCHI_INTEGRATION_TRANSPORT=omlx`, against an oMLX server (a short model selector in
|
|
47
|
+
`SAMAGOTCHI_INTEGRATION_MODEL`).
|
|
@@ -6,6 +6,7 @@ require_relative "bridge_client"
|
|
|
6
6
|
require_relative "reply_wait"
|
|
7
7
|
require_relative "parent_report"
|
|
8
8
|
require_relative "guardrails/parent_approvals"
|
|
9
|
+
require_relative "guardrails/parent_continue"
|
|
9
10
|
require_relative "cli/command"
|
|
10
11
|
require_relative "cli/flags"
|
|
11
12
|
require_relative "cli/parent_wait"
|
|
@@ -46,6 +47,9 @@ module Samagotchi
|
|
|
46
47
|
An approval (a guardrail's ask) can be denied here: deny it, and
|
|
47
48
|
tell your user; allowing it is the user's. With
|
|
48
49
|
guardrails.parent_approvals: once, "Allow once" is let through.
|
|
50
|
+
A step-limit question (kind continue): --option Continue, or
|
|
51
|
+
--option Stop [--text WHY] (exit 0, status not_continued); it
|
|
52
|
+
can't be dismissed.
|
|
49
53
|
Exit: 0 answered, 1 failed, refused or the worker is gone, 2
|
|
50
54
|
usage or an option the question doesn't offer, 3 a question waits,
|
|
51
55
|
4 still running (--timeout), 130 Ctrl-C.
|
|
@@ -105,8 +109,10 @@ module Samagotchi
|
|
|
105
109
|
baseline = ReplyWait.baseline_of(session, question_id: qid)
|
|
106
110
|
|
|
107
111
|
if pending && pending[:id].to_s == qid
|
|
112
|
+
return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
|
|
113
|
+
|
|
108
114
|
selected = selection(pending, options) or return @selection_status
|
|
109
|
-
refusal = approval_refusal(pending, selected, id)
|
|
115
|
+
refusal = approval_refusal(pending, selected, id) || continue_refusal(pending, selected)
|
|
110
116
|
return failure(refusal) if refusal
|
|
111
117
|
|
|
112
118
|
posted = post(id, qid, selected, options)
|
|
@@ -154,6 +160,18 @@ module Samagotchi
|
|
|
154
160
|
reason && Guardrails::ParentApprovals.message(reason)
|
|
155
161
|
end
|
|
156
162
|
|
|
163
|
+
# Why a parent may not answer Continue (turn.parent_continue: false in
|
|
164
|
+
# this process's config.yml), or nil; the worker checks it again.
|
|
165
|
+
def continue_refusal(pending, selected)
|
|
166
|
+
Guardrails::ParentContinue.refusal(pending, selected) && Guardrails::ParentContinue.message
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# A step-limit question can't be left unanswered: the turn waits on it.
|
|
170
|
+
def not_dismissable(pending)
|
|
171
|
+
"the step-limit question can't be dismissed: answer #{Array(pending[:options]).join(' or ')} " \
|
|
172
|
+
"(--option Stop --text WHY stops it)"
|
|
173
|
+
end
|
|
174
|
+
|
|
157
175
|
# Post the answer (or the dismissal) to the live worker's Bridge.
|
|
158
176
|
# @return [Integer, nil] an exit status when it didn't go in; nil to
|
|
159
177
|
# wait (it went in, or the question was no longer open: 409)
|
|
@@ -174,7 +192,10 @@ module Samagotchi
|
|
|
174
192
|
|
|
175
193
|
case reply.status
|
|
176
194
|
when 200 then nil
|
|
177
|
-
when 409
|
|
195
|
+
when 409
|
|
196
|
+
return failure(reply.json&.dig("detail").to_s) if reply.json&.dig("error") == "not_dismissable"
|
|
197
|
+
|
|
198
|
+
not_open(qid)
|
|
178
199
|
when 400 then usage_failure(reply.json&.dig("detail") || "the worker refused the answer")
|
|
179
200
|
# The worker's own guardrails.parent_approvals refused the allow.
|
|
180
201
|
when 403 then failure(reply.json&.dig("detail") || "the worker refused the allow: deny it, and tell your user")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "context_note"
|
|
4
|
+
require_relative "output_formatter"
|
|
5
|
+
require_relative "steer"
|
|
6
|
+
require_relative "turn_note"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# "The last answer a UI shows": the newest message the web's history
|
|
10
|
+
# shows as an assistant message. One rule for the worker's Bridge (`/tail`,
|
|
11
|
+
# Engine#last_answer_message) and the web's disk path, so the two can't
|
|
12
|
+
# drift. Any change to what the web shows as an answer goes through here.
|
|
13
|
+
module AnswerTail
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param messages [Array<Hash>] a session's messages (symbol keys from
|
|
17
|
+
# disk or the Engine, string keys from a Bridge snapshot)
|
|
18
|
+
# @param turn_id [String, nil] that turn's answer: the last one between
|
|
19
|
+
# the prompt carrying this turn id and the next prompt (a queued turn's
|
|
20
|
+
# page re-reads after the next turn ran). An unknown or missing id:
|
|
21
|
+
# the newest answer.
|
|
22
|
+
# @return [Hash, nil] the raw message (not a copy), or nil
|
|
23
|
+
def find(messages, turn_id: nil)
|
|
24
|
+
list = Array(messages)
|
|
25
|
+
list = turn_slice(list, turn_id.to_s) || list unless turn_id.to_s.empty?
|
|
26
|
+
list.reverse_each { |m| return m if answer?(m) }
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The messages of the turn whose prompt carries +turn_id+, from its
|
|
31
|
+
# prompt up to the next prompt (merged input and steers stay in it), or
|
|
32
|
+
# nil when no prompt carries it.
|
|
33
|
+
def turn_slice(list, turn_id)
|
|
34
|
+
start = list.rindex { |m| Steer.turn_prompt?(m) && (m[:turn_id] || m["turn_id"]).to_s == turn_id }
|
|
35
|
+
return nil unless start
|
|
36
|
+
|
|
37
|
+
stop = list.each_index.find { |i| i > start && Steer.turn_prompt?(list[i]) } || list.size
|
|
38
|
+
list[start...stop]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Shown as an assistant message: role assistant or model, not a context
|
|
42
|
+
# note, a steer or a no-answer turn's marker, and some text left once the
|
|
43
|
+
# wire-format markup is stripped (a tool-call-only step has none).
|
|
44
|
+
def answer?(message)
|
|
45
|
+
return false unless message.is_a?(Hash)
|
|
46
|
+
return false if ContextNote.note?(message) || Steer.steer?(message) || TurnNote.empty_answer(message)
|
|
47
|
+
return false unless %w[assistant model].include?((message[:role] || message["role"]).to_s)
|
|
48
|
+
|
|
49
|
+
!OutputFormatter.strip_markup((message[:content] || message["content"]).to_s).empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -41,11 +41,23 @@ module Samagotchi
|
|
|
41
41
|
# continue, ordered parts (thinking / text / tool / input / steer /
|
|
42
42
|
# reminder / notice), pending_question and the last event_seq folded
|
|
43
43
|
# in. A finished tool part keeps the live row's action and duration_ms
|
|
44
|
-
# too
|
|
45
|
-
#
|
|
46
|
-
#
|
|
44
|
+
# too, a running one the time it has run so far (elapsed_ms), so a
|
|
45
|
+
# join times its row from its start. A notice part holds the event
|
|
46
|
+
# of one of the turn's rows (TurnNotice: a hook's notice, an
|
|
47
|
+
# empty-answer retry, a question and its answer), which a UI replays
|
|
48
|
+
# through its live handler.
|
|
47
49
|
def current_turn
|
|
48
|
-
@mutex.synchronize
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
next nil unless @turn
|
|
52
|
+
|
|
53
|
+
turn = Marshal.load(Marshal.dump(@turn))
|
|
54
|
+
now = @clock.call
|
|
55
|
+
Array(turn[:parts]).each do |part|
|
|
56
|
+
started_at = part[:kind] == "tool" && part[:status] == "running" && @tool_started_at[[part[:iteration], part[:call_index]]]
|
|
57
|
+
part[:elapsed_ms] = ((now - started_at) * 1000).round if started_at
|
|
58
|
+
end
|
|
59
|
+
turn
|
|
60
|
+
end
|
|
49
61
|
end
|
|
50
62
|
|
|
51
63
|
# The turn in progress as conversation messages, for a plugin's
|
|
@@ -142,7 +154,10 @@ module Samagotchi
|
|
|
142
154
|
call[:label] = part[:label] if part[:label]
|
|
143
155
|
title = part[:title] ? { title: part[:title] } : {}
|
|
144
156
|
events = [{ type: :tool_call_started, **call, params: part[:params], **title }]
|
|
145
|
-
|
|
157
|
+
if part[:status] == "running"
|
|
158
|
+
events.first[:elapsed_ms] = part[:elapsed_ms] unless part[:elapsed_ms].nil?
|
|
159
|
+
return events
|
|
160
|
+
end
|
|
146
161
|
|
|
147
162
|
activity = { tool: part[:tool], status: part[:status], params: part[:params], **title }
|
|
148
163
|
activity[:action] = part[:action] if part[:action]
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -209,8 +209,11 @@ module Samagotchi
|
|
|
209
209
|
# last cards and between-turns notices (CardStore#list), the session's commands (Commands::Registry#listing:
|
|
210
210
|
# what an attached TUI routes and completes, the web's autocomplete), and the event_seq it all covers.
|
|
211
211
|
# Taken with the log held, so no event is half-applied.
|
|
212
|
-
#
|
|
213
|
-
#
|
|
212
|
+
# The pending question at the top level too: one asked between turns
|
|
213
|
+
# (the step-limit question) belongs to no turn, so the accumulator
|
|
214
|
+
# doesn't keep it.
|
|
215
|
+
# @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:}
|
|
214
217
|
def snapshot
|
|
215
218
|
@engine.synchronize_events do
|
|
216
219
|
seq = @engine.event_count
|
|
@@ -221,6 +224,7 @@ module Samagotchi
|
|
|
221
224
|
recap: @accumulator.recap,
|
|
222
225
|
saved_recap: @engine.saved_recap,
|
|
223
226
|
continue_offer: @accumulator.continue_offer,
|
|
227
|
+
pending_question: @engine.pending_question,
|
|
224
228
|
guardrail_warning: @engine.guardrail_warning,
|
|
225
229
|
plugin_warning: @engine.plugin_warning,
|
|
226
230
|
init_tasks: @engine.init_tasks,
|
|
@@ -244,6 +248,34 @@ module Samagotchi
|
|
|
244
248
|
end
|
|
245
249
|
end
|
|
246
250
|
|
|
251
|
+
# What the page re-reads at a turn's end and when a card arrives: the
|
|
252
|
+
# session state, the last answer (Engine#last_answer_message: one
|
|
253
|
+
# message, not the conversation) and the cards, as one step of the event
|
|
254
|
+
# log (one event_seq). Light whatever the session's length: GET tail.
|
|
255
|
+
# @return [Hash] {session_id:, session_state_snapshot:, answer:, cards:, event_seq:, event_id:}
|
|
256
|
+
# +turn_id+: that turn's answer, not the newest (Engine#last_answer_message).
|
|
257
|
+
def tail_frame(turn_id: nil)
|
|
258
|
+
@engine.synchronize_events do
|
|
259
|
+
seq = @engine.event_count
|
|
260
|
+
state = @engine.session_state_snapshot.merge(event_seq: seq, event_id: event_id(seq))
|
|
261
|
+
{ session_id: @session_id, session_state_snapshot: state, answer: @engine.last_answer_message(turn_id: turn_id),
|
|
262
|
+
cards: @cards.list, event_seq: seq, event_id: event_id(seq) }
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The worker's own way in for a session command that came as a message
|
|
267
|
+
# (its first prompt, an input file): queued and announced as a POST
|
|
268
|
+
# /command's is (#handle_command).
|
|
269
|
+
# @return [String] its command_id
|
|
270
|
+
def queue_command(line, client_id: nil, card: false)
|
|
271
|
+
command = { command_id: SecureRandom.uuid, client_id: client_id, line: line.to_s.strip }
|
|
272
|
+
# A card's action (the step-limit question's answer): the UIs leave
|
|
273
|
+
# its line out.
|
|
274
|
+
command[:card] = true if card
|
|
275
|
+
@engine.synchronize_events { queue_command_locked(command) }
|
|
276
|
+
command[:command_id]
|
|
277
|
+
end
|
|
278
|
+
|
|
247
279
|
private
|
|
248
280
|
|
|
249
281
|
# The Engine's commands: the built-ins and its plugins'.
|
|
@@ -337,7 +369,7 @@ module Samagotchi
|
|
|
337
369
|
client_id: stream_client_id(request[:query]))
|
|
338
370
|
break # SSE owns the connection until the client disconnects.
|
|
339
371
|
else
|
|
340
|
-
payload, status, body = dispatch(route.first, route.last, request[:body])
|
|
372
|
+
payload, status, body = dispatch(route.first, route.last, request[:body], request[:query])
|
|
341
373
|
write_json(io, status, payload, body)
|
|
342
374
|
end
|
|
343
375
|
|
|
@@ -436,7 +468,8 @@ module Samagotchi
|
|
|
436
468
|
%w[POST relay/status] => :handle_relay_status,
|
|
437
469
|
%w[GET state] => :handle_state,
|
|
438
470
|
%w[GET stats] => :handle_stats,
|
|
439
|
-
%w[GET snapshot] => :handle_snapshot
|
|
471
|
+
%w[GET snapshot] => :handle_snapshot,
|
|
472
|
+
%w[GET tail] => :handle_tail
|
|
440
473
|
}.freeze
|
|
441
474
|
ROUTE_PATH = %r|\A/session/([^/]+)/(.+)\z|u
|
|
442
475
|
|
|
@@ -452,8 +485,10 @@ module Samagotchi
|
|
|
452
485
|
# #own_session?), and a handler that raises answers 500 bridge_error,
|
|
453
486
|
# logged (the connection thread doesn't report).
|
|
454
487
|
# @return [Array(Hash, Integer, Hash)] headers, status, body
|
|
455
|
-
|
|
488
|
+
# Only GET tail takes the request's +query+.
|
|
489
|
+
def dispatch(handler, session_id, body, query = nil)
|
|
456
490
|
return [{}, 404, { error: "unknown_session" }] unless own_session?(session_id)
|
|
491
|
+
return handle_tail(session_id, body, query) if handler == :handle_tail
|
|
457
492
|
|
|
458
493
|
send(handler, session_id, body)
|
|
459
494
|
rescue StandardError => e
|
|
@@ -523,7 +558,7 @@ module Samagotchi
|
|
|
523
558
|
[{}, 409, { error: "question_not_pending", detail: e.message }]
|
|
524
559
|
rescue QuestionDesk::Refused => e
|
|
525
560
|
Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
|
|
526
|
-
[{}, 403, { error: "parent_approval_refused",
|
|
561
|
+
[{}, 403, { error: e.reason == :stop_only ? "parent_continue_refused" : "parent_approval_refused",
|
|
527
562
|
detail: Guardrails::ParentApprovals.message(e.reason) }]
|
|
528
563
|
rescue ArgumentError => e
|
|
529
564
|
[{}, 400, { error: "invalid_answer", detail: e.message }]
|
|
@@ -579,7 +614,12 @@ module Samagotchi
|
|
|
579
614
|
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
580
615
|
return deadline_passed("dismissal") if expired?("dismiss_expired", deadline, sid: session_id, id: qid)
|
|
581
616
|
|
|
582
|
-
|
|
617
|
+
begin
|
|
618
|
+
dismissed = @engine.cancel_question("dismissed", id: qid)
|
|
619
|
+
rescue QuestionDesk::NotDismissable => e
|
|
620
|
+
# A step-limit question: its answer is Continue or Stop.
|
|
621
|
+
return [{}, 409, { error: "not_dismissable", detail: e.message }]
|
|
622
|
+
end
|
|
583
623
|
return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless dismissed
|
|
584
624
|
|
|
585
625
|
[{}, 200, { status: "dismissed", id: qid, session_id: @session_id }]
|
|
@@ -612,17 +652,21 @@ module Samagotchi
|
|
|
612
652
|
deadline = fetched(parsed, "deadline")
|
|
613
653
|
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
614
654
|
|
|
615
|
-
|
|
655
|
+
queue_command_request(session_id, line, client_id: fetched(parsed, "client_id"), deadline: deadline,
|
|
656
|
+
card: fetched(parsed, "card") == true)
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
# Queues +line+ (a session command) for the worker and announces its
|
|
660
|
+
# :command_queued, unless +deadline+ has passed. Returns [headers,
|
|
661
|
+
# status, body]: 202 with its command_id, or 408.
|
|
662
|
+
def queue_command_request(session_id, line, client_id:, deadline: nil, card: false)
|
|
663
|
+
command = { command_id: SecureRandom.uuid, client_id: client_id, line: line }
|
|
616
664
|
# A card's action: the UIs leave its line out (the card is the echo).
|
|
617
|
-
command[:card] = true if
|
|
665
|
+
command[:card] = true if card
|
|
618
666
|
queued = @engine.synchronize_events do
|
|
619
667
|
next false if expired?("command_expired", deadline, sid: session_id, client_id: command[:client_id])
|
|
620
668
|
|
|
621
|
-
|
|
622
|
-
# An anytime command runs now, beside a turn (D8): the UIs show its
|
|
623
|
-
# line here, so the cards it shows come after it.
|
|
624
|
-
anytime = command_registry.lookup(line)&.anytime ? { anytime: true } : {}
|
|
625
|
-
@engine.announce(type: :command_queued, **command, **anytime)
|
|
669
|
+
queue_command_locked(command)
|
|
626
670
|
true
|
|
627
671
|
end
|
|
628
672
|
return deadline_passed("command") unless queued
|
|
@@ -630,6 +674,15 @@ module Samagotchi
|
|
|
630
674
|
[{}, 202, { status: "accepted", command_id: command[:command_id], session_id: @session_id }]
|
|
631
675
|
end
|
|
632
676
|
|
|
677
|
+
# With the event log held.
|
|
678
|
+
def queue_command_locked(command)
|
|
679
|
+
@on_command.call(command)
|
|
680
|
+
# An anytime command runs now, beside a turn (D8): the UIs show its
|
|
681
|
+
# line here, so the cards it shows come after it.
|
|
682
|
+
anytime = command_registry.lookup(command[:line])&.anytime ? { anytime: true } : {}
|
|
683
|
+
@engine.announce(type: :command_queued, **command, **anytime)
|
|
684
|
+
end
|
|
685
|
+
|
|
633
686
|
# A client asks the worker to exit now (`/exit` in the attached TUI). The
|
|
634
687
|
# worker decides with the event log held, so no POST /turn lands between
|
|
635
688
|
# its check and its answer; it leaves from its loop after this reply.
|
|
@@ -688,6 +741,14 @@ module Samagotchi
|
|
|
688
741
|
deadline = fetched(parsed, "deadline")
|
|
689
742
|
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
690
743
|
|
|
744
|
+
# A session command sent as a message (chi send -m "/model x", chi -p,
|
|
745
|
+
# a UI whose command list lags) runs as the command, as typed in a
|
|
746
|
+
# TUI; an unknown /word stays a prompt. Not with images: those are
|
|
747
|
+
# for the model.
|
|
748
|
+
if images.empty? && @on_command && command_registry.command?(prompt.to_s)
|
|
749
|
+
return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline)
|
|
750
|
+
end
|
|
751
|
+
|
|
691
752
|
enqueued_id = SecureRandom.uuid
|
|
692
753
|
# Write and announce with the event log held: the worker can't emit
|
|
693
754
|
# this turn's :turn_started (or merge it mid-turn) before
|
|
@@ -733,6 +794,17 @@ module Samagotchi
|
|
|
733
794
|
[{}, 200, snapshot_frame]
|
|
734
795
|
end
|
|
735
796
|
|
|
797
|
+
# The page's light re-read (#tail_frame): no message list. `?turn_id=`:
|
|
798
|
+
# that turn's answer (a queued turn's page re-reads late). The one
|
|
799
|
+
# handler that gets the query (#dispatch); an older worker's route
|
|
800
|
+
# ignores it and sends the newest answer.
|
|
801
|
+
def handle_tail(session_id, _body = nil, query = nil)
|
|
802
|
+
turn_id = query && URI.decode_www_form(query).to_h["turn_id"].to_s
|
|
803
|
+
[{}, 200, tail_frame(turn_id: turn_id.to_s.empty? ? nil : turn_id)]
|
|
804
|
+
rescue ArgumentError
|
|
805
|
+
[{}, 200, tail_frame]
|
|
806
|
+
end
|
|
807
|
+
|
|
736
808
|
# The stream cursor for +seq+ in this worker.
|
|
737
809
|
def event_id(seq)
|
|
738
810
|
EventId.format(seq.to_i, @epoch)
|
|
@@ -210,17 +210,32 @@ module Samagotchi
|
|
|
210
210
|
# GET /session/:id/<path> as JSON.
|
|
211
211
|
# @return [Hash, nil] the parsed body, or nil unless the Bridge answered 200
|
|
212
212
|
def get_json(path)
|
|
213
|
+
status, body = get(path)
|
|
214
|
+
status == 200 ? body : nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# GET /session/:id/<path>, telling a refusal apart from no answer: an
|
|
218
|
+
# older worker's 404 for a route it lacks from a refused connect, a
|
|
219
|
+
# timeout or a 500.
|
|
220
|
+
# @return [Array(Integer, Object)] the status and the parsed JSON body
|
|
221
|
+
# (nil when it isn't JSON); [nil, nil] when no reply came
|
|
222
|
+
def get(path)
|
|
213
223
|
sock = TCPSocket.new(@host, @port)
|
|
214
224
|
sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nConnection: close\r\n\r\n")
|
|
215
225
|
response = read_reply(sock, path)
|
|
216
226
|
sock.close rescue nil
|
|
217
227
|
# A 500 whose text has "200" in it is not a 200.
|
|
218
|
-
|
|
228
|
+
status = response && response[STATUS_LINE, 1]
|
|
229
|
+
return [nil, nil] unless status
|
|
219
230
|
|
|
220
231
|
body = response.split("\r\n\r\n", 2)[1] || ""
|
|
221
|
-
|
|
232
|
+
[status.to_i, begin
|
|
233
|
+
JSON.parse(body)
|
|
234
|
+
rescue JSON::ParserError
|
|
235
|
+
nil
|
|
236
|
+
end]
|
|
222
237
|
rescue StandardError
|
|
223
|
-
nil
|
|
238
|
+
[nil, nil]
|
|
224
239
|
end
|
|
225
240
|
|
|
226
241
|
# Monotonic SSE cursor of the live Engine, from GET /session/:id/state.
|