samagotchi 0.63.0 → 0.65.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 +86 -1
- data/docs/architecture.md +32 -5
- data/docs/cli.md +41 -9
- data/docs/configuration.md +143 -11
- data/docs/internals/engine.md +25 -6
- data/docs/plugins.md +7 -0
- data/docs/sessions.md +14 -10
- data/docs/sub-agent.md +21 -4
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +29 -5
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +11 -7
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +41 -7
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +33 -12
- data/lib/samagotchi/host_model.rb +2 -2
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +72 -57
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +11 -4
- data/lib/samagotchi/turn_checkpoint.rb +9 -3
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +96 -13
- data/lib/samagotchi/web/public/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +29 -0
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/timing.js +2 -1
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +29 -12
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/worker.rb +7 -5
- metadata +9 -1
data/docs/plugins.md
CHANGED
|
@@ -675,6 +675,7 @@ llama.cpp serves on a native host too.
|
|
|
675
675
|
id = ctx.sessions.fork(messages: ctx.messages + [{ role: "user", content: q }, { role: "model", content: a }],
|
|
676
676
|
title: "btw: #{q}")
|
|
677
677
|
ctx.sessions.send(id, "go on from here")
|
|
678
|
+
ctx.sessions.send(id, "then the docs", delivery: :queue) # after its running turn
|
|
678
679
|
ctx.sessions.read(id) # => {id:, title:, status:, parent_id:, running:, messages:}
|
|
679
680
|
ctx.sessions.children # => [{id:, short_id:, state:, branch:, last_reply:, reported:, ...}]
|
|
680
681
|
ctx.sessions.stop(id) # one of this session's own children
|
|
@@ -695,6 +696,12 @@ ctx.sessions.stop(id) # one of this session's own children
|
|
|
695
696
|
5 s for the session's worker, so call it from an anytime command or a
|
|
696
697
|
thread of your own, never from a tool or hook of a running turn. A
|
|
697
698
|
session open in a chi REPL can't take it.
|
|
699
|
+
- A running turn takes it at its next step. `delivery: :queue` runs it as
|
|
700
|
+
a turn of its own after that turn instead (as `/queue TEXT` does; to an
|
|
701
|
+
idle session it simply runs). A plugin never cuts the running turn's
|
|
702
|
+
thinking: `delivery: :cut` raises `Samagotchi::Plugin::Sessions::Error`,
|
|
703
|
+
as does an unknown value. A worker older than queueing refuses a queue
|
|
704
|
+
(it would steer): `Error` with its reason; nothing is sent.
|
|
698
705
|
- `read(id)` gives a session now: from its worker when one runs (with a
|
|
699
706
|
running turn so far, `running: true`), else as saved. `messages` has no
|
|
700
707
|
system prompt.
|
data/docs/sessions.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`owner.lock`/`bridge.json` (`Session.session_dir`). Besides the messages and turn state, the JSON keeps `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, `prompt_notes`, the model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)), `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise), and `continues`, the session this one continues (the previous link of a chain, see *Session chains*; `null` otherwise); files from before those keys read as empty lists, no parent and no previous link. `model_name` is the resolved model ref (an alias applied, `host:id` when it names a host), so a resumed session keeps its model when an alias is retargeted; `model_typed` is the name it was given as when that differs (an alias), for the `models:` lookup after a resume (`null` otherwise).
|
|
4
4
|
|
|
5
|
+
**Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
|
|
6
|
+
|
|
5
7
|
**Retention (file-based, opt-out via env):**
|
|
6
8
|
|
|
7
9
|
| Env | Default | Purpose |
|
|
@@ -23,9 +25,9 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
23
25
|
|
|
24
26
|
**A worker outlives whoever started it:** a spawned worker leaves its spawner's process tree (it is forked again, in a session of its own, and reparented to init), so closing the terminal of a `chi web`, or a tool tearing down a background `chi send --wait` and its children, no longer takes the worker and its turn with it.
|
|
25
27
|
|
|
26
|
-
**A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration}
|
|
28
|
+
**A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once). The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
|
|
27
29
|
|
|
28
|
-
**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`. A running turn is canceled over the Bridge first (
|
|
30
|
+
**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`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
|
|
29
31
|
|
|
30
32
|
**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).
|
|
31
33
|
|
|
@@ -74,7 +76,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
74
76
|
- `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.
|
|
75
77
|
- Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
76
78
|
- 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).
|
|
77
|
-
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue), `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), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
|
|
79
|
+
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue), `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), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `delivery_line.js` (the composer's row above the text field in a session, always there so the composer keeps its height: the key hint while idle, and the delivery line while a turn runs: where a message typed now goes in, from the step's phase, clock and size, whether "cut in now" can land, and the queue link for a worker with the `delivery` feature, as a pure `deliveryLine`, its lit-html template and `createDeliveryControl`, which ticks it and keeps a choice armed from a link; see docs/cli.md, "Typing during a turn"), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
|
|
78
80
|
- 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).
|
|
79
81
|
|
|
80
82
|
**Test-session hygiene:**
|
|
@@ -133,7 +135,8 @@ system message marked `kind: turn_note` (the UIs don't show it; they showed the
|
|
|
133
135
|
- cancelled (Ctrl-C, the web's stop): `[SYSTEM: the previous turn was cancelled (ctrl-c) after 12s; the reply
|
|
134
136
|
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;
|
|
135
137
|
- ended with nothing visible: `[SYSTEM: the previous turn ended with no visible answer (thinking only, or
|
|
136
|
-
nothing). The user's last message is still unanswered.]
|
|
138
|
+
nothing). The user's last message is still unanswered.]`, or `[SYSTEM: the previous turn's answer was refused by
|
|
139
|
+
the provider (cyber). …]` when the provider refused it.
|
|
137
140
|
|
|
138
141
|
When the context window fills past 40% (then 60%, 80%), the native loop leaves a similar line (`kind: context`):
|
|
139
142
|
`[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted
|
|
@@ -153,21 +156,22 @@ cancel during its thinking, it said it had never been asked.
|
|
|
153
156
|
`chi send` is the other half of `chi note`: the text goes in as your message, the same as typing it in the attached terminal or the web composer, so a turn runs.
|
|
154
157
|
|
|
155
158
|
```sh
|
|
156
|
-
chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
|
|
159
|
+
chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
|
|
157
160
|
chi send -m "is this the same bug?" 3fa2 # a message
|
|
158
161
|
chi send --cut -m "stop, wrong file" 3fa2 # cut the running turn's long thinking
|
|
162
|
+
chi send --queue -m "then update the docs" 3fa2 # a turn of its own after the running one
|
|
159
163
|
chi send --image shot.png -m "why is this red?" 3fa2 # with an image
|
|
160
164
|
pbpaste | chi send -m "is this the same bug?" 3fa2 # the clipboard quoted above the message
|
|
161
165
|
pbpaste | chi send 3fa2 # the clipboard is the message
|
|
162
166
|
```
|
|
163
167
|
|
|
164
168
|
- With both stdin and `-m`, stdin is context: each line becomes a `> ` quote (as web annotations quote a selection), then a blank line, then the message. With only one, it goes in as is. A terminal on stdin is ignored. Both empty is a usage error; over 16 KiB is refused, never cut.
|
|
165
|
-
- It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then, and cuts nothing. `--cut` asks for a cut, as `/cut TEXT` in a terminal does: a generation that has streamed only thinking for `steer.cut_after` seconds (per host/model: `steer_cut_after`) is cut (now, or once its thinking passes that), and the step starts again with the message. To an idle session `--cut` sends normally. `--cut --wait` into a running turn is refused like any `--wait` there (`busy: a turn is running`).
|
|
169
|
+
- It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then, and cuts nothing. `--cut` asks for a cut, as `/cut TEXT` in a terminal does: a generation that has streamed only thinking for `steer.cut_after` seconds (per host/model: `steer_cut_after`) is cut (now, or once its thinking passes that), and the step starts again with the message. To an idle session `--cut` sends normally. `--cut --wait` into a running turn is refused like any `--wait` there (`busy: a turn is running`). `--queue` (not with `--cut`, a usage error) keeps the message for after the running turn: it runs as a turn of its own then (as `/queue TEXT` does), and a plain message sent after it still goes in at the running turn's next step. To an idle session it runs at once (it still goes as a queue, so a turn another client starts first never takes it as a steer). A worker older than queueing would steer it instead, so it is refused (to an idle session too) (`refused: this session's worker predates queueing, …; restart it`, exit 1) and nothing is sent; `chi sessions restart ID` and send again.
|
|
166
170
|
- 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.
|
|
167
171
|
- 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`).
|
|
168
172
|
- `--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.
|
|
169
173
|
- 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. A UI-only command (`/stats`, `/exit`, `/recap`, …) is refused instead: the worker doesn't run it, so the line would reach the model as a prompt (`<id> refused: /stats is a terminal and web command; use chi sessions stats <id8>`; exit 1, and nothing is sent). 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.
|
|
170
|
-
- One line per session: `sent`, `sent as a session command`, `sent with 2 images`, `sent (goes in at the running turn's next step)`, with `--cut` `sent (cut in now)`, `sent (cuts in once the thinking passes 20 s)` or `sent (cutting is off; goes in at the next step)`, `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`.
|
|
174
|
+
- One line per session: `sent`, `sent as a session command`, `sent with 2 images`, `sent (goes in at the running turn's next step)`, with `--cut` `sent (cut in now)`, `sent (cuts in once the thinking passes 20 s)` or `sent (cutting is off; goes in at the next step)`, with `--queue` `sent (queued: runs after the current turn)`, `sent (the running turn had ended: it runs now)` or `sent (nothing running: it runs now)`, `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`.
|
|
171
175
|
|
|
172
176
|
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)`.
|
|
173
177
|
|
|
@@ -190,10 +194,10 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
190
194
|
(`SAMAGOTCHI_THINKING_LEVEL=low chi send --new …`) only reaches the worker this call spawns: when the session's
|
|
191
195
|
worker is woken later (by `chi web`, a delivery) without that env, the level is gone. Use `--thinking`.
|
|
192
196
|
- `--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.
|
|
193
|
-
- `--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
|
|
197
|
+
- `--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 merge into it, and its reply would come back as the answer. `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
|
|
194
198
|
- 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.
|
|
195
199
|
- `--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); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
|
|
196
|
-
- `--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).
|
|
200
|
+
- `--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. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs. With neither (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. 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).
|
|
197
201
|
- `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model, LLM context and thinking level, so `--dir`, `--model`, `--llm-context*` and `--thinking` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1.
|
|
198
202
|
- 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.
|
|
199
203
|
- A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
|
|
@@ -220,7 +224,7 @@ A session can **continue** an earlier one: "the next day of the same routine" (a
|
|
|
220
224
|
|
|
221
225
|
The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail like an `execute` result. Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (folded into its parent's card on the strip and in all sessions, whose `▸ N delegates` chip lists the delegates at any depth, see Web UI above; a card of its own with a `↳` chip when its parent isn't listed; `delegated by` in its info bar; the parent's info bar carries a `⑂ N` chip counting its delegates, with how many run, wait, are done, failed, stopped or idle in the tooltip and the waiting ones named in the attention colour; a click opens all sessions), the terminal's status row (a parent's `⑂ 2 running · 1 waiting`, see [Status Line](cli.md#status-line)) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
|
|
222
226
|
|
|
223
|
-
- `delegate(task, model:, session:, cwd:, wait:, timeout:)`: starts a child and, by default, waits for its reply. `wait: false` is the usual way for work that takes a while, several children side by side, or to keep talking with the user: it returns at once, the parent ends its turn or works on, and the reply comes back by itself as a *delegate report* (below), in a turn of its own if the parent is idle. The tool descriptions tell the model so, and to wait (`wait: true`, `delegate_result`) only when its turn can't go on without the reply. `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.
|
|
227
|
+
- `delegate(task, model:, session:, cwd:, wait:, timeout:)`: starts a child and, by default, waits for its reply. `wait: false` is the usual way for work that takes a while, several children side by side, or to keep talking with the user: it returns at once, the parent ends its turn or works on, and the reply comes back by itself as a *delegate report* (below), in a turn of its own if the parent is idle. The tool descriptions tell the model so, and to wait (`wait: true`, `delegate_result`) only when its turn can't go on without the reply. `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. The wait (`wait: true`, a later `delegate_result`) and the delegate report are for the reply of the child's turn the follow-up went to: its running turn, which it merges into, or the turn of its own it runs when the child is idle (`turn` in `delegates.json`, set when the child's worker names `turn_ids`), not a newer reply of a turn queued behind it. A follow-up merged as a steer into a turn the child started at that moment (a wake, a reminder, another message) gets that turn's reply or question. A child on its first turn, or with an older worker, gives its next reply, as before.
|
|
224
228
|
- `cwd:` starts the child in another folder of the parent's repository: a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides). Outside git only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's repository …` or `… is not in a work tree …`. A follow-up (`session:`) refuses `cwd:`: the child keeps its folder. The child's guardrails measure from its own worktree, and project memories, `list_sessions` and the web's project are the same as the parent's.
|
|
225
229
|
- `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. After `wait: false` it isn't needed: the report brings the reply.
|
|
226
230
|
- A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, a `--no-shared` REPL, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
|
data/docs/sub-agent.md
CHANGED
|
@@ -24,12 +24,17 @@ and Claude's answer comes back as attached context.
|
|
|
24
24
|
```sh
|
|
25
25
|
chi send --new --wait --format json [--dir D] [--model M] [--timeout S] -m "task"
|
|
26
26
|
chi send --wait --format json [--timeout S] [-m "follow-up"] ID
|
|
27
|
+
chi send --queue --wait --format json [--timeout S] -m "follow-up" ID
|
|
27
28
|
chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]
|
|
28
29
|
chi answer ID --question QID --dismiss [--timeout S] [--format json]
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
`chi answer` waits after answering, the same way `chi send --wait` does, so the
|
|
32
|
-
next question comes back as exit 3 again.
|
|
33
|
+
next question comes back as exit 3 again. What it waits for is the answered
|
|
34
|
+
turn: the turn that asked (or, for the step-limit question, the turn it
|
|
35
|
+
continues), so its reply comes back, not that of a turn queued behind it.
|
|
36
|
+
A worker older than this (its `bridge.json` doesn't name `turn_ids`) gets
|
|
37
|
+
the session's next reply instead. `--option` is 1-based or the label,
|
|
33
38
|
repeated on a multi-select question. `--text` is free text (where the question
|
|
34
39
|
allows it) or the reason for a Deny. `--dismiss` leaves the question
|
|
35
40
|
unanswered: the model doesn't do what it asked about and finishes its reply.
|
|
@@ -43,7 +48,7 @@ Exit codes, both commands:
|
|
|
43
48
|
| 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
|
|
44
49
|
| 3 | `question` | chi waits for an answer: `question`, `answer_with` |
|
|
45
50
|
| 4 | `running` | `--timeout` passed; the turn goes on, wait again |
|
|
46
|
-
| 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). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web
|
|
51
|
+
| 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). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
|
|
47
52
|
| 2 | (no JSON) | usage error, or an option the question doesn't offer |
|
|
48
53
|
| 130 | `running` | Ctrl-C; the turn goes on |
|
|
49
54
|
|
|
@@ -139,8 +144,18 @@ answers from any local process.
|
|
|
139
144
|
isn't sent: it exits 1 with the `chi answer` command for the question. The
|
|
140
145
|
step-limit question is the exception: it waits between turns, so a message
|
|
141
146
|
goes in, drops it and starts a new turn.
|
|
147
|
+
- A follow-up to a session whose turn still runs (`busy: a turn is running`,
|
|
148
|
+
exit 1, say you waited on with `--timeout` and got exit 4) goes in with
|
|
149
|
+
`chi send --queue --wait -m "…" ID`: it runs as a turn of its own after the
|
|
150
|
+
running one, and the wait is for that turn's answer alone, never the running
|
|
151
|
+
turn's (`--format json` adds `"delivery": "queue"`). A question the running
|
|
152
|
+
turn asks meanwhile isn't yours: the wait goes on through it (until your
|
|
153
|
+
`--timeout`); one the queued turn asks comes back as exit 3. `chi answer`
|
|
154
|
+
on the running turn's question gets that turn's reply, not your queued one.
|
|
142
155
|
- `chi send --wait ID` with no message waits past a question already reported
|
|
143
|
-
(the step-limit question too), until someone answers it.
|
|
156
|
+
(the step-limit question too), until someone answers it. It waits for the
|
|
157
|
+
turn running when it starts (or the one the step-limit question continues),
|
|
158
|
+
and prints that turn's reply, not a queued one's after it.
|
|
144
159
|
- An answer to a question that is no longer open (you answered it in the web
|
|
145
160
|
first) isn't sent; `chi answer` says so and waits for what comes next. Its
|
|
146
161
|
JSON then has `"answered_here": false` (absent when the answer went in), so
|
|
@@ -187,6 +202,8 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
|
|
|
187
202
|
Run each chi command on its own, nothing chained after it: the exit code comes
|
|
188
203
|
back with the output. Talk to chi only through these commands; never call its
|
|
189
204
|
Bridge or web API directly. Follow-ups in the same session:
|
|
190
|
-
`chi send --wait --format json --timeout 500 -m "…" <session_id
|
|
205
|
+
`chi send --wait --format json --timeout 500 -m "…" <session_id>`; if that says
|
|
206
|
+
`busy` (a turn still runs), add `--queue`: the follow-up runs after that turn
|
|
207
|
+
and you get its own answer.
|
|
191
208
|
Keep `--timeout` under your shell tool's limit (Claude Code: 600 s per call).
|
|
192
209
|
```
|
data/docs/testing.md
CHANGED
|
@@ -27,6 +27,12 @@ A select bound with `.value` instead reads right in both, but lit sets it before
|
|
|
27
27
|
`llm_context_chip.js`'s `renderForm` draws twice then. Assert a kept node with `assert.ok(a === b)`: a failing
|
|
28
28
|
`assert.equal` on two DOM nodes inspects the whole DOM (huge output, can stall the run).
|
|
29
29
|
|
|
30
|
+
Where a composer line goes is decided twice, in Ruby (`ComposerRoute`, both terminals) and in the web
|
|
31
|
+
(`composer.js#routeComposerLine`). `spec/fixtures/composer_routes.json` holds the cases both answer: rspec
|
|
32
|
+
(`spec/composer_route_spec.rb`) and `npm test` (`spec/web/public/composer.test.js`) run every row. A change to
|
|
33
|
+
either router adds its row there; a row where a UI differs on purpose (or a found divergence) carries a `web` or
|
|
34
|
+
`repl` override and a `note`.
|
|
35
|
+
|
|
30
36
|
## While you work: affected specs
|
|
31
37
|
|
|
32
38
|
```sh
|
|
@@ -112,6 +112,10 @@ module Samagotchi
|
|
|
112
112
|
# wakes, and it must not come back as exit 3.
|
|
113
113
|
cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
|
|
114
114
|
baseline = ReplyWait.baseline_of(session, question_id: qid)
|
|
115
|
+
# The turn the answer is about: the one asking (running), or the one
|
|
116
|
+
# whose continue offer stands. Its own reply is the answer's, not a
|
|
117
|
+
# turn queued behind it; nil (an older worker): the next reply.
|
|
118
|
+
turn = ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
|
|
115
119
|
|
|
116
120
|
if pending && pending[:id].to_s == qid
|
|
117
121
|
return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
|
|
@@ -129,7 +133,7 @@ module Samagotchi
|
|
|
129
133
|
end
|
|
130
134
|
# Answered here, or no longer open (answered elsewhere, or a newer
|
|
131
135
|
# question): what comes next either way.
|
|
132
|
-
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
|
|
136
|
+
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn)
|
|
133
137
|
end
|
|
134
138
|
|
|
135
139
|
# The labels to send. A number is an option's place (1-based); an
|
|
@@ -226,6 +226,7 @@ module Samagotchi
|
|
|
226
226
|
when :turn_enqueued
|
|
227
227
|
queued = { enqueued_id: event[:enqueued_id], client_id: event[:client_id], prompt: event[:prompt] }
|
|
228
228
|
queued[:images] = event[:images] if event[:images]
|
|
229
|
+
queued[:delivery] = event[:delivery] if event[:delivery]
|
|
229
230
|
@queued << queued
|
|
230
231
|
when :recap_ready
|
|
231
232
|
# One collected just as a turn started describes the chat before it.
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -29,6 +29,7 @@ require_relative "version"
|
|
|
29
29
|
require_relative "worker_sidecar"
|
|
30
30
|
require_relative "relay_verifier"
|
|
31
31
|
require_relative "relay_watcher"
|
|
32
|
+
require_relative "reply_wait"
|
|
32
33
|
require_relative "tools/task_runtime"
|
|
33
34
|
|
|
34
35
|
module Samagotchi
|
|
@@ -52,8 +53,9 @@ module Samagotchi
|
|
|
52
53
|
DEFAULT_RING_CAPACITY = 256
|
|
53
54
|
DEFAULT_HEARTBEAT_INTERVAL = 15.0
|
|
54
55
|
# Cancel reasons a client may name (the web sends user, an attached TUI
|
|
55
|
-
# ctrl_c
|
|
56
|
-
|
|
56
|
+
# ctrl_c, a session stop session_stopped: SessionManager.stop_session);
|
|
57
|
+
# anything else is :manual, so client input never mints symbols.
|
|
58
|
+
CANCEL_REASONS = %w[manual user ctrl_c session_stopped].freeze
|
|
57
59
|
# How long #stop waits for requests it is answering (not open streams).
|
|
58
60
|
REQUEST_GRACE_SECONDS = 1.0
|
|
59
61
|
# How long #stop waits for each killed RelayWatcher thread to end.
|
|
@@ -252,7 +254,7 @@ module Samagotchi
|
|
|
252
254
|
seq = @engine.event_count
|
|
253
255
|
{
|
|
254
256
|
messages: @engine.messages_checkpoint,
|
|
255
|
-
current_turn:
|
|
257
|
+
current_turn: current_turn_with_generation,
|
|
256
258
|
queued: @accumulator.queued,
|
|
257
259
|
queued_commands: @accumulator.queued_commands,
|
|
258
260
|
recap: @accumulator.recap,
|
|
@@ -314,6 +316,16 @@ module Samagotchi
|
|
|
314
316
|
|
|
315
317
|
private
|
|
316
318
|
|
|
319
|
+
# The turn in progress (TurnAccumulator#current_turn) with the running
|
|
320
|
+
# generation's phase as generation: {thinking_only:, age:}
|
|
321
|
+
# (Engine#generation_snapshot), so a page joining mid-generation can
|
|
322
|
+
# clock its delivery line and say whether a cut can land.
|
|
323
|
+
def current_turn_with_generation
|
|
324
|
+
turn = @accumulator.current_turn
|
|
325
|
+
gen = turn && @engine.generation_snapshot
|
|
326
|
+
gen ? turn.merge(generation: gen.to_h) : turn
|
|
327
|
+
end
|
|
328
|
+
|
|
317
329
|
# The Engine's commands: the built-ins and its plugins'.
|
|
318
330
|
def command_registry
|
|
319
331
|
@engine.command_registry
|
|
@@ -495,7 +507,11 @@ module Samagotchi
|
|
|
495
507
|
# task_stop: POST tasks/stop (#handle_task_stop), the web's stop-task button.
|
|
496
508
|
# reparent: the worker reads Session::PARENT_FILE (its rings, each turn's
|
|
497
509
|
# start), so a continue may move it to the chain's new link (ChildMove).
|
|
498
|
-
|
|
510
|
+
# delivery: POST /turn runs every Delivery value; a queue waits for the
|
|
511
|
+
# turn's end (Delivery::QUEUE_FEATURE). A worker without it would steer.
|
|
512
|
+
# turn_ids: the running turn's checkpoint mark, a continue turn and a
|
|
513
|
+
# Stop's last_turn carry the turn's enqueued_id (ReplyWait.own_turn).
|
|
514
|
+
FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE].freeze
|
|
499
515
|
|
|
500
516
|
# The routes, by method and what follows /session/:id/: a handler takes
|
|
501
517
|
# the session id and the request body and returns [headers, status, body].
|
|
@@ -868,6 +884,10 @@ module Samagotchi
|
|
|
868
884
|
enqueued_id = SecureRandom.uuid
|
|
869
885
|
# Before it is queued: a drain after this may have taken it (#cut_for_steer).
|
|
870
886
|
epoch = @engine.input_epoch
|
|
887
|
+
# Whether a queue waits for a running turn: read with the write under
|
|
888
|
+
# the event log, so no :turn_started or :turn_completed falls between.
|
|
889
|
+
# To an idle worker the queued file simply runs as the next turn.
|
|
890
|
+
queued_behind_turn = false
|
|
871
891
|
# Write and announce with the event log held: the worker can't emit
|
|
872
892
|
# this turn's :turn_started (or merge it mid-turn) before
|
|
873
893
|
# :turn_enqueued, and a failed write announces nothing.
|
|
@@ -875,12 +895,14 @@ module Samagotchi
|
|
|
875
895
|
raise IntakeClosed if stopped?
|
|
876
896
|
next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
|
|
877
897
|
|
|
898
|
+
queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
|
|
878
899
|
enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
879
900
|
no_interrupt: no_interrupt, images: images, delivery: delivery).tap do |ok|
|
|
880
901
|
next unless ok
|
|
881
902
|
|
|
882
903
|
enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
|
|
883
904
|
enqueued_event[:images] = images unless images.empty?
|
|
905
|
+
enqueued_event[:delivery] = Delivery::QUEUE if queued_behind_turn
|
|
884
906
|
@engine.announce(enqueued_event)
|
|
885
907
|
@on_input&.call
|
|
886
908
|
end
|
|
@@ -896,7 +918,9 @@ module Samagotchi
|
|
|
896
918
|
# the running turn's next step.
|
|
897
919
|
cut = nil
|
|
898
920
|
cut = @engine.cut_for_steer(Steer.source_for_client(client_id), epoch: epoch) if images.empty? && Delivery.cut?(delivery)
|
|
899
|
-
|
|
921
|
+
# A queue to an idle worker is a turn now: the ack says next_step.
|
|
922
|
+
ack_delivery = Delivery.queue?(delivery) && !queued_behind_turn ? Delivery::NEXT_STEP : delivery
|
|
923
|
+
ack = { status: "accepted", enqueued_id: enqueued_id, session_id: @session_id, delivery: ack_delivery }
|
|
900
924
|
ack[:cut] = cut.to_s if cut
|
|
901
925
|
ack[:cut_after] = @engine.steer_cut_after if cut == :waits
|
|
902
926
|
[{}, 202, ack]
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: slow-commands
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Remembers commands that ran past the execute limit in a project; the next identical execute is denied once a turn with advice to run it with task_create and task_wait; /slow-commands lists and forgets them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
9
|
+
sha256: sha256:6bf5da2501a6404cda01c95975b005fce179f756c56222771c5ee857d7ba4b98
|
|
10
10
|
# Execute::HANDED_OVER, read when the plugin loads (0.59.0); after_tool_call's
|
|
11
11
|
# call:, elapsed_ms: and result: (0.57.0)
|
|
12
12
|
requires_chi: ">= 0.59.0"
|
|
@@ -75,9 +75,11 @@ class Plugin
|
|
|
75
75
|
TIMED_OUT = /\A#{Regexp.escape(Samagotchi::Tools::Execute::TIMEOUT_PREFIX)} (\d+)s/
|
|
76
76
|
# Still running at the limit, handed over as a task (chi >= 0.59.0).
|
|
77
77
|
HANDED_OVER = /\A#{Regexp.escape(Samagotchi::Tools::Execute::HANDED_OVER)} (\d+)s limit/
|
|
78
|
-
# A stopped command's first words: the user's Stop,
|
|
79
|
-
# (chi >= 0.62.0; an older chi has no
|
|
80
|
-
|
|
78
|
+
# A stopped command's first words: the user's Stop, a signal to chi
|
|
79
|
+
# (chi >= 0.62.0) or a session stop (chi >= 0.64.0); an older chi has no
|
|
80
|
+
# such text.
|
|
81
|
+
STOPPED = %i[STOPPED_BY_USER NOT_RUN_ON_STOP STOPPED_BY_SIGNAL NOT_RUN_ON_SIGNAL
|
|
82
|
+
STOPPED_BY_SESSION_STOP NOT_RUN_ON_SESSION_STOP].filter_map do |name|
|
|
81
83
|
Samagotchi::Tools::Execute.const_get(name) if Samagotchi::Tools::Execute.const_defined?(name)
|
|
82
84
|
end.freeze
|
|
83
85
|
LEX = Samagotchi::Guardrails::ShellLex
|
|
@@ -57,7 +57,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
|
|
|
57
57
|
**Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
|
|
58
58
|
|
|
59
59
|
- `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
|
|
60
|
-
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`;
|
|
60
|
+
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, auth, dialect, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `auth:` (`bearer|x-api-key`) is the header the key goes in (default `x-api-key` on `api: anthropic`, Bearer elsewhere); `dialect:` (`fireworks`, or `standard` for the default; `api: anthropic` only) adjusts the Messages wire for that provider (Bearer by default, no thinking `display`, no `cache_control`, `x-session-affinity`; `LLM::Dialect`, docs/configuration.md "Anthropic Messages hosts"); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; a worker reads `hosts:` from the file as it starts (`SAMAGOTCHI_HOSTS_JSON` replaces it for a process it is set on; `SessionManager.spawn_options` unsets it for workers).
|
|
61
61
|
- `models:` map of model id or alias → `{profile, vision, sampling, thinking, reasoning_history}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `reasoning_history:` (here or on a host) is `drop|turn|all`, which of its reasoning a chat host's model gets back. Precedence: the session's own level (`/reasoning-history LEVEL`, saved in the session) > `SAMAGOTCHI_REASONING_HISTORY` > `models:` > `hosts.<name>.reasoning_history` > `turn`; `docs/configuration.md` "Reasoning history". `/thinking` and `/reasoning-history` ask before a change that restarts the prompt cache; `--yes` applies it (`chi send` commands apply directly). `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
|
|
62
62
|
- `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
|
|
63
63
|
- `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
|
|
@@ -128,7 +128,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
|
|
|
128
128
|
## Validations
|
|
129
129
|
|
|
130
130
|
- **Model name** (`default.model` / `SAMAGOTCHI_DEFAULT_MODEL`): `ModelProfile.required_model_name` — non-empty string, otherwise harness fails fast at startup. Via `Config.get("default.model")`.
|
|
131
|
-
- **Host api** (`hosts.<name>.api`): `llama_cpp|mlx|omlx` (raw-prompt loop; also the transport)
|
|
131
|
+
- **Host api** (`hosts.<name>.api`): `llama_cpp|mlx|omlx` (raw-prompt loop; also the transport), `openai` (chat loop at `http://HOST:PORT/v1`) or `anthropic` (chat loop over the Messages API at `{url}/v1/messages`; without `url:`/`host:` it is `https://api.anthropic.com`). Absent: raw-prompt loop. It replaces the removed `backend` setting.
|
|
132
132
|
- **Transport** (`server.transport`): enum `llama_cpp|mlx|omlx`.
|
|
133
133
|
- **Profile** (`models.<id>.profile`, `hosts.<name>.profile`, `SAMAGOTCHI_MODEL_PROFILE`): enum `qwen36|gemma4`; an unknown one warns and is ignored.
|
|
134
134
|
- **Alias name** (`write_model_alias!`):
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.65.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
9
|
+
config_modification_protocol.md: sha256:301895777916dd998d8946aaa2e8f3a6e7f2b18eaf9362eef11471ae602d54ab
|
|
10
10
|
memory_guide.md: sha256:b0a1ba42c5334d4117b5733c8c26d7477364baca9169b0c0a711de6ac7fc7f3c
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
|
@@ -231,11 +231,16 @@ module Samagotchi
|
|
|
231
231
|
return nil unless child.parent_id == @session_id
|
|
232
232
|
|
|
233
233
|
before = @taken[child_id]&.after || Tools::DelegateCursors.get(@session_id, child_id, state_dir: @state_dir)
|
|
234
|
+
# A follow-up's turn (DelegateCursor#turn) is reported by its own
|
|
235
|
+
# reply, as delegate_result takes it, not a newer queued turn's.
|
|
234
236
|
wait = ReplyWait.call(child_id, state_dir: @state_dir, cursor: before.reply_file, baseline: before.baseline,
|
|
235
|
-
timeout: 0)
|
|
237
|
+
timeout: 0, turn: before.turn)
|
|
236
238
|
return nil unless reportable?(wait)
|
|
237
239
|
|
|
238
|
-
|
|
240
|
+
# A reported end is the follow-up turn's too (DelegateCursor#turn):
|
|
241
|
+
# a later wait on it would repeat its reply.
|
|
242
|
+
after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
|
|
243
|
+
turn: wait.status == :waiting_for_answer ? before.turn : nil)
|
|
239
244
|
.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
240
245
|
text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
|
|
241
246
|
Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings)
|
|
@@ -57,10 +57,17 @@ module Samagotchi
|
|
|
57
57
|
# report it. Ctrl-C leaves the turn running (130).
|
|
58
58
|
# @param owner_grace [Numeric, nil] seconds with no live worker before
|
|
59
59
|
# it counts as gone; nil waits for whatever wakes one
|
|
60
|
+
# @param turn [String, nil] the enqueued_id of the turn waited for
|
|
61
|
+
# (ReplyWait's turn:): a queued message's, or the turn an answer or
|
|
62
|
+
# a --wait with no message is about (ReplyWait.own_turn)
|
|
63
|
+
# @param queued [Boolean] +turn+ is a queued message's (ReplyWait's
|
|
64
|
+
# queued:)
|
|
60
65
|
# @return [Integer] the exit status
|
|
61
|
-
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER
|
|
66
|
+
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER, turn: nil,
|
|
67
|
+
queued: false)
|
|
62
68
|
result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
|
|
63
|
-
owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL
|
|
69
|
+
owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL, turn: turn,
|
|
70
|
+
queued: queued)
|
|
64
71
|
result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
65
72
|
@reported = true
|
|
66
73
|
ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
|
data/lib/samagotchi/client.rb
CHANGED
|
@@ -195,9 +195,11 @@ module Samagotchi
|
|
|
195
195
|
# @param api_key_env [String, nil] the variable holding the host's API
|
|
196
196
|
# key (llama.cpp's --api-key), sent as a bearer token on every request;
|
|
197
197
|
# nil sends no Authorization header
|
|
198
|
+
# @param auth [Symbol, nil] the header the key goes in (LLM::ApiKey::SCHEMES;
|
|
199
|
+
# HostEntry#auth_scheme), nil: Bearer
|
|
198
200
|
# @param env [Hash] where the key variable is read
|
|
199
201
|
def initialize(host: nil, port: nil, open_timeout: nil, read_timeout: nil, transport: nil, sleeper: nil, scheme: nil,
|
|
200
|
-
first_token_timeout: nil, name: nil, api_key_env: nil, env: ENV)
|
|
202
|
+
first_token_timeout: nil, name: nil, api_key_env: nil, auth: nil, env: ENV)
|
|
201
203
|
# Unified config precedence: CLI > ENV > file > default (via Samagotchi::Config)
|
|
202
204
|
@host = host || Samagotchi::Config.get("server.host")
|
|
203
205
|
@port = (port || Samagotchi::Config.get("server.port")).to_i
|
|
@@ -217,7 +219,7 @@ module Samagotchi
|
|
|
217
219
|
@label = name.to_s.empty? ? @transport.label : name.to_s
|
|
218
220
|
@http = LLM::HTTP.new(label: @label, open_timeout: @open_timeout, read_timeout: @read_timeout,
|
|
219
221
|
sleeper: sleeper, first_token_timeout: first_token_timeout,
|
|
220
|
-
api_key: LLM::ApiKey.for(api_key_env, host: @label, env: env))
|
|
222
|
+
api_key: LLM::ApiKey.for(api_key_env, host: @label, env: env, scheme: auth))
|
|
221
223
|
end
|
|
222
224
|
|
|
223
225
|
# Seconds a completion may take to stream its first text, or nil.
|