samagotchi 0.156.0 → 0.157.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 +44 -1
- data/bin/chi +1 -0
- data/docs/architecture.md +12 -7
- data/docs/bundles.md +3 -1
- data/docs/cli.md +9 -8
- data/docs/configuration.md +36 -4
- data/docs/guardrails.md +9 -5
- data/docs/hooks.md +11 -5
- data/docs/internals/bridge.md +83 -6
- data/docs/internals/bundles.md +28 -18
- data/docs/internals/context-telemetry.md +4 -3
- data/docs/internals/engine.md +16 -2
- data/docs/internals/participants.md +3 -2
- data/docs/internals/remote-sessions.md +5 -2
- data/docs/internals/web-frontend.md +3 -2
- data/docs/internals/working-set.md +1 -1
- data/docs/memory.md +13 -2
- data/docs/plugins.md +35 -2
- data/docs/sessions.md +9 -9
- data/docs/sub-agent.md +17 -2
- data/docs/testing.md +53 -2
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +3 -2
- data/lib/samagotchi/bridge/card_places.rb +10 -4
- data/lib/samagotchi/bridge/card_store.rb +3 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
- data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
- data/lib/samagotchi/bridge.rb +31 -6
- data/lib/samagotchi/broadcast/recipients.rb +8 -8
- data/lib/samagotchi/broadcast/triage_model.rb +1 -1
- data/lib/samagotchi/bundle_command/check.rb +18 -0
- data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +5 -2
- data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
- data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
- data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/child_move.rb +6 -6
- data/lib/samagotchi/child_reports.rb +2 -2
- data/lib/samagotchi/children_status.rb +15 -21
- data/lib/samagotchi/cli/parent_wait.rb +8 -22
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_sources.rb +5 -4
- data/lib/samagotchi/context_window.rb +9 -12
- data/lib/samagotchi/effective_model.rb +57 -22
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/recap.rb +8 -4
- data/lib/samagotchi/engine.rb +29 -3
- data/lib/samagotchi/events.rb +3 -2
- data/lib/samagotchi/extension_load.rb +6 -3
- data/lib/samagotchi/guardrails_command.rb +1 -1
- data/lib/samagotchi/host_address.rb +52 -0
- data/lib/samagotchi/host_registry.rb +57 -45
- data/lib/samagotchi/kernel_loop.rb +7 -3
- data/lib/samagotchi/llm/chat_loop.rb +6 -3
- data/lib/samagotchi/llm_context_strategy.rb +27 -63
- data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
- data/lib/samagotchi/memory_bundle/builder.rb +2 -1
- data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
- data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
- data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
- data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
- data/lib/samagotchi/memory_bundle/installer.rb +185 -169
- data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
- data/lib/samagotchi/memory_bundle/merger.rb +69 -65
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
- data/lib/samagotchi/memory_bundle/profile.rb +5 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
- data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
- data/lib/samagotchi/memory_bundle/status.rb +14 -20
- data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
- data/lib/samagotchi/memory_bundle/trash.rb +23 -8
- data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
- data/lib/samagotchi/memory_usage.rb +1 -1
- data/lib/samagotchi/model_lookup.rb +75 -0
- data/lib/samagotchi/model_profile.rb +1 -0
- data/lib/samagotchi/models_command.rb +1 -1
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/participant_mode.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +41 -6
- data/lib/samagotchi/plugin/loader.rb +33 -10
- data/lib/samagotchi/plugin/sessions.rb +1 -1
- data/lib/samagotchi/reasoning_history.rb +14 -20
- data/lib/samagotchi/recap_place.rb +27 -0
- data/lib/samagotchi/reply_wait/liveness.rb +38 -0
- data/lib/samagotchi/reply_wait/poll.rb +44 -15
- data/lib/samagotchi/reply_wait/target.rb +113 -0
- data/lib/samagotchi/reply_wait.rb +22 -2
- data/lib/samagotchi/sampling_settings.rb +9 -16
- data/lib/samagotchi/self_report.rb +34 -35
- data/lib/samagotchi/send_command.rb +17 -23
- data/lib/samagotchi/session.rb +321 -439
- data/lib/samagotchi/session_access/local.rb +5 -6
- data/lib/samagotchi/session_access/remote.rb +5 -3
- data/lib/samagotchi/session_field.rb +133 -0
- data/lib/samagotchi/session_inbox.rb +49 -1
- data/lib/samagotchi/session_manager/emptiness.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +29 -100
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_metrics.rb +7 -4
- data/lib/samagotchi/session_paths.rb +57 -0
- data/lib/samagotchi/session_retention.rb +2 -2
- data/lib/samagotchi/session_row.rb +200 -0
- data/lib/samagotchi/session_setup.rb +15 -12
- data/lib/samagotchi/sessions_command.rb +11 -129
- data/lib/samagotchi/steer_cut_after.rb +7 -14
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
- data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +3 -1
- data/lib/samagotchi/testing/install.rb +2 -1
- data/lib/samagotchi/thinking.rb +16 -21
- data/lib/samagotchi/tool_response.rb +5 -3
- data/lib/samagotchi/tool_runner.rb +18 -1
- data/lib/samagotchi/tools/delegate.rb +48 -43
- data/lib/samagotchi/tools/delegate_relay.rb +5 -5
- data/lib/samagotchi/tools/delegate_result.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +41 -17
- data/lib/samagotchi/tools/list_sessions.rb +7 -7
- data/lib/samagotchi/update_command.rb +11 -7
- data/lib/samagotchi/verify_fresh.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +11 -14
- data/lib/samagotchi/web/app.rb +31 -21
- data/lib/samagotchi/web/message_display.rb +57 -7
- data/lib/samagotchi/web/participant_access.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +16 -7
- data/lib/samagotchi/web/public/app.js +29 -8
- data/lib/samagotchi/web/public/bubbles.js +8 -3
- data/lib/samagotchi/web/public/cards_place.js +15 -0
- data/lib/samagotchi/web/public/commands.js +5 -1
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/history_shape.js +220 -0
- data/lib/samagotchi/web/public/recap_stamp.js +17 -0
- data/lib/samagotchi/web/public/reload_check.js +71 -0
- data/lib/samagotchi/web/public/stage_model.js +7 -5
- data/lib/samagotchi/web/public/stage_view.js +7 -3
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/timing.js +43 -2
- data/lib/samagotchi/web/public/turn_block.js +215 -30
- data/lib/samagotchi/web/public/turn_drawing.js +43 -0
- data/lib/samagotchi/web/public/turn_events.js +10 -1
- data/lib/samagotchi/web/public/turn_pieces.js +29 -0
- data/lib/samagotchi/web/reload_check_route.rb +95 -0
- data/lib/samagotchi/web/server.rb +4 -2
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_control.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +9 -17
- data/lib/samagotchi/web/session_show.rb +64 -2
- data/lib/samagotchi/web/session_summary.rb +36 -24
- data/lib/samagotchi/worker.rb +28 -5
- data/lib/samagotchi/worker_link.rb +3 -0
- metadata +20 -4
- data/lib/samagotchi/send_command/wait_target.rb +0 -85
data/docs/sessions.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Sessions
|
|
2
2
|
|
|
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`), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars)). Its keys are in [the Session file table](#session-file). A file from before `used_memory_names`, `preloaded_memory_names`, `muted_memory_names`, `parent_id` or `continues` reads those as empty lists, no parent and no previous link.
|
|
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`), `turn_drawings.jsonl` once a turn ended with a Bridge (each finished turn as live drew it, which the web's reload draws: [bridge.md](internals/bridge.md#turndrawings)), and `tool_output/` once a long `execute` result was cut: its whole output, which the cut result names (`ToolOutputFiles`, the newest 50 kept; see [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars)). Its keys are in [the Session file table](#session-file). A file from before `used_memory_names`, `preloaded_memory_names`, `muted_memory_names`, `parent_id` or `continues` reads those as empty lists, no parent and no previous link.
|
|
4
4
|
|
|
5
5
|
**Message times:** an entry of `messages` may carry `at`, when it happened: a local-offset ISO time with milliseconds (`2026-10-24T14:32:05.120+02:00`). User prompts, lines merged into a turn and steers, reminders, context and task notes, and the model's replies have one (a note's is its `created_at`); tool results, `!cmd` messages and chi's own turn notes don't, nor does anything saved before chi stamped messages (no backfill). It is for the UIs only (the gap dividers, a bubble's time): it never goes to the model, so it changes nothing in the requests or their prompt cache. The one time the model reads is the reminders': due reminders go into a turn as a system message headed `[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]` (the turn's local time; `ReminderQueue#inject!`), written into the saved content then, so a later request sends the same text.
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
|
|
|
8
8
|
|
|
9
9
|
**Revise stacks:** a web Revise turn (POST /turn `revises: {turn_id}`, the answer it rewrites) stamps its opening message (its prompt) with `revises: {turn_id}` at the turn's end, only when the turn completed with an answer (`AnswerTail`): a failed, canceled or empty turn revised nothing (`TurnStages::VerifyAnchors#stamp_opening`). A draft no prompt carries (a stale page, a rolled-back turn) is no target: no stamp. A draft a stamped turn already revised is re-targeted to its stack's top, the newest version, so a stack is a chain `v1 ← v2 ← v3`. Like `verify`, it is metadata only, never added to the prompt (no change to a request's bytes); chi web's history passes it on the opening message (and on a wake turn's note), and `:turn_completed` carries the stamped `revises` (the participant view's too). The history also marks a Verify turn's opening `verify: true`, and so does its live end event (`:turn_completed`'s `verify: true`, with its `turn_id`): the page offers no Revise on its answer. The web page draws the stacks from these stamps (docs/cli.md, Revise). Like a Verify turn's, a Revise message waits for the running turn's end (`SessionInbox.turn_end_data?`; the Bridge takes either as `delivery: queue`, so it never cuts and its ack and `:turn_enqueued` say it queues), is sent only to a worker whose `bridge.json` names the `revise` feature (else 409 `revise_unavailable`), and a failed turn gives it back (`:prompt_restored`'s `revises`). Participants can't Revise: chi web and the Bridge drop it from their turns. Both anchors travel as one `TurnAnchors` inside chi; on the wire and in an input file each keeps its own key (`verify`, `revises`).
|
|
10
10
|
|
|
11
|
-
**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`). Such a file, with a prompt, from the user, `chi send` or a delegating parent, also ends a running `task_wait` or `delegate_result` and hands a running `execute` over as a task after 3 s (`SessionInbox.steer_waiting?`, a peek the worker's turn polls through `Engine#input_waiting?`; never in a participant's turn): the file stays for the 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). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **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`.
|
|
11
|
+
**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`). For each file it merges, before deleting it, the drain records which turn took it: `input/merged/<enqueued_id>` = `{"into": the turn's enqueued_id, "at"}` (`SessionInbox.mark_merged`; none for a turn without an id), so a wait for that message waits for that turn (`ReplyWait::Poll#follow_merge`; a 0.156 worker writes none and the wait infers the merge). A turn nobody typed (a reminder, a wake turn: a delegate report's, an attached context change's, a finished task's) gets an `enqueued_id` its worker mints as it runs, `wake-` and 16 hex digits (`Worker#wake_origin`), for its mark, `last_turn`, reply file and settle; the reports it merges keep id-less origins. Such a file, with a prompt, from the user, `chi send` or a delegating parent, also ends a running `task_wait` or `delegate_result` and hands a running `execute` over as a task after 3 s (`SessionInbox.steer_waiting?`, a peek the worker's turn polls through `Engine#input_waiting?`; never in a participant's turn): the file stays for the 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). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **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`.
|
|
12
12
|
|
|
13
13
|
**Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), and a message in a stopped worker's inbox (the next worker runs it).
|
|
14
14
|
|
|
@@ -33,11 +33,11 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
33
33
|
|
|
34
34
|
**Which chi a worker runs:** a new or woken worker runs the newest chi installed for this Ruby (`gem 'samagotchi', '>= 0.a'`, prereleases count), whoever starts it: a `chi web` or terminal still on an older chi starts it on the newest one, so a stale process doesn't spread old code. A worker already running keeps its version until it is restarted. Should the newest install fail to load, the worker runs the starter's own version and logs `boot_fallback` (its stdio is `/dev/null`). Under `bundle exec` the worker drops Bundler's env first, so it activates the newest gem too. Two consequences: a wrapper pin (`chi _0.17.0_ web`) pins only that process, not the workers it starts; and a source checkout (`bin/chi` in a worktree, `bundle exec`) starts its workers on its own `lib/`, but a session it made that is later woken by an installed chi (`chi web`, `chi send`) runs the installed code. The names an older starter hands a newer worker (the boot line, its env keys, `input/` files, the session JSON fields and the Bridge routes sessions use on each other) are pinned in `spec/worker_contract_spec.rb`. A chi newer than 0.130.0 keeps the top-level keys it doesn't know (a newer chi's fields) when it saves a session, after its own, and doesn't act on them; `written_by` names the chi that saved the file last, and a worker woken on a file a newer chi saved logs `newer_writer`. chi 0.130.0 and older drop those keys: a session a newer chi made loses its newer fields when an older chi wakes or saves it (for example `chi --attach` from a project whose Ruby has an older chi). Keep the chi in every Ruby you run it from newer than 0.130.0.
|
|
35
35
|
|
|
36
|
-
A new session field (
|
|
36
|
+
A new session field is one `Session::FIELDS` row, plus a `SessionSetup` member when a fork and a continue carry it (`SessionSetup.of` and `Session.new_session` go by its members). The rule in `FIELDS`' comment: only top-level keys are kept (a new sub-key of `review`, `toolkit` or another value object is still dropped by an older chi), and an older chi keeps a key without honouring it, so a new field must be safe to come back unchanged after an older chi ran turns on the session.
|
|
37
37
|
|
|
38
38
|
**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.
|
|
39
39
|
|
|
40
|
-
**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 `chi --resume ID -p TEXT --non-interactive` run, 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 dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A wait with no turn id that saw the turn running ends the same way when the settle comes (`chi send --wait ID` with no message on a turn with no id, such as a
|
|
40
|
+
**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 `chi --resume ID -p TEXT --non-interactive` run, 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 dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A wake or reminder turn's settle is recorded under its worker-minted `wake-…` id the same way, and a message merged into a turn as a steer follows that turn's id (`input/merged/`). A wait with no turn id that saw the turn running ends the same way when the settle comes (`chi send --wait ID` with no message on a turn with no id, such as a 0.156 worker's reminder turn; a steer merged by a 0.156 worker), unless an input still waits or was claimed since: an unmerged message runs as its own turn next, and that turn's end is the answer. A reply the turn wrote before its worker died is still its answer. 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.
|
|
41
41
|
|
|
42
42
|
**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, then CONT (a stopped worker, SIGSTOP or Ctrl-Z, runs its TERM handler only once continued, so it leaves at once instead of outliving the wait), 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 ⚙ panel, 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 (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. Before that stop, the page's `stop session` button asks what the worker still has that the user can stop: `GET /api/sessions/:id/holds` (owner-only) answers the worker's `holds` — the same list the exit ask's 409 carries — always an array, empty when there is no live worker, one from before the route, or one that did not answer. With something, a sheet (a bottom sheet on a phone, a popover at the button otherwise — `stop_sheet.js`) lists them, at most three of a kind named and `+N more`, and offers **Stop all** (`POST /api/sessions/:id/holds/stop` → `HoldStop#stop_all`, then the stop), **Stop session only** (the stop exactly as it was) or **Cancel**. Nothing to ask about is the plain confirm, one tap as before. Both buttons stay because a stop only TERMs the worker: its reminders die with it (a stop also deletes a restart's `reminders.json` no successor took yet, so a later resume doesn't bring them back), while a background task and a delegate are their own processes and keep running. 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.
|
|
43
43
|
|
|
@@ -93,7 +93,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
93
93
|
|
|
94
94
|
**Projects:** a session belongs to the git project it was started in: the repository, whichever worktree or subfolder of it (the same project root memories use). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no repo (`~`). `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
|
|
95
95
|
|
|
96
|
-
`--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.
|
|
96
|
+
`--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.session_rows`: the rows are `SessionRow`; the plain list, the picker's text and a remote list print one line, `SessionRow#cli_line`): `--live` keeps the sessions a worker runs now (the owner lock, not the saved status; a session a one-turn `chi --non-interactive` run has is left out), `--cwd PATH` those in PATH or below, and test runs are left out unless the list is itself run as one (`SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` set: then they show, marked `[test]`; `chi note --all` likewise). `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, project, updated_at, live, busy, owner, recap, parent_id, continues, delegate, archived, scratch, ctx_pct}]` (`owner`: `"worker"`, `"tui"` for a one-turn `chi --non-interactive` run, which takes no notes or messages, or null; `recap`: the first sentence of the session's recap, or null; `delegate`: true for a child the `delegate` tool started, false for a fork, which has a `parent_id` too), `--format tsv` one `id<TAB>desc` line per session, where `desc` is `<folder> · <last prompt>` cut to 60 characters (the text form of `--live`/`--cwd` prints the plain list's line, with its state, `idle` for a live idle worker too, and its text; tsv and json keep `desc`).
|
|
97
97
|
|
|
98
98
|
**Ordering:**
|
|
99
99
|
|
|
@@ -115,7 +115,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
115
115
|
|
|
116
116
|
## Session file
|
|
117
117
|
|
|
118
|
-
`Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.from_h` and `#to_h`
|
|
118
|
+
`Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.new`, `.from_h` and `#to_h` all go by it; 41 keys). Each key is one `SessionField` row (`session_field.rb`): its default for a file without it, how a value is cast when read and what the file gets, the codec being its type's own (`Toolkit.from_file`, `SessionReview.of`, `PromptNote.list`, `#to_file`…); the typed fields' writers cast through their row, and `#to_h` reads each field's instance variable, not its reader. A key a file lacks reads as the default below, a key present as `null` as `null` (then cast: `test_run` false, `toolkit_origin` chosen); `review`, `first_turn_id`, `verdicts` and `verify_fresh` are written only when set (`OPTIONAL_FIELDS`). A key this chi doesn't know is kept and written back as it was (see the rule above). `thinking`, `reasoning_history` and `llm_context` follow one Unread rule (`SessionField.level`): a value this chi can't read (a newer chi's level, a hand edit) reads as none and is kept and written back as it was, until the session sets its own; `llm_context` keeps the parts it can't read under the ones it can. Where the file and the session's dir are, and which ids name a session, is `SessionPaths` (`session_paths.rb`, a leaf that `Session` extends: `Session.session_dir` is `SessionPaths.session_dir`). The bytes a save writes for each field and on each path that makes or rewrites a file are pinned in `spec/session_file_goldens_spec.rb`, with what a fork, a continue, a delegate child and a respawn keep of their source (`carry_matrix.json`). "Who writes it": the Engine runs in the session's worker (or a `--non-interactive` run); the rest are named.
|
|
119
119
|
|
|
120
120
|
| Key | Meaning | Written by |
|
|
121
121
|
|-----|---------|------------|
|
|
@@ -494,12 +494,12 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
|
|
|
494
494
|
|
|
495
495
|
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 above 64 KB (`DelegateWait.cut`); a reply longer than `max_tool_output_chars` keeps its start and its end (the conclusion), with a gap line naming that `output/` file, where the whole reply is (`ToolOutputCut`'s `:head_tail_ref`; see [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars)). 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; `↑ <parent id>` in its info bar, its tooltip `delegated by session …`; 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.
|
|
496
496
|
|
|
497
|
-
- `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
|
|
497
|
+
- `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 (`turn` in `delegates.json` is the follow-up's own id, set when the child's worker names `turn_ids`): the turn of its own it runs, or the running turn whose drain merges it as a steer (the drain's `input/merged/` record says which; inferred for an older child), not a newer reply of a turn queued behind it. A follow-up the running turn ended without taking runs as its own turn and gets that turn's reply. A child on its first turn, or with an older worker, gives its next reply, as before.
|
|
498
498
|
- `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. With no `cwd:` and a task that names another checkout of the same repository (a path in the task belonging to a linked worktree, read from the files with `Guardrails::GitInfo.checkouts`, no git process), the result carries `Warning: this child runs in <folder>, but its task names <checkout>. If it should work there, stop it (chi sessions stop <id>) and call delegate again without session, with cwd: <checkout>.` One line however many are named; nothing for a path of the child's own checkout, a folder that isn't one, a path with spaces, or the main checkout when the child runs in a linked worktree of it (the work belongs in the worktree, so naming a main-checkout file is not a forgotten `cwd:`). The child starts as it would anyway, and with `wait: true` (the default) the line comes after the child ran, so it only helps with `wait: false`. A `Warning:` for an unknown model id comes first, then this one. `Log.info(:worker, "delegate_other_checkout", parent:)` records each line.
|
|
499
499
|
- `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.
|
|
500
500
|
- A wait also ends early when a person's message waits for the parent's turn's next step (the `task_wait` rule: `Engine#input_waiting?`, `SessionInbox.steer_waiting?`, never in a participant's turn): `ReplyWait`'s `message_waiting:` probe gives `:message_waiting`, checked after every outcome the child can arrive at (a reply, a question, an end, a gone worker) and a cancel, before the deadline, and the result says `no reply yet: a message from the user is waiting; the child keeps running. Read the message first.` with the same last line as a timeout's. The child is never touched, the cursor doesn't move (the reply still comes, as a report or at the next `delegate_result`), a relayed approval's outcome line stays in the result, and a reserved report reads as for a timeout. A delegate child's report doesn't end a wait. This holds for `delegate`'s own wait too (both go through `DelegateWait`).
|
|
501
501
|
- 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`, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
|
|
502
|
-
- 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
|
|
502
|
+
- 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 5 seconds (it crashed without saying so, or idle-exited; `ReplyWait::GONE_AFTER`, the one rule `chi send --wait` follows too), or, for a child the call started or woke, when its worker didn't come up within the boot limit (30 s: "its worker did not start"; the wait awaits a booting worker first, within the call's `timeout` and until a cancel, which then read as `running`): `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.
|
|
503
503
|
- **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 `guardrails.parent_answers`, or the child's own when stricter (`hard`: Stop only; see [chi as a sub-agent](sub-agent.md#the-step-limit)).
|
|
504
504
|
- **A child at its cost cap** (the [cost-cap bundle](bundles.md#the-cost-cap-bundle), the child's own cap: `child:` in its settings, or `/cap`) asks a `hook` question, "Cost cap", `Continue` or `Stop`. Like every hook's question on a `delegate` child, it goes where it can be decided, under the child's `guardrails.parent_answers` (config.yml's, or the child's own when stricter): when the parent may pick an option other than a deny (Stop) there, `standard` and `soft` here, it comes back to the parent's model as `status: question` with the `chi answer` commands; when it may not, `hard` here (a parent's Continue is refused), it goes to the parent's user as a card, as approvals do (the relay below), and the model sees one line: `hook question relayed to your user: Cost cap → Stop` (or `dismissed`, which the hook reads as it likes, cost-cap as Stop; `refused by the child …`, `answered on the child`, `still open (your turn was stopped)`). Free text alone doesn't count as an option the parent may pick. A **background** child (`wait: false`) paused at its cap rings its parent as a question does (delegate reports, below): the parent's model gets it as a report when it may answer; otherwise the parent's worker relays it to its user at its next step boundary, or in a wake turn's first boundary when idle (before the model is called), and the report the model gets is the outcome line. While that card is open the parent's turn waits on it; a Stop closes it and leaves the question for your next input (or a `delegate_result`): no wake turn offers that question again meanwhile, while other children's reports and attached-context wakes go on. Under the default `soft` that means a background parent's model now hears its child's cost cap, and may Continue it.
|
|
505
505
|
- **A child's approvals go to the parent's user** (the approval relay), and so do the hook questions its parent can't usefully answer (above: a card headed `delegate ab12 asks: Cost cap`, the child's options and kind, no approval facts). 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.
|
|
@@ -510,7 +510,7 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
|
|
|
510
510
|
- **Delegate reports: a child's reply comes back by itself.** When a child ends a turn that was its parent's (its task, a follow-up, a continue the parent answered, a reminder: not a turn its user typed into it directly), it rings the parent: a small file in the parent's `children/` dir, written after the child's turn is saved. So does a question the parent decides (the child's own `ask_user_question`, the step-limit continue, a hook's question, relayed to the parent's user first when the parent may only deny it; not an approval, which the relay or the user on the child's card answers, nor a card the child relays from its own child), the turn after such a question whoever starts it, and the child's worker crashing. The parent reads the child as `delegate_result` would and hands its model a **report**: the same text (`session:`, `status:`, the reply), under its own header (`[Delegate report, delivered by chi …; not your user's message. Tell your user what it found …]`), as an input message with `source: "delegate_report"`.
|
|
511
511
|
- Parent mid-turn: the report joins the turn at its next iteration boundary (a "delegate report" bubble in the web, `delegate report> ab12cd34 answered: …` in the attached terminal).
|
|
512
512
|
- Parent idle: it runs a turn for the report (a *wake turn*, `last_turn.origin: "delegate_report"`) within the worker's 5 s tick, or wakes a worker for it when the parent's has idle-exited. Several reports that wait together are one turn and one message. A stopped, archived or scratch parent, or one a one-turn `chi --non-interactive` run has, is not woken: the rings wait on disk and join the next turn it runs (the user's message first). An archived parent that a later link continues wakes that link instead, which takes the rings its moved delegates left there (see *Session chains*). A worker just started (`chi send` to a stopped parent) runs no wake turn in its first 2 s, so the message it was started for goes first.
|
|
513
|
-
- The parent's cursor per child (the newest reply it was given and the child as it was then) is in its `delegates.json`, shared with `delegate_result` and `wait: true`: a reply the model already had is never reported again, also after the parent's worker restarts. A ring is deleted only when the turn that took its report stays in the conversation (completed, canceled, or failed but kept: one that got to tool steps, or a reminder or continue turn, which keep what they read); a turn rolled back (a failed one that got nowhere, a canceled continue turn) leaves it for the next one (and the report isn't handed back to the composer). So the model reads each report once, unless a later `!rollback` erases a kept turn (what it read goes with it).
|
|
513
|
+
- The parent's cursor per child (the newest reply it was given and the child as it was then, and the turn waited for: a new child's first turn, by its `first_turn_id`, or the turn a follow-up joined) is in its `delegates.json`, shared with `delegate_result` and `wait: true`: a reply the model already had is never reported again, also after the parent's worker restarts. A ring is deleted only when the turn that took its report stays in the conversation (completed, canceled, or failed but kept: one that got to tool steps, or a reminder or continue turn, which keep what they read); a turn rolled back (a failed one that got nowhere, a canceled continue turn) leaves it for the next one (and the report isn't handed back to the composer). So the model reads each report once, unless a later `!rollback` erases a kept turn (what it read goes with it).
|
|
514
514
|
- Limits: `session.max_wakes` (default `10`) wake turns in a row with no human input; past it the reports wait for the next message and the UIs show `N delegate reports waiting; they join your next message`. A wake turn that fails, or is stopped before the model call (a Stop, a `before_turn` hook's cancel), pauses wakes until the next human input; its reports' rings stay, so they join that input's turn, and a failure's note tells the model so. A continue offer waiting in the parent takes the reports into its continue turn instead.
|
|
515
515
|
- `session.delegate_reports`: `wake` (default), `queue` (no wake turns: reports join the parent's next turn) or `off` (no rings; only `delegate_result` asks, as before). Env `SAMAGOTCHI_SESSION_DELEGATE_REPORTS`, `SAMAGOTCHI_SESSION_MAX_WAKES`.
|
|
516
516
|
- The web's bell rings at a wake turn's end however short (nobody started it, so nobody watched it). The text a model writes about a report mid-turn, before its next tool call, stays visible after the report's bubble once the turn ends.
|
data/docs/sub-agent.md
CHANGED
|
@@ -81,8 +81,15 @@ With `--format json` stdout is one JSON object (see
|
|
|
81
81
|
when the wait is about a known turn, it includes `turn_id`. Use
|
|
82
82
|
`chi send --wait --format json --turn <turn_id> <session_id>` to wait for
|
|
83
83
|
that exact turn again after exit 4, or after exit 3 if someone else will
|
|
84
|
-
answer the question. A turn id can name the running turn, a queued turn
|
|
85
|
-
the last turn that ended
|
|
84
|
+
answer the question. A turn id can name the running turn, a queued turn,
|
|
85
|
+
the last turn that ended, or a message a running turn merged (waited for as
|
|
86
|
+
that turn); any other id is an error. A turn nobody typed (a
|
|
87
|
+
reminder, an attached context change, a finished task, a delegate report: a
|
|
88
|
+
wake turn) has an id its worker made, `wake-` and 16 hex digits, so
|
|
89
|
+
`chi send --wait` with no message names it too and `--turn` waits for it. A
|
|
90
|
+
message merged into a running turn (a steer) gets that turn's reply or end;
|
|
91
|
+
its `turn_id` stays the message's own, and `--turn` with it waits for that
|
|
92
|
+
turn again.
|
|
86
93
|
|
|
87
94
|
```json
|
|
88
95
|
{"status":"question","session_id":"297da360-…","question":{"id":"3f1c…","kind":"question",
|
|
@@ -198,6 +205,14 @@ answers from any local process.
|
|
|
198
205
|
- If the worker dies while a question waits, the question goes with it.
|
|
199
206
|
`chi answer` then exits 1 and says to send the task again
|
|
200
207
|
(`chi send --wait -m "…" ID`); a wait ends with `worker_gone` (exit 1).
|
|
208
|
+
- `worker_gone` follows one rule everywhere (`ReplyWait::GONE_AFTER`): no
|
|
209
|
+
worker has owned the session for 5 s while the wait runs (it died without
|
|
210
|
+
marking the session). A worker chi just started is awaited first (`chi send
|
|
211
|
+
--new`, a `delegate` child), so a slow boot isn't read as a death; a
|
|
212
|
+
delegate child whose worker never starts is reported `worker_gone` ("its
|
|
213
|
+
worker did not start") once the boot limit (30 s) passes. That await is
|
|
214
|
+
part of the `delegate` call's wait: the call's `timeout` bounds it (the
|
|
215
|
+
child then reads `running`) and the parent's Stop ends it.
|
|
201
216
|
- A follow-up (`chi send --wait -m "…" ID`) to a session waiting for an answer
|
|
202
217
|
isn't sent: it exits 1 with the `chi answer` command for the question. The
|
|
203
218
|
step-limit question is the exception: it waits between turns, so a message
|
data/docs/testing.md
CHANGED
|
@@ -75,6 +75,47 @@ spec/engine_turn_golden_spec.rb`, then read `git diff spec/fixtures/turn_goldens
|
|
|
75
75
|
to `tmp/turn_goldens/<golden>.actual` and names the `diff` to run. Changes to the turn path (engine, kernel loop, chat loop,
|
|
76
76
|
tool runner, gate, guardrail wiring, system prompt, tool declarations, registry) pick it in `script/affected-specs`.
|
|
77
77
|
|
|
78
|
+
The model-setting characterizations (`spec/engine_effective_model_characterization_spec.rb`,
|
|
79
|
+
`spec/engine_session_model_settings_characterization_spec.rb`, `spec/web/app_session_settings_characterization_spec.rb`
|
|
80
|
+
over `spec/fixtures/effective_model/`, and `spec/model_setting_precedence_characterization_spec.rb`'s ladders over
|
|
81
|
+
`spec/fixtures/model_setting_precedence/`) pin the settings on the code as it was before the `ModelLookup` refactor
|
|
82
|
+
(`UPDATE_GOLDENS=1` regenerates them after an intended change only). They are retired after their refactor: the
|
|
83
|
+
next round folds the first three into one wiring example each in `effective_model_spec.rb`,
|
|
84
|
+
`session_model_settings_spec.rb` and `session_setting_spec.rb`, and the ladders the round after into the setting
|
|
85
|
+
modules' and `model_lookup_spec.rb`.
|
|
86
|
+
|
|
87
|
+
The session file goldens (`spec/session_file_goldens_spec.rb`, `spec/fixtures/session_goldens/`) pin the bytes `Session#save`
|
|
88
|
+
writes: every field away from its default, a new session, a newer chi's file, an old chi's file in a git repo, every key
|
|
89
|
+
present as `null`, the plain writers' raw values, a running turn's checkpoint and the scrub (the `*_in.json` files are
|
|
90
|
+
hand-written inputs, never regenerated); and the file each path that makes or rewrites a session writes (a fork, a
|
|
91
|
+
continue, a delegate child, a plain spawn, a review, a Verify fresh child, a resume, a respawn, an archive), with
|
|
92
|
+
`carry_matrix.json`, what each keeps of its source. They are pinned before Session's fields became one table and are kept
|
|
93
|
+
as the file format's pins, not retired: a diff is a change to every session file on disk, so only a commit that means to
|
|
94
|
+
change the format regenerates them (`UPDATE_GOLDENS=1 bundle exec rspec spec/session_file_goldens_spec.rb`).
|
|
95
|
+
|
|
96
|
+
`UPDATE_GOLDENS=1` is the characterization specs' variable (the prompt and turn goldens above use `UPDATE_PROMPTS`):
|
|
97
|
+
the model-setting ones above, the session file goldens, claude-peer's goldens and the bundle-state corpus below. Regenerate only for an intended
|
|
98
|
+
change, then read the diff.
|
|
99
|
+
|
|
100
|
+
The bundle-state corpus (`spec/samagotchi/memory_bundle/file_state_corpus/`, `spec/fixtures/bundle_states/`) pins what
|
|
101
|
+
every bundle file state does under every command that reads or writes it. Each case in `memory_cases.yml` (a memory:
|
|
102
|
+
owned, edited, behind, missing, dropped, the user's own, another bundle's too…) and `asset_cases.yml` (a rule, the
|
|
103
|
+
plugin, a script, a hook) is written directly as a starting tree, in the 0.155 and the 0.149 record shape, and run
|
|
104
|
+
through install, `--force`, `--dry-run`, upgrade and its flags, chi update's plan, Remerge, the system bundle's restore,
|
|
105
|
+
uninstall and status. A cell holds only what a user or the next command sees (the bytes, the trash, base and checksum,
|
|
106
|
+
the mark, the summary's word, status's label, the warnings), never the Installer's result strings. `expect/*.yml` are
|
|
107
|
+
generated (`UPDATE_GOLDENS=1 bundle exec rspec spec/samagotchi/memory_bundle/file_state_corpus/`, which also writes
|
|
108
|
+
`tmp/bundle_states/review.<file>.txt`, every cell in words); a mismatch names the case, command and shape and writes
|
|
109
|
+
`tmp/bundle_states/<file>.yml.actual` to diff. A regeneration asserts nothing, so run the folder again without
|
|
110
|
+
`UPDATE_GOLDENS`: that run checks the new cells against `cases_spec.rb`, which holds what every regeneration keeps. No
|
|
111
|
+
user bytes (neither the bundle's text nor what the record vouches for, its checksum first) are changed or removed without
|
|
112
|
+
a copy in the trash first; the two record shapes give one answer except on a marked case (an allowlist entry needs a
|
|
113
|
+
note); and for every asset, chi update's plan lists as replaced exactly the files the upgrade copies to the trash. To stay
|
|
114
|
+
under 3 s, `upgrade_spec.rb` runs the 0.149 shape for marked cases only (`MARKED_ONLY_149`), where the shapes can differ. A regenerated cell is
|
|
115
|
+
reviewed: a refactor that turns the corpus red stops and asks, and only a commit that means to change a cell
|
|
116
|
+
regenerates, its changed cells read one by one; a cell that looks wrong today carries a `divergent:` tag and a note in
|
|
117
|
+
its case.
|
|
118
|
+
|
|
78
119
|
The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_block.js`,
|
|
79
120
|
`turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
|
|
80
121
|
`question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
|
|
@@ -124,7 +165,15 @@ Each level owns its own share; a detail is asserted once, at the lowest level th
|
|
|
124
165
|
- **e2e** is for real-browser behaviour (layout, focus, reload, two tabs), one happy path per Ruby↔web mechanism and
|
|
125
166
|
a few journeys. Before adding a test, extend an existing journey's step; a variant goes to node, a Ruby↔web shape
|
|
126
167
|
variant to a shared fixture (`spec/fixtures/composer_routes.json`). A journey names its stages with `test.step`
|
|
127
|
-
(the report names the one that failed: web.spec.js's notifications).
|
|
168
|
+
(the report names the one that failed: web.spec.js's notifications). A turn test ends with
|
|
169
|
+
`expectSameAfterReload` (spec/e2e/support/history_shape.js): the history's shape live, then after a reload, the
|
|
170
|
+
same (live is the truth). Its allowlist is `history_shape.js ACCEPTED`, each entry with its reason; a test names
|
|
171
|
+
the entries its scenario has (only deliberate ones are left). A drawn turn's variants are node's: the shared
|
|
172
|
+
scenarios in `spec/shared/turn_drawings/` (written from `spec/support/turn_drawing_scenarios.rb`;
|
|
173
|
+
`UPDATE_TURN_DRAWINGS=1 bundle exec rspec spec/bridge/turn_drawings_spec.rb spec/web/session_show_drawings_spec.rb`
|
|
174
|
+
rewrites their lines and show after an intended change) feed `turn_drawing_parity.test.js`, which draws each one
|
|
175
|
+
live (the real stream handlers and stage) and from its drawing and compares the shapes with no allow list. The
|
|
176
|
+
`lan` project's chi web is on the LAN only
|
|
128
177
|
when the machine has a private IPv4: without one its LAN tests skip and the participant review journey runs on
|
|
129
178
|
loopback.
|
|
130
179
|
|
|
@@ -254,6 +303,7 @@ any other file requires it, so a spec file run alone still finds its requires. E
|
|
|
254
303
|
| `lock_holder.rb` | *helper*: `spawn_lock_holder(session_dir)`, a stand-in worker process holding a session's owner lock, which says so on a pipe |
|
|
255
304
|
| `integration_server.rb` | *helper*: the live model server `:integration` examples use, from `SAMAGOTCHI_INTEGRATION_*` ([Integration specs](#integration-specs)) |
|
|
256
305
|
| `mcp_bundle.rb` | The shipped mcp bundle's path and the fake MCP server its specs start (`MCP_FAKE_COMMAND`, no RubyGems) |
|
|
306
|
+
| `model_setting_ladders.rb` | shared context "model setting ladders": per setting a config, env and CLI with every layer set, peeled top down (`peel`), against `spec/fixtures/model_setting_precedence/` |
|
|
257
307
|
| `no_console.rb` | Loaded first (`ruby -r`) by spawned bin/chi children: `IO.console` is nil, so output never wraps at a terminal's width |
|
|
258
308
|
| `recording_surface.rb` | `RecordingSurface`: a `TerminalUI::Surface` that records commits and slots instead of drawing |
|
|
259
309
|
| `remote_backend.rb` | `RemoteBackend`: `SessionAccess::Remote` against chi web's Rack app in this process (no socket, LAN token gate) |
|
|
@@ -334,7 +384,8 @@ files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*
|
|
|
334
384
|
`lib/samagotchi/foo/bar/baz.rb` → `spec/foo/bar_baz_spec.rb`), the specs that read
|
|
335
385
|
docs or source as text (its `RULES`: add one when a full run catches what the names missed) and the golden of a changed
|
|
336
386
|
file's layer (any `foo/*.rb` → `spec/foo_golden_spec.rb` when there is one; `turn_flow.rb` and `turn_note.rb`, whose
|
|
337
|
-
words the attached TUI prints, pick its `event_renderer` and `attached_loop` specs by a rule
|
|
387
|
+
words the attached TUI prints, pick its `event_renderer` and `attached_loop` specs by a rule; `memory_bundle/*.rb`,
|
|
388
|
+
`bundle_command.rb`, `memory_paths.rb`, `model_overlay.rb` and the corpus's fixtures pick the bundle-state corpus). Changes to `tool_declarations.rb` and `system_prompt.rb` also pick the
|
|
338
389
|
effective-model, request, prompt-snapshot and full system-prompt goldens because they hash the request's prompt and
|
|
339
390
|
tools. Then come the other name matches (a spec named after several changed files first, e.g. `engine_toolkit_spec.rb` for `engine.rb` and
|
|
340
391
|
`toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
|
|
@@ -138,7 +138,8 @@ module Samagotchi
|
|
|
138
138
|
end
|
|
139
139
|
# Answered here, or no longer open (answered elsewhere, or a newer
|
|
140
140
|
# question): what comes next either way.
|
|
141
|
-
wait_for_reply(id,
|
|
141
|
+
wait_for_reply(id, target: ReplyWait::Target.answer(start),
|
|
142
|
+
timeout: options[:timeout])
|
|
142
143
|
end
|
|
143
144
|
|
|
144
145
|
# The labels to send. A number is an option's place (1-based); an
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "time"
|
|
6
6
|
require_relative "atomic_file"
|
|
7
|
+
require_relative "session_paths"
|
|
7
8
|
|
|
8
9
|
require_relative "session"
|
|
9
10
|
|
|
@@ -46,7 +47,7 @@ module Samagotchi
|
|
|
46
47
|
|
|
47
48
|
# @return [Boolean] whether it was archived (and is not now)
|
|
48
49
|
def self.unarchive(session_id, state_dir:)
|
|
49
|
-
return false unless archived?(
|
|
50
|
+
return false unless archived?(SessionPaths.session_dir(session_id, state_dir: state_dir))
|
|
50
51
|
|
|
51
52
|
write(session_id, { "unarchived_at" => Time.now.iso8601(3) }, state_dir: state_dir)
|
|
52
53
|
end
|
|
@@ -63,9 +64,9 @@ module Samagotchi
|
|
|
63
64
|
end
|
|
64
65
|
|
|
65
66
|
def self.write(session_id, record, state_dir:)
|
|
66
|
-
return false unless File.exist?(
|
|
67
|
+
return false unless File.exist?(SessionPaths.session_file(session_id, state_dir: state_dir))
|
|
67
68
|
|
|
68
|
-
dir =
|
|
69
|
+
dir = SessionPaths.session_dir(session_id, state_dir: state_dir)
|
|
69
70
|
FileUtils.mkdir_p(dir)
|
|
70
71
|
path = File.join(dir, FILE)
|
|
71
72
|
AtomicFile.write(path, JSON.generate(record))
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "rbconfig"
|
|
4
4
|
require_relative "config"
|
|
5
5
|
require_relative "model_profile"
|
|
6
|
+
require_relative "host_address"
|
|
6
7
|
require_relative "host_registry"
|
|
7
8
|
require_relative "host_keys"
|
|
8
9
|
require_relative "chi_home"
|
|
@@ -342,7 +343,7 @@ module Samagotchi
|
|
|
342
343
|
# The "loading the model?" hint fits a local server (a llama.cpp loading
|
|
343
344
|
# weights); a remote provider's slow answer is the network, not a load.
|
|
344
345
|
def loading_hint?(result)
|
|
345
|
-
!
|
|
346
|
+
!HostAddress.remote_address?(result.candidate.scheme, result.candidate.host)
|
|
346
347
|
end
|
|
347
348
|
|
|
348
349
|
# 0 written, 1 written but not as planned, nil with @exit on a refusal.
|
|
@@ -489,7 +490,7 @@ module Samagotchi
|
|
|
489
490
|
when :skipped then "skipped (#{result.error})"
|
|
490
491
|
else "failed (#{result.error})"
|
|
491
492
|
end
|
|
492
|
-
@stdout.puts("system bundle: #{text}")
|
|
493
|
+
@stdout.puts("system bundle: #{[text, *result.warnings].join("; ")}")
|
|
493
494
|
result.status != :failed
|
|
494
495
|
end
|
|
495
496
|
|
|
@@ -18,6 +18,15 @@ module Samagotchi
|
|
|
18
18
|
# comes after (a prompt's turn_id, a line's opening_id), which the page
|
|
19
19
|
# finds by its bubble, not by counting. Pure: no Engine, no file.
|
|
20
20
|
class CardPlaces
|
|
21
|
+
# An opening's stamp: its turn_id (a prompt's, a wake note's), else its
|
|
22
|
+
# own opening_id (/continue no's reason line); nil for neither (a line
|
|
23
|
+
# an older chi wrote). The recap's too (RecapPlace).
|
|
24
|
+
# @return [String, nil]
|
|
25
|
+
def self.stamp(message)
|
|
26
|
+
id = message[:turn_id] || message["turn_id"] || message[:opening_id] || message["opening_id"]
|
|
27
|
+
id.is_a?(String) && !id.empty? ? id : nil
|
|
28
|
+
end
|
|
29
|
+
|
|
21
30
|
# @param turn_log [Array<String>] the turn ids, oldest first
|
|
22
31
|
# @param messages [Array<Hash>] the messages the page draws
|
|
23
32
|
def initialize(turn_log, messages)
|
|
@@ -114,10 +123,7 @@ module Samagotchi
|
|
|
114
123
|
position >= 0 ? @last_at_or_before[position] : nil
|
|
115
124
|
end
|
|
116
125
|
|
|
117
|
-
def stamp_of(message)
|
|
118
|
-
id = message[:turn_id] || message["turn_id"] || message[:opening_id] || message["opening_id"]
|
|
119
|
-
id.is_a?(String) && !id.empty? ? id : nil
|
|
120
|
-
end
|
|
126
|
+
def stamp_of(message) = self.class.stamp(message)
|
|
121
127
|
|
|
122
128
|
def idless_run(opening)
|
|
123
129
|
@ids.drop(opening + 1).take_while(&:nil?).size
|
|
@@ -147,6 +147,9 @@ module Samagotchi
|
|
|
147
147
|
stamp = places&.opening_of(entry)
|
|
148
148
|
out[:opening_id] = stamp if stamp
|
|
149
149
|
out[:current] = current
|
|
150
|
+
# Whose row it is (its turn's id, +of_turn+): the page leaves a drawn
|
|
151
|
+
# turn's own rows and cards to its drawing (Bridge::TurnDrawings).
|
|
152
|
+
out[:of_turn] = entry[:turn_id] if shown[:in_turn] && entry[:turn_id]
|
|
150
153
|
out[:during] = true if entry[:during]
|
|
151
154
|
out[:after_failed] = true if entry[:after_failed] && entry[:turn_id] == @failed_turn
|
|
152
155
|
out[:label] = "from #{entry[:command]}" if entry[:command]
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../events"
|
|
4
4
|
require_relative "../kernel_loop"
|
|
5
|
+
require_relative "../log"
|
|
5
6
|
require_relative "../steer"
|
|
6
7
|
require_relative "../tool_output_cut"
|
|
7
8
|
require_relative "../tool_row_fields"
|
|
@@ -23,10 +24,17 @@ module Samagotchi
|
|
|
23
24
|
# its head and tail: a reload's cap, so a joined row is the row a
|
|
24
25
|
# reload draws
|
|
25
26
|
# @param clock [#call] monotonic seconds: a tool call's duration
|
|
27
|
+
# @param on_turn_end [#call, nil] (turn, end_event): the turn's final
|
|
28
|
+
# fold (a deep copy, #current_turn's shape without elapsed_ms) and its
|
|
29
|
+
# end event, at the end event, before the turn is cleared (the Bridge's
|
|
30
|
+
# TurnDrawings#append). It runs under this accumulator's mutex and the
|
|
31
|
+
# SessionObserver's lock: it must never call back in here. An error is
|
|
32
|
+
# logged and the fold goes on.
|
|
26
33
|
def initialize(max_output_chars: ToolOutputCut::WEB_PREVIEW_CHARS,
|
|
27
|
-
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
34
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, on_turn_end: nil)
|
|
28
35
|
@max_output_chars = max_output_chars
|
|
29
36
|
@clock = clock
|
|
37
|
+
@on_turn_end = on_turn_end
|
|
30
38
|
@mutex = Mutex.new
|
|
31
39
|
@turn = nil
|
|
32
40
|
# When each running tool call started, by [iteration, call_index].
|
|
@@ -47,13 +55,14 @@ module Samagotchi
|
|
|
47
55
|
|
|
48
56
|
# @return [Hash, nil] a copy of the turn in progress: prompt, turn_id, origin,
|
|
49
57
|
# continue, at (its wall time), ordered parts (generation / thinking / text / tool /
|
|
50
|
-
# input / steer / reminder / notice), pending_question and the last event_seq folded
|
|
58
|
+
# input / steer / reminder / notice / card), pending_question and the last event_seq folded
|
|
51
59
|
# in. A finished tool part keeps the live row's action and duration_ms
|
|
52
60
|
# too, a running one the time it has run so far (elapsed_ms), so a
|
|
53
61
|
# join times its row from its start. A notice part holds the event
|
|
54
62
|
# of one of the turn's rows (TurnNotice: a hook's notice, an
|
|
55
63
|
# empty-answer retry, a question and its answer), which a UI replays
|
|
56
|
-
# through its live handler.
|
|
64
|
+
# through its live handler. A card part is a card the turn showed in
|
|
65
|
+
# its step (in_turn), its latest version in the place of its first.
|
|
57
66
|
def current_turn
|
|
58
67
|
@mutex.synchronize do
|
|
59
68
|
next nil unless @turn
|
|
@@ -132,10 +141,13 @@ module Samagotchi
|
|
|
132
141
|
# the live activity: action, tool, params, status), a merged_input
|
|
133
142
|
# (the one synthetic type) per merged prompt, a steer-only
|
|
134
143
|
# pending_input_merged per steer, a reminder_injected, and a notice
|
|
135
|
-
# part's own event.
|
|
144
|
+
# part's own event. Card parts only with +cards+ (a finished turn's
|
|
145
|
+
# drawing, chi web's unfold of it): a running turn's join gets its cards
|
|
146
|
+
# from CardStore's snapshot.
|
|
136
147
|
# @param started_at [String, nil] the turn's start, on turn_started
|
|
148
|
+
# @param cards [Boolean] replay card parts as their card events
|
|
137
149
|
# @return [Array<Hash>] symbol keys and types; [] with no turn
|
|
138
|
-
def self.replay_events(turn, started_at: nil)
|
|
150
|
+
def self.replay_events(turn, started_at: nil, cards: false)
|
|
139
151
|
return [] unless turn
|
|
140
152
|
|
|
141
153
|
started = { type: :turn_started, prompt: turn[:prompt], origin: turn[:origin], continue: !!turn[:continue] }
|
|
@@ -175,14 +187,66 @@ module Samagotchi
|
|
|
175
187
|
when "steer"
|
|
176
188
|
close_text.call
|
|
177
189
|
events << { type: :pending_input_merged, count: 0, content: nil, steers: [{ source: part[:source], text: part[:text] }] }
|
|
190
|
+
when "card"
|
|
191
|
+
next unless cards && part[:event].is_a?(Hash)
|
|
192
|
+
|
|
193
|
+
close_text.call
|
|
194
|
+
events << part[:event].merge(type: :card, in_turn: true)
|
|
178
195
|
end
|
|
179
196
|
end
|
|
180
197
|
events
|
|
181
198
|
end
|
|
182
199
|
|
|
200
|
+
# The page's unfold of a session view (chi web's show sends it as
|
|
201
|
+
# `replay`; turn_events.js snapshotEvents was its JS twin): the turn in
|
|
202
|
+
# progress as #replay_events has it, its turn_started with what the
|
|
203
|
+
# page also reads (its wall time `at`, the run's `cut_after`, and every
|
|
204
|
+
# author key, a pair turn's participant_mode too), then a turn_enqueued
|
|
205
|
+
# per prompt queued behind it and a command_queued per command waiting
|
|
206
|
+
# for its end (spec/shared/turn_snapshot.json pins both sides). The TUI
|
|
207
|
+
# keeps #replay_events.
|
|
208
|
+
# @param current_turn [Hash, nil] #current_turn's shape, symbol keys
|
|
209
|
+
# (a snapshot's: .from_wire)
|
|
210
|
+
# @param queued [Array<Hash>] #queued's entries
|
|
211
|
+
# @param queued_commands [Array<Hash>] #queued_commands' entries
|
|
212
|
+
# @return [Array<Hash>] symbol keys and types
|
|
213
|
+
def self.join_events(current_turn:, queued: [], queued_commands: [], started_at: nil)
|
|
214
|
+
events = replay_events(current_turn, started_at: started_at)
|
|
215
|
+
if (started = events.first)
|
|
216
|
+
started[:at] = current_turn[:at] if current_turn[:at].is_a?(String)
|
|
217
|
+
started[:cut_after] = current_turn[:cut_after] unless current_turn[:cut_after].nil?
|
|
218
|
+
Steer::AUTHOR_KEYS.each { |key| started.delete(key) }
|
|
219
|
+
Steer::AUTHOR_KEYS.each { |key| started[key] = current_turn[key] if current_turn[key] } if current_turn[:author]
|
|
220
|
+
end
|
|
221
|
+
Array(queued).each do |entry|
|
|
222
|
+
enqueued = { type: :turn_enqueued, enqueued_id: entry[:enqueued_id], client_id: entry[:client_id], prompt: entry[:prompt] }
|
|
223
|
+
enqueued[:delivery] = entry[:delivery] if entry[:delivery]
|
|
224
|
+
enqueued[:author] = entry[:author] if entry[:author]
|
|
225
|
+
events << with_images(enqueued, entry[:images])
|
|
226
|
+
end
|
|
227
|
+
Array(queued_commands).each do |entry|
|
|
228
|
+
command = { type: :command_queued, command_id: entry[:command_id], client_id: entry[:client_id], line: entry[:line],
|
|
229
|
+
waits: "turn_end" }
|
|
230
|
+
command[:author] = entry[:author] if entry[:author]
|
|
231
|
+
events << command
|
|
232
|
+
end
|
|
233
|
+
events
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# A Bridge snapshot's part (JSON: string keys) as the accumulator's own
|
|
237
|
+
# shapes: symbol keys all the way down, values as they are.
|
|
238
|
+
def self.from_wire(value)
|
|
239
|
+
case value
|
|
240
|
+
when Hash then value.to_h { |key, item| [key.is_a?(String) ? key.to_sym : key, from_wire(item)] }
|
|
241
|
+
when Array then value.map { |item| from_wire(item) }
|
|
242
|
+
else value
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
183
246
|
def self.tool_replay_events(part)
|
|
184
247
|
call = { iteration: part[:iteration], call_index: part[:call_index], tool: part[:tool] }
|
|
185
248
|
call[:label] = part[:label] if part[:label]
|
|
249
|
+
call[:tool_id] = part[:tool_id] if part[:tool_id]
|
|
186
250
|
# Its row fields (ToolRowFields), each in its place: title and, for
|
|
187
251
|
# an alias's call, the name the model used, also in the activity.
|
|
188
252
|
title = part.slice(*ToolRowFields::ACTIVITY_KEYS).compact
|
|
@@ -296,6 +360,7 @@ module Samagotchi
|
|
|
296
360
|
|
|
297
361
|
fold_turn_event(event)
|
|
298
362
|
if Events::TURN_END.include?(type)
|
|
363
|
+
turn_ended(event)
|
|
299
364
|
@turn = nil
|
|
300
365
|
@merged_origins = []
|
|
301
366
|
@working_set = nil
|
|
@@ -326,6 +391,8 @@ module Samagotchi
|
|
|
326
391
|
part = { kind: "tool", iteration: event[:iteration], call_index: event[:call_index],
|
|
327
392
|
tool: event[:tool], params: event[:params], status: "running" }
|
|
328
393
|
part[:label] = event[:label] if event[:label]
|
|
394
|
+
# The id its output is saved under (ToolIds; not a row field).
|
|
395
|
+
part[:tool_id] = event[:tool_id] if event[:tool_id]
|
|
329
396
|
ToolRowFields::KEYS.each { |key| part[key] = event[key] if event[key] }
|
|
330
397
|
parts << part
|
|
331
398
|
@tool_started_at[[event[:iteration], event[:call_index]]] = @clock.call
|
|
@@ -342,16 +409,26 @@ module Samagotchi
|
|
|
342
409
|
# Only System 1's auto-allow is drawn on the row (the web's label).
|
|
343
410
|
guardrail = event.dig(:activity, :guardrail)
|
|
344
411
|
tool[:guardrail] = guardrail if guardrail.is_a?(Hash) && guardrail[:decided_by] == "system one"
|
|
345
|
-
|
|
412
|
+
# The event's web preview (ToolRunner: the reload's cut) when it
|
|
413
|
+
# has one; an older worker's event is cut here the same way.
|
|
414
|
+
tool[:output] = if event[:preview] then event[:preview].to_s.dup
|
|
415
|
+
elsif capped then ToolOutputCut.preview(output, cap: @max_output_chars, tool: event[:tool])
|
|
416
|
+
else output.dup
|
|
417
|
+
end
|
|
346
418
|
tool[:output_truncated] = capped || !!event[:output_truncated]
|
|
347
419
|
tool[:images] = event[:images] if event[:images]
|
|
348
420
|
tool[:diff] = event[:diff] if event[:diff]
|
|
349
421
|
action = event.dig(:activity, :action)
|
|
350
422
|
tool[:action] = action.to_s if action
|
|
351
|
-
#
|
|
352
|
-
#
|
|
423
|
+
# The event's (ToolRunner's: the figure every UI shows); without
|
|
424
|
+
# one (an older worker's event), as the live row times it: from
|
|
425
|
+
# tool_call_started, less an approval wait.
|
|
353
426
|
started_at = @tool_started_at.delete([event[:iteration], event[:call_index]])
|
|
354
|
-
|
|
427
|
+
if event[:duration_ms].is_a?(Integer)
|
|
428
|
+
tool[:duration_ms] = event[:duration_ms]
|
|
429
|
+
elsif started_at
|
|
430
|
+
tool[:duration_ms] = [((@clock.call - started_at) * 1000) - event[:waited_ms].to_f, 0].max.round
|
|
431
|
+
end
|
|
355
432
|
when :pending_input_merged
|
|
356
433
|
# A steer-only merge (count 0) has no user part: the origins stay
|
|
357
434
|
# for the user lines' own merge.
|
|
@@ -366,6 +443,8 @@ module Samagotchi
|
|
|
366
443
|
end
|
|
367
444
|
when :working_set_updated
|
|
368
445
|
@working_set = { items: event[:items], at: event[:at] }
|
|
446
|
+
when :card
|
|
447
|
+
fold_card(parts, event) if event[:in_turn]
|
|
369
448
|
when :reminder_injected
|
|
370
449
|
parts << { kind: "reminder", reminders: Array(event[:reminders]).map(&:dup) }
|
|
371
450
|
when :question_requested
|
|
@@ -382,6 +461,27 @@ module Samagotchi
|
|
|
382
461
|
parts << { kind: "notice", event: TurnNotice.slice(event) } if TurnNotice.notice?(event)
|
|
383
462
|
end
|
|
384
463
|
|
|
464
|
+
# The card fields a UI draws (CardStore keeps the same), a copy.
|
|
465
|
+
CARD_FIELDS = %i[id source title body level actions].freeze
|
|
466
|
+
private_constant :CARD_FIELDS
|
|
467
|
+
|
|
468
|
+
# A card the turn shows in its step; one with an earlier card's id
|
|
469
|
+
# replaces that part where it was.
|
|
470
|
+
def fold_card(parts, event)
|
|
471
|
+
card = { kind: "card", id: event[:id], event: Marshal.load(Marshal.dump(event.slice(*CARD_FIELDS))) }
|
|
472
|
+
index = parts.index { |part| part[:kind] == "card" && part[:id] == card[:id] }
|
|
473
|
+
index ? parts[index] = card : parts << card
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# The turn's final fold to the hook (#initialize's on_turn_end).
|
|
477
|
+
def turn_ended(event)
|
|
478
|
+
return unless @on_turn_end
|
|
479
|
+
|
|
480
|
+
@on_turn_end.call(Marshal.load(Marshal.dump(@turn)), event)
|
|
481
|
+
rescue StandardError => e
|
|
482
|
+
Log.warn(:bridge, "turn_end_hook_failed", error: "#{e.class}: #{e.message}")
|
|
483
|
+
end
|
|
484
|
+
|
|
385
485
|
def append_text(iteration, kind, text)
|
|
386
486
|
return if text.nil? || text.empty?
|
|
387
487
|
|