samagotchi 0.141.0 → 0.142.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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/README.md +1 -1
  4. data/bin/chi +2 -0
  5. data/docs/architecture.md +12 -283
  6. data/docs/bundle-authoring.md +1 -0
  7. data/docs/cli.md +15 -7
  8. data/docs/configuration.md +57 -18
  9. data/docs/context.md +2 -2
  10. data/docs/guardrails.md +20 -11
  11. data/docs/hooks.md +9 -7
  12. data/docs/internals/attached-context.md +226 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bridge.md +380 -0
  15. data/docs/internals/bundles.md +1 -0
  16. data/docs/internals/context-telemetry.md +1 -1
  17. data/docs/internals/engine.md +24 -18
  18. data/docs/internals/participants.md +13 -12
  19. data/docs/internals/remote-sessions.md +1 -1
  20. data/docs/internals/{tool-guardrails.md → tool-output-limits.md} +21 -2
  21. data/docs/memory.md +31 -1
  22. data/docs/plugins.md +122 -1
  23. data/docs/sessions.md +50 -26
  24. data/docs/system-one.md +122 -0
  25. data/docs/testing.md +3 -1
  26. data/lib/samagotchi/bridge.rb +2 -0
  27. data/lib/samagotchi/bundles/check-in/manifest.yml +6 -3
  28. data/lib/samagotchi/bundles/check-in/plugin.rb +101 -4
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  32. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/config.rb +74 -14
  35. data/lib/samagotchi/context_candidate_choice.rb +8 -0
  36. data/lib/samagotchi/context_candidates_log.rb +25 -1
  37. data/lib/samagotchi/context_judge.rb +6 -2
  38. data/lib/samagotchi/engine.rb +68 -7
  39. data/lib/samagotchi/extension_load.rb +3 -2
  40. data/lib/samagotchi/guardrail_wiring.rb +36 -31
  41. data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
  42. data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
  43. data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
  44. data/lib/samagotchi/guardrails_command.rb +3 -3
  45. data/lib/samagotchi/judgments_command.rb +155 -0
  46. data/lib/samagotchi/kernel_loop.rb +13 -1
  47. data/lib/samagotchi/llm/chat_loop.rb +74 -10
  48. data/lib/samagotchi/llm/errors.rb +7 -5
  49. data/lib/samagotchi/llm/stray_tool_call.rb +61 -0
  50. data/lib/samagotchi/log_line.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
  52. data/lib/samagotchi/plugin/api.rb +33 -2
  53. data/lib/samagotchi/plugin/context.rb +49 -1
  54. data/lib/samagotchi/plugin/judgments.rb +72 -0
  55. data/lib/samagotchi/plugin/loader.rb +3 -2
  56. data/lib/samagotchi/run_options.rb +1 -0
  57. data/lib/samagotchi/self_report.rb +36 -0
  58. data/lib/samagotchi/session_access/contract.rb +1 -1
  59. data/lib/samagotchi/session_commands.rb +3 -0
  60. data/lib/samagotchi/session_manager.rb +13 -2
  61. data/lib/samagotchi/session_retention.rb +10 -0
  62. data/lib/samagotchi/side_judgments.rb +12 -1
  63. data/lib/samagotchi/system_one/answer.rb +63 -11
  64. data/lib/samagotchi/system_one/approval_label.rb +56 -0
  65. data/lib/samagotchi/system_one/approval_question.rb +2 -0
  66. data/lib/samagotchi/system_one/client.rb +43 -17
  67. data/lib/samagotchi/system_one/fake_backend.rb +1 -1
  68. data/lib/samagotchi/system_one/http_backend.rb +12 -4
  69. data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
  70. data/lib/samagotchi/system_one/judgments.rb +260 -0
  71. data/lib/samagotchi/system_one/log.rb +4 -2
  72. data/lib/samagotchi/system_one/recording.rb +117 -0
  73. data/lib/samagotchi/system_one/runs.rb +179 -0
  74. data/lib/samagotchi/system_one/runs_store.rb +230 -0
  75. data/lib/samagotchi/system_one.rb +9 -0
  76. data/lib/samagotchi/testing/bundle.rb +9 -2
  77. data/lib/samagotchi/testing/host.rb +2 -1
  78. data/lib/samagotchi/testing/judgments.rb +185 -0
  79. data/lib/samagotchi/testing/recorder.rb +5 -0
  80. data/lib/samagotchi/testing/recording_api.rb +1 -1
  81. data/lib/samagotchi/tool_output_cut.rb +26 -6
  82. data/lib/samagotchi/tool_response.rb +1 -1
  83. data/lib/samagotchi/tool_runner.rb +63 -4
  84. data/lib/samagotchi/toolkit_suggest.rb +11 -2
  85. data/lib/samagotchi/tools/memory.rb +28 -0
  86. data/lib/samagotchi/turn_note.rb +9 -0
  87. data/lib/samagotchi/version.rb +1 -1
  88. metadata +16 -2
@@ -0,0 +1,380 @@
1
+ # The Bridge
2
+
3
+ The Bridge (`lib/samagotchi/bridge.rb`, `Samagotchi::Bridge`) is a session worker's only live client transport: a
4
+ localhost HTTP server in the worker's process, next to the `Engine` it reads and drives. chi web and the attached
5
+ terminal are its clients; no browser reaches it. [architecture.md](../architecture.md) places it among the layers;
6
+ this page is the surface: what it keeps, how a client joins and catches up, and every route.
7
+
8
+ ## What a Bridge keeps
9
+
10
+ `Bridge#start` binds a port (OS-assigned, `127.0.0.1`), then subscribes four persistent observers to the Engine
11
+ (`Engine#subscribe`, the thread-safe fan-out whose events all carry a monotonic `event_seq`). Each is cheap and
12
+ rescues its own errors: an observer must never break the running turn.
13
+
14
+ | Piece | File (`bridge/`) | Holds |
15
+ |-------|------------------|-------|
16
+ | `RingBuffer` | `ring_buffer.rb` | The last `ring_capacity` (256) raw events by `event_seq`, in memory, for a reconnect's replay. The capture observer feeds it. |
17
+ | `TurnAccumulator` | `turn_accumulator.rb` | The turn in progress, folded from events. |
18
+ | `CardStore` | `card_store.rb` | The last cards and notices, saved in `cards.json`. |
19
+ | `PendingCard` | `pending_card.rb` | `pending_card.json` `{id:, bundle:}`: the running turn's open card with actions (check-in's Nudge), written and removed with that card, so chi web's hub, which watches files and not workers, can notify. |
20
+
21
+ Per connection there is an `SSEWriter` (with a `BoundedQueue`); `ClientEvent` and `EventId` are pure helpers. The
22
+ Bridge also owns its **epoch** (`SecureRandom.hex(4)`: `event_seq` starts over in each worker, so the epoch says
23
+ whose seq a cursor is) and its **secret**; both are published in `bridge.json` (`WorkerSidecar`) with the port.
24
+
25
+ ## TurnAccumulator
26
+
27
+ A joining UI must draw a turn already half done, and the ring may have dropped its early events. The accumulator
28
+ folds the event log into one hash, `current_turn`: the prompt, `turn_id`, origin, `at`, and ordered `parts`
29
+ (generation, thinking, text, tool, input, steer, reminder, notice), plus `pending_question`. A tool part keeps its
30
+ row fields (`ToolRowFields`), its output cut head-and-tail at `ToolOutputCut::WEB_PREVIEW_CHARS` so a joined row is the
31
+ row a reload draws, and `elapsed_ms` while it runs (the accumulator times running calls itself, `clock:`). A
32
+ `notice` part holds the event of a row that is neither text nor a tool call (`TurnNotice::FIELDS`: a hook's notice,
33
+ the "asking again" row, a steer's cut, a ✂ row, a question and its answer), so a join replays it through the handler
34
+ that drew it live.
35
+
36
+ The turn is cleared on its terminal event (`Events::TURN_END`), the same event that brings its messages into the
37
+ session, so a snapshot shows a turn in exactly one of the two places. Beside the turn it keeps what a snapshot also
38
+ needs and the messages lag: `queued` turns (`:turn_enqueued` until started, merged or dequeued), `queued_commands`,
39
+ the idle `recap` (until a turn starts), a pending `continue_offer`, and the running turn's `working_set`.
40
+
41
+ Three other users: `replay_events(turn)` turns a `current_turn` back into the live events that would have drawn it
42
+ (the web's `snapshotEvents` replays them; `spec/shared/turn_snapshot.json` pins both sides); `messages_of` gives a
43
+ plugin's `ctx.messages` the turn so far (`Engine#running_turn_messages`); `participant_turn?` tells `POST /turn` that
44
+ a participant's review turn runs, so the owner's input queues behind it.
45
+
46
+ ## CardStore
47
+
48
+ The cards a plugin or the Engine shows (`Engine#show_card`), hook notices, load warnings (`:guardrail_warning`),
49
+ the "asking again" and steer-cut rows, ✂ rows and questions (with how they were answered), for a UI that joins later:
50
+ `snapshot[:cards]` and `GET /tail`'s `cards`.
51
+
52
+ - **Placement is relative.** An entry carries `turns` and lists with `turns_since` (turns completed after it) and
53
+ `current` (a card of the running turn). A card that comes mid-turn but isn't the turn's (an anytime command's, such as
54
+ `/btw`) goes after that turn (`during`). A card with an earlier card's id replaces it in place.
55
+ - **Caps.** `CAPACITY` (20) each for notices, ✂ rows (`LLM_CONTEXT_TYPES`) and cards, so one burst cannot push out
56
+ another kind. An open question (an approval too) is never pushed out.
57
+ - **Saved.** With a `path` (`cards.json` in the session's folder) the store rewrites the file after each event in
58
+ `SAVED_ON`, and a later worker's store starts from it, so a turn's cards outlive the worker; `CardStore.saved(dir)` is
59
+ what chi web reads for a session no worker runs. Load warnings are the worker's own (each start announces them)
60
+ and an unanswered question waits for no one: neither is saved or seeded. An entry from an earlier worker lists with
61
+ `earlier: true`; one shown during a turn comes back **without its actions** (their turn no longer runs), while a
62
+ between-turns card keeps its buttons.
63
+
64
+ ## SSEWriter
65
+
66
+ One per `GET /session/:id/stream` connection, and itself that connection's observer: `Engine#emit_event` calls
67
+ `#call` on the turn's thread, so it only pushes onto a `BoundedQueue` (when full, the oldest entry goes and an
68
+ overflow flag is set). The connection's own thread (`#serve!`) drains the queue onto the socket, so a slow client
69
+ cannot stall a turn or another subscriber. `serve!` orders a join so that nothing is missed or doubled:
70
+
71
+ 1. subscribe the live queue first;
72
+ 2. capture `event_count` (the snapshot seq);
73
+ 3. replay the ring's events in `(from_seq, snapshot]`;
74
+ 4. drain the queue for `seq > snapshot`; a per-connection high-water mark drops what step 3 already wrote.
75
+
76
+ `?snapshot=1` replaces 2-3: subscribe and `Bridge#snapshot_frame` happen as one step with the event log held
77
+ (`Engine#synchronize_events`), the client gets one `snapshot` frame, and live events follow from the next seq.
78
+
79
+ A **`reset`** frame (the same content, numbered with its own seq) is written when a cursor cannot be replayed: it is
80
+ from another epoch, ahead of the engine (a restarted worker), behind the ring's window (a cursor at `oldest - 1` has
81
+ seen everything before the window and replays), or the queue overflowed since the last drain. A cold connect replays
82
+ the whole window. Replay state is in memory per worker; there is no durable resume.
83
+
84
+ Each frame is `id: <seq>-<epoch>` (`EventId`: the seq first so a reader that takes it as a number still gets the
85
+ seq), `event: <type>`, `data: <json>`. A `: ping` goes out after `heartbeat_interval` (15 s) of silence. A watcher
86
+ thread reads the socket for EOF so a hang-up is noticed at once, not at the next write, and `Bridge#open_streams_except`
87
+ (used by `POST /exit`) stops counting that stream.
88
+
89
+ **ClientEvent.** Every event is projected on its way out, live and replayed alike (`ClientEvent.project`, pure: the
90
+ event hash is shared by every observer and is never mutated). `generation_chunk` loses `payload` and `content` and
91
+ gains `counts`; `tool_call_started` loses `call`; `turn_completed` loses `result`; everything else, `event_seq`
92
+ included, passes through. `?raw=1` skips the projection (debugging). Add a field to the wire only here, when a client
93
+ needs it.
94
+
95
+ ## Routes and reads
96
+
97
+ The route list, the wire shapes, the browser/proxy refusals and the secret, as the Bridge answers them. Every route
98
+ goes through `Bridge#dispatch`.
99
+
100
+ The Bridge exposes:
101
+
102
+
103
+ - `GET /session/:id/stream` — SSE stream of engine + kernel events, each with an
104
+ `id: <event_seq>-<epoch>` cursor (the epoch is drawn per worker's Bridge, since `event_seq` starts
105
+ over in each worker; snapshots and `/state` carry it as `event_id`); resume via `Last-Event-ID` /
106
+ `?from_seq=` (a plain `event_seq` is still accepted); a `: ping` heartbeat keeps idle proxies alive;
107
+ too-old reconnects, and cursors from another worker's epoch, receive a `reset` marker carrying
108
+ `session_state_snapshot`. `?snapshot=1` joins with a snapshot frame instead of a replay;
109
+ `?client_id=` names whose stream it is (`Bridge#open_streams_except`, used by `POST /exit`).
110
+ Each event goes out as clients get it (`Bridge::ClientEvent.project`, in `SSEWriter#write_event`, live
111
+ and replayed alike); `generation_chunk`, `tool_call_started` and `turn_completed` are projected to the
112
+ contracts below. The ring retains raw events for replay and `?raw=1`.
113
+ `?raw=1` writes events as the engine emitted them, for debugging on the worker's port: `chi web`'s
114
+ `/api/sessions/:id/stream` never passes it on (`Web::App#stream_query`).
115
+ An attached terminal's stream (`EventStream` with `rediscover:`) looks for the session's live
116
+ worker whenever it drops: a new one (a restart) is followed from its snapshot, and the terminal
117
+ sends its requests there (`worker_changed`); it gives up after ~12 s, or at once when the session
118
+ was stopped.
119
+
120
+ The wire chunk (the contract; the engine's own event keeps the provider's `payload` and the raw `content`):
121
+
122
+ ```
123
+ event: generation_chunk
124
+ data: {"type":"generation_chunk","iteration":2,"text":"…","thinking":"",
125
+ "tool_call":true, // only when set
126
+ "counts":{"prompt_tokens":489,"completion_tokens":12,
127
+ "total_tokens":501,"context_window_tokens":32768}, // only when the server reported counts
128
+ "event_seq":812}
129
+ ```
130
+
131
+ A `tool_call_started` frame keeps its row fields but omits the model's `call`:
132
+
133
+ ```
134
+ event: tool_call_started
135
+ data: {"type":"tool_call_started","iteration":2,"call_index":1,"tool":"write",
136
+ "params":"path=notes.txt","event_seq":812}
137
+ ```
138
+
139
+ A `turn_completed` frame omits `result`; `turn_summary.output` carries the answer:
140
+
141
+ ```
142
+ event: turn_completed
143
+ data: {"type":"turn_completed","turn_summary":{"output":"Done."},
144
+ "display_pending":false,"event_seq":813}
145
+ ```
146
+
147
+ `counts` is `ContextUsage.normalize` of the provider's payload without `ctx_pct` (normalize computes it
148
+ without the session's LLM context budget; a client counts `total_tokens` against the window or budget, as
149
+ `ContextStatus#generation_done` does for the TUI) and with its nil keys left out: cumulative for the
150
+ request, a missing key means unknown (never 0), `prompt_tokens` includes cached tokens, and
151
+ `context_window_tokens` is there only when the payload reported a window itself (llama.cpp's `/completion`
152
+ doesn't: the client uses `generation_started`'s). Unknown provider fields never cross; a field is added here
153
+ when a client needs it, never as a catch-all bag. Details: [internals/context-telemetry.md](context-telemetry.md).
154
+ - `POST /session/:id/turn` — fire-and-forget turn creation; returns `202` with an `enqueued_id`
155
+ (delivery is at-least-once via the worker's file-IPC input path — it never calls `run_turn`
156
+ across the HTTP boundary). Only the bridge's own session: another id, in the path or the body's
157
+ `session_id`, is `404 unknown_session`. Inspect results through the read surface, not the turn response.
158
+ An optional `deadline` (epoch seconds; `BridgeClient` sends 5/6 of its read timeout ahead) makes
159
+ a request read after it (a worker frozen by sleep or SIGSTOP) answer `408 deadline_passed` and
160
+ not run: a client that timed out has said the message was not sent. `/answer`,
161
+ `/question/dismiss`, `/command`, `/turn/dequeue` and `/exit` take the same `deadline` (a command is
162
+ checked with the event log held, as a turn is), and the Bridge logs `turn_expired`, `answer_expired`,
163
+ `dismiss_expired`, `command_expired`, `dequeue_expired` or `exit_expired`. The web app answers either
164
+ kind of timeout with `504 worker_timeout` ("… so the command was not run"). `/cancel` and `/recap` take none.
165
+ The web relays answer, dismiss, command and cancel the same way (`App#relay`): a 408 or a read
166
+ timeout is `504 worker_timeout`; a Bridge 404 for a route an older worker lacks is
167
+ `501 not_supported` with the restart hint; a Bridge `404 unknown_session`, no bridge or a
168
+ refused connection is `503 not_live`.
169
+ - `POST /session/:id/cancel` — cancel the running turn; `202`, or `409` when none runs.
170
+ - `POST /session/:id/recap` — `/recap` in an attached TUI: `200 {enabled, saved, request,
171
+ min_user_turns}` (`{enabled: false, session_off}` without recaps; `session_off` when the session's own
172
+ `/recap off` turned it off); a new recap is asked for at once and
173
+ arrives as `:recap_ready`. Answers mid-turn too.
174
+ - `POST /session/:id/answer` — answer the pending question; `200`, `409` when another client
175
+ answered first or it is gone, `400` for an invalid selection. With `client_id: "cli:answer"`
176
+ (`chi answer`, a parent agent) the answer is checked by `Guardrails::ParentPolicy` in
177
+ `QuestionDesk#answer`: the question carries each option's effect (`effects`, set by its asker:
178
+ `Approval.payload`, `ContinueOffer`, `Relay.card`; derived for an untagged one) and `user_only`,
179
+ and an option beyond the worker's `guardrails.parent_answers` (tightened by the session's own
180
+ `parent_answers`: `ParentPolicy.for_session`) is `403 parent_answer_refused` (with its `reason`),
181
+ the question still open. `chi answer` makes the same check first, from the session file.
182
+ - `POST /session/:id/question/dismiss` — leave the question unanswered (an approval: denied);
183
+ `200`, or `409` when it is no longer pending.
184
+ - `POST /session/:id/tasks/stop` — `{task_id}`: stop a background task as the user
185
+ (`TaskRuntime.stop_task by: "user"`, the web's stop-task button on a running `task_wait`;
186
+ web route `POST /api/sessions/:id/tasks/:task_id/stop`). Only a task this conversation's
187
+ `task_create` started, or an `execute` handed over as a task at its limit (`TaskRuntime.created_ids_in` on the
188
+ saved messages, or the running turn's accumulator); answers once the
189
+ task is gone: `200 {status, stop_reason, task_id}`, `404 task_not_found`, `409 not_running`.
190
+ The worker names it in its sidecar features (`task_stop`).
191
+ - `POST /session/:id/turn/dequeue` — `{enqueued_id, client_id?, deadline?}`: take back a queued message
192
+ (one that waits for the running turn's end: `--queue`, Alt+Enter, `/queue TEXT`, or a message
193
+ with images) before a turn takes it (web route `POST /api/sessions/:id/turn/:enqueued_id/dequeue`,
194
+ the × on a queued bubble; `/unqueue` in the attached TUI). Under the event log, like its enqueue:
195
+ `SessionInbox.take_queued_input` writes a tombstone (`input/removed/<enqueued_id>`, for `ReplyWait`'s
196
+ `turn:`), claims the file as the drain does (one rename wins; a lost claim takes the tombstone back),
197
+ deletes it, and the Bridge announces `:turn_dequeued`. `200 {status: "removed", enqueued_id, prompt}`, `409 already_started`
198
+ (a turn took it), `409 not_queued` (a next-step message: a drain may merge it any moment), `404
199
+ unknown_enqueued_id`. It never wakes the worker. The worker names it in its sidecar features (`unqueue`).
200
+ A worker's start prunes the `input/removed/` and `input/settled/` tombstones older than a week
201
+ (`SessionInbox::TOMBSTONE_MAX_AGE`, log `tombstones_pruned`); otherwise they go with the session dir.
202
+ - `POST /session/:id/relay` — the approval relay, on a delegated child's worker
203
+ (`{action, relay_id, question_id}`): `opened` / `closed` (with a `reason`) set or clear the
204
+ pending question's `relayed_to` mark (`QuestionDesk#annotate`, `:question_relay`); `answered`
205
+ makes the child ask its parent's Bridge for the answer (`RelayVerifier`) and take it only for
206
+ this child and the question pending now. `200`, `409` no longer pending, `403` a parent agent's
207
+ pick beyond the child's own `guardrails.parent_answers` and session `parent_answers` (`parent_answer_refused`; the
208
+ relay card in the parent was held to the parent's), `422 relay_unverified` (the question
209
+ stays open).
210
+ - `POST /session/:id/relay/status` — on a parent's worker, `{relay_id}`: what its `RelayDesk`
211
+ (memory only) holds for that relay (`child_id`, `child_question_id`, `state`, `answer`, `by`);
212
+ `404` for an unknown id. A POST with a body because routes match exact paths.
213
+ - `POST /session/:id/command` — a session command (`/model`, `/models`, `!rollback`, `!cmd`,
214
+ `/continue`) for the worker loop; `202` with a `command_id` its `:command_ran` names, `400` when
215
+ the line isn't one. A `:command_ran` carries `thinking`, `reasoning_history` and `llm_context`
216
+ (the summaries the next turn runs under) when the command changed them or the model, and
217
+ `confirm: {line, text}` when a `/thinking` or `/reasoning-history` change from a `web:` or `tui:`
218
+ client asks first (`CacheConfirm`; nothing changed, `line` applies it).
219
+ - `POST /session/:id/exit` — ask the worker to exit now (`{client_id:, delete?, restart?, deadline?}`; `delete`:
220
+ `/exit --delete`, no recap written; `restart: true`: hand the session to a new worker on the newest chi
221
+ installed, `200 {status: "restarting"}`; an empty session's `exiting` carries `discard: true`). The worker checks with
222
+ the event log held (`WorkerIdleExit#hold_for_request`): `200 {status: "exiting"}` and it leaves
223
+ like an idle exit, or `409 {status: "held", reason:}` with `turn_running`, `input_queued`,
224
+ `continue_offered`, `client_connected` (a stream not named by the asker), `reminders`,
225
+ `background_tasks` (with `tasks: [{id, command}]`: the running tasks a finish wake watches), `starting`, or
226
+ `leaving_exit` / `leaving_delete` / `leaving_restart` when another client's exit of another kind is already taken
227
+ (`Worker#claim_exit`, claimed before a stop-all; the words are `WorkerIdleExit::LEAVING_WORDS`). A second exit of
228
+ the kind already taken is answered `200` as that one.
229
+ - `GET /session/:id/state` — `session_state_snapshot` (JSON).
230
+ - `GET /session/:id/stats` — `Engine#stats_snapshot` for attached `/stats`: the metrics, with the context window and prompt profile asked from the server before the first turn.
231
+ - `GET /session/:id/snapshot` — the snapshot frame's content as one request (the web server renders
232
+ the messages itself, then streams from its `event_seq`). Read by the web's full show
233
+ (`GET /api/sessions/:id`, `?parts=1`) and the plugin's `list_sessions`; every message is copied.
234
+ - `GET /session/:id/tail` — the page's light re-read (`Bridge#tail_frame`): `session_state_snapshot`,
235
+ `working_directory` (the session's folder, which the web checks the answer's file refs against),
236
+ `answer` (the last message a UI shows as an answer, `AnswerTail`: the one message, copied alone),
237
+ `cards` (`CardStore#list`; a card an earlier worker showed during a turn comes back read-only, with no
238
+ actions: its turn no longer runs), `event_seq`, `event_id`, all taken in one event-log hold; no message
239
+ list, so it costs the same however long the session is. `?turn_id=` answers that turn's answer
240
+ (an unknown or missing id: the newest); the only route that reads its query.
241
+ - No browser: only chi's own clients talk to a Bridge. A request with an `Origin` header, a `Sec-Fetch-Site`
242
+ other than `none`, or a `Host` that isn't loopback (a DNS-rebound page) is `403 cross_origin`
243
+ (`Bridge#browser_request?`, before any route); there is no CORS and no `OPTIONS` route. The browser reaches a
244
+ worker only through `chi web`. A request carrying any trace a proxy adds (`ProxyTrace`: `X-Forwarded-*`,
245
+ `Forwarded`, `X-Real-IP`, `X-Auth-Request-*`, `Cf-*`…, underscore twins too) is `403 proxied`
246
+ (`Bridge#proxied_request?`): an auth proxy that forwards every local port and adds such a header doesn't reach a
247
+ worker.
248
+ - **The secret.** Every request, on every route (the stream, the reads such as `state` and `stats`, the writes, and
249
+ an unknown path), names the Bridge's secret in `X-Chi-Bridge`; one without it, or with another one, is
250
+ `403 bridge_secret` with one session-specific message (`BridgeSecret.refused`): "session ID's worker refused
251
+ this chi's request (it didn't name its secret): a chi web or terminal started before chi was updated can't
252
+ reach it; restart chi web, re-attach with `chi --attach ID`, or restart the worker with `chi sessions restart
253
+ ID`." `chi sessions restart` can also recover when the worker cannot be asked: TERM + resume if disk shows no
254
+ running turn, pending question or approval, queued input, or running background task. The check is after the
255
+ browser and proxy checks and before any route (`Bridge#secret_ok?`,
256
+ `OpenSSL.secure_compare`). No route is left open: what the reads show is the session itself. Only a request that
257
+ got past it counts as client activity, so a refused one (an older chi web's polls) never holds the idle exit. Each Bridge makes its own (`SecureRandom.hex(32)`, 64 hex
258
+ characters) when it starts and writes it to `bridge.json`, which is 0600 from the moment it exists
259
+ (`AtomicFile` creates the temporary file with that mode, then renames it), so only this machine's user can read
260
+ it: a forward that adds no proxy header, or another local account, reaches the port but not a session. Clients get
261
+ it only from the sidecar: `WorkerLink.client`/`.await` and `BridgeClient.for_sidecar` take the port and the secret from the
262
+ same read (chi web makes its client from the sidecar for every request, the attached terminal again on a
263
+ `worker_changed`), so a respawned worker's new one is used and nothing passes a port by hand. A worker from before
264
+ the secret writes none and asks for none; a chi web or attached terminal from before it sends none, so a worker
265
+ the newer chi started refuses it until it is restarted. A client tells that refusal from a route's own 403
266
+ (`BridgeClient::Response#secret_refused?`) and shows the one message: chi web's relays answer 502 `bridge_secret`,
267
+ `chi answer` and a parent's approval relay say it instead of "refused", and an event stream ends with reason
268
+ `bridge_secret` once rediscovery finds no newer worker (the attached terminal's closing line), never retrying in
269
+ silence; chi web's stream proxy answers 502 `bridge_secret`, never passing the refusal on as frames. The secret is never logged (a refusal logs
270
+ `secret_refused` with the path, at most once a minute), never in an event, a reply, `/api/info` or a session
271
+ file, and `WorkerSidecar`, `BridgeClient` and `Bridge` leave it out of `#inspect`.
272
+
273
+ The metrics in `/state`, `/snapshot`, `/stats` and the SSE `snapshot`/`reset` frames
274
+ (`SessionMetrics#snapshot`) carry the session's totals and only the recent timing records: the
275
+ newest turn the worker finished and any it hasn't saved yet (at most 20 turns), with their tool
276
+ calls and the running turn's finished ones. The full history is the session's `analytics.json`,
277
+ which `SessionMetrics#persist` rewrites after each turn; the web server merges the two by id.
278
+ The totals' `tokens` block adds the cached, cache-write and reasoning sums, the reported cost (`cost_sum`) and the
279
+ estimated one (`cost_estimate_sum`, from `hosts.<name>.models` prices; 0 for none), the decode time and tokens
280
+ behind the average speed (`avg_decode_tps`) and the last speeds (`last_decode_tps`,
281
+ `last_prefill_tps`, `tps_source`: `server` or `estimate`). `model_segments` (always present) splits those sums
282
+ by model after a `/model` switch: one row per run of consecutive turns on one model (`SessionMetrics::ModelSegment`,
283
+ keyed on the turn records' `model_ref`), in order, with its turns, token and cost sums, speeds and the switch's prefill
284
+ (`prefill_tokens`, `prefill_cost_estimate`: the sums of its turn records' `switch_prefill_tokens` and
285
+ `switch_prefill_cost_estimate`, [sessions.md](../sessions.md)); `[]` with one model, the oldest rows folded into one (`folded_segments`)
286
+ past 6; a running turn counts in its model's row (`ended_at` null). The rows add up to `tokens`. They are derived from
287
+ the records, never read back. Each `generation_completed` event
288
+ carries the generation's `speed` (`{decode_tps, source}`, null without one), its own prompt-cache
289
+ counts (`prompt_tokens`, `cached_tokens`, `cache_write_tokens`: null when the server reported no writes) and
290
+ those running `tokens` and `model_segments`: the Engine closes the generation in `SessionMetrics#finish_generation` before relaying
291
+ it, so the web's info bar updates per generation without parsing chunk payloads.
292
+ Side requests (the idle recap, a plugin's `ctx.ask_model` such as `/btw`) are counted apart, in a `side` block
293
+ (only when there were any; `SessionMetrics::SideTotals`: requests, token sums, `cost_sum` null until reported,
294
+ `cost_estimate_sum` from the target host's price, `by_kind`): `IdleClient` hands each reply's usage to its
295
+ `on_usage:` callback, `Engine#note_side_usage`, which folds it (`SessionMetrics#add_side`) and announces
296
+ `:side_usage` (`kind`, the whole `side` block) under the event lock, so the blocks arrive in fold order, then saves
297
+ `analytics.json` between turns (a running turn's end saves it). `tokens`, `model_segments` and the turn records
298
+ stay the turns' only. `SessionMetrics#persist` is serialized (its own mutex, taken before the metrics one), so two
299
+ threads' saves land in snapshot order.
300
+ A bundle's `ctx.cost` reads the same sums (`SessionMetrics#cost`, a frozen `SessionMetrics::Cost`): the `tokens`
301
+ block's `cost_sum` and `cost_estimate_sum` with the running turn's finished generations folded in, plus the `side`
302
+ block's, so it counts what earlier workers spent too ([plugins.md](../plugins.md)). Its `ctx.usage` reads the work
303
+ (`SessionMetrics#usage`, a frozen `SessionMetrics::Usage`): the snapshot's `turns`, `tool_calls_total` /
304
+ `tool_calls_by_tool` and `iterations_total`, and `active_ms`, the generations' `gen_ms` plus the finished tool
305
+ records' `duration_ms` (tallied per tool as they are counted; `ask_user_question`'s, a wait on the user, left out).
306
+
307
+ The page's frequent reads of `GET /api/sessions/:id` stay light too:
308
+ - `?tail=1&recent=1` (each turn's end, a cancel, an answer display): `{tail, session: {id, status,
309
+ used_memory_names}, messages: [the answer, with its display and display_refs (re-read on an `answer_display` with a display or refs)], markdown_warning, timing}`, from the worker's `/tail`
310
+ alone (no session file parsed, no `analytics.json`); `timing` carries the worker's recent records
311
+ and `turn_count` (finished turns). The page merges the records by id (`timing.js mergeTiming`) and
312
+ finishes the ended turn's own line by its `turn_id` (sent as `&turn_id=`, which also picks that
313
+ turn's answer). A tail read's timing is always the newest turn's; `recent=1` is accepted and not needed.
314
+ The page binds an answer's `display_refs` to its bubble (`answer_refs.js`: on a reload's render and on this
315
+ read's re-render) and pairs them with the bubble's links and text before the `path:line` scan. A question's
316
+ refs (`pending_question.refs`, `:question_asked`) pair the same way (`markdown_place.js` `linkRefsIn`/`pairRefsIn`):
317
+ `question_requested` (or a snapshot's pending question) keeps them on the asking step's gen
318
+ (`turn_model.js` `questionRefs`) before the card draws, so the stage's asking slot (`stage_slots.js`
319
+ `drawAsking(text, refs)`) pairs them on its raw draw and again after its markdown swap; the card pairs its
320
+ question text and its kept reply (`question_cards.js`); the step's closed `.gen-text` takes them
321
+ (`turn_block.js`; on a reload its `data-answer-at`, the message's `display_refs`) and `linkTextRefs` pairs it at
322
+ the turn's end and after a reload. A closed step's text is markdown too with `web.markdown` on: a reload's
323
+ from its message's `html` (`turn_block.js` `textTemplate`), a live turn's from `POST /api/render` on its step's
324
+ first open after the turn's end (`markdown_place.js` `renderStepTexts`, which pairs its refs again after the
325
+ swap); the text as written stays on it as `data-copy-source` (`stepText`, a reloaded card's kept reply). A file ref outside the
326
+ session's folder is dropped, as a reload drops it: the folder `/tail` names, else (an older worker) the hub's
327
+ session list entry's, else (refs to check, no hub entry) the session file's; with no folder known at all, every
328
+ file ref is dropped rather than shown unchecked.
329
+ A plugin tool's row refs (`refs`, `RowRefs`) ride its tool events and, on a reload, its part (saved as
330
+ `tool_refs`); `turn_block.js` binds them to the row's title (`refs.js bindRowRefs`: the first on the title,
331
+ the others in its menu).
332
+ - `?timing=1`: the whole timing (`analytics.json` merged with the worker's `/state`) and
333
+ `turn_count`, when the page's merge came up short.
334
+ - `?cards=1`: the cards, from `/tail`; the session file is only stat'ed for the 404.
335
+
336
+ The stage view's working set (`web.working_set`) has its own read, `GET /api/sessions/:id/working_set`: the
337
+ `WorkingSet` projection (`{items, at, running}`: each file, memory and URL live, stale or forgotten, with its tokens)
338
+ of the worker's `/snapshot` messages, else the session file, under the session's LLM context strategy; while a turn
339
+ runs, the snapshot's `working_set` (the running turn's last event, which the messages lag) when it has one. Its live form
340
+ is the `:working_set_updated` event, which always wins over a GET's answer it overtook. The page overlays it on the
341
+ set it builds from its tool rows ([internals/working-set.md](working-set.md)).
342
+
343
+ A worker without `/tail` (an older chi; `404 not_found`) is read through `/snapshot` in the same
344
+ shape; any other failure (no worker, a 500, a timeout) reads the disk, trimmed to the newest
345
+ turn's records.
346
+
347
+ Every route goes through `Bridge#dispatch`: an id other than the bridge's own session is
348
+ `404 unknown_session`, and a handler that raises answers `500 bridge_error` (logged as
349
+ `handler_failed`) rather than dropping the connection.
350
+
351
+ The per-session port is OS-assigned (bound to `0`) and published to a `bridge.json` sidecar
352
+ for client discovery, with the Bridge's secret (see above).
353
+
354
+ **Worker link.** Every client asks `WorkerLink` (`worker_link.rb`) whether a session's worker can be reached, never
355
+ reading `bridge.json` or probing a port itself: `WorkerLink.resolve(session_dir, unlink:, owner:)` answers a typed
356
+ outcome. The rule is the owner lock plus the sidecar's `pid`; there is no port probe (a port can be reused by another
357
+ session's Bridge, a free lock is the kernel's proof the worker died). In order: no lock holder, or a chi REPL's: a
358
+ sidecar is `Stale(:lock_free)` (removed with `unlink: true`, only if it is still the file that was read and the lock,
359
+ read again just before, is still free: a worker that took it meanwhile is resolved as that worker), else `None`;
360
+ a holder with no kind yet: `Starting`; a leaving worker: `Leaving` (marked before its Bridge stops, on every way out:
361
+ `Worker#teardown` marks it too, with `TEARDOWN_LEAVE_WAIT`); a worker whose sidecar names no port or another
362
+ pid than the holder's (a worker that has not written its own yet): `Starting`, `stuck` once it has held the lock
363
+ past `START_LIMIT` (30 s) with none; a sidecar whose `protocol` is newer than `WorkerLink::PROTOCOL`: `Skew` (a
364
+ newer worker; never driven, `wake` answers 409 `worker_newer`); else `Live` (its sidecar; `#client` makes the one
365
+ `BridgeClient`). A sidecar with no `pid` is an older worker's: the lock alone says it is live. `owner:` is the
366
+ holder as the caller already read it (nil: free), so one read serves a summary and its status. `WorkerLink.client` is
367
+ the Live's client or nil; `WorkerLink.await` polls for a new worker's Live (an `until:` check tells a restart's
368
+ successor from the old worker); chi web's `App#worker_link` / `#await_worker_link` wrap them. The Bridge writes
369
+ `pid` and `protocol` with its port and removes its sidecar on stop only when the file still carries its epoch.
370
+
371
+ `chi web`'s `GET /api/sessions/:id/stream` proxies this bridge
372
+ (503 `not_live` for no worker, a leaving or stuck worker, or an unreachable Bridge; 502 `worker_newer` for skew and
373
+ `bridge_error` / `bridge_secret` for a refused Bridge; 200 only after the Bridge answers; full history of any session
374
+ is served by `GET /api/sessions/:id/output`). It opens the Bridge before answering and gives the one opened socket to
375
+ `App#proxy_stream_body` / `ProxyStreamBody`, which closes it when the body or hijacked client ends. The owner's stream
376
+ is a byte pipe; the body gives it the request's frame filter (`App#stream_filter`: nil for the owner; for a participant, and the owner's participant view
377
+ `?as=participant`, a `Web::ParticipantStreamFilter`, which cuts the Bridge's chunks into frames and writes each again
378
+ from `Web::ParticipantView.event`'s allowlist, dropping every other event; only `from_seq` and `snapshot` reach the
379
+ Bridge; [internals/participants.md](participants.md#what-a-participant-sees)). Resume/ring-buffer state is **in-memory**, per worker: a cursor from
380
+ an earlier worker gets the `reset` snapshot, not a replay.
@@ -43,6 +43,7 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
43
43
  | `SystemBundle`, `ShippedUpdate` | the built-in system bundle (`ensure!` at Engine start, `sync` in `chi update`) and `chi update`'s upgrade of shipped bundles; both drive the Installer with `upgrade: true` |
44
44
  | `Uninstaller`, `Trash`, `Status`, `Listing` | remove, keep removed files, `chi bundle status`, `chi bundle list` |
45
45
  | `IndexUpdater`, `IndexSync` | the managed `index.md` lines (Installer; and the write/edit tools for memories) |
46
+ | `EntrySize` | one memory's size in tokens: `memory.entry_warn_tokens`' crossing note after a memory write, `chi self`'s `big memories` row |
46
47
  | `IndexSize` | one scope's memory index as the prompt carries it (tokens, lines, bytes): `memory.index_warn_tokens`' crossing note after a memory write, `chi self`'s summary, the prompt's figures (`SystemPrompt`) |
47
48
  | `Placeholder` | `{{placeholder}}` patterns left in an installed memory, one line per file from `Installer#detect_placeholders`; a hint, never a gate |
48
49
 
@@ -86,7 +86,7 @@ Configuration:
86
86
  - `SAMAGOTCHI_CONTEXT_WINDOW_TOKENS` / `context.window_tokens`: context window size for when neither the server nor the model's or host's config gives one (step 4 of the lookup below).
87
87
  - `models.<key>.window_tokens` / `hosts.<name>.window_tokens` (config file only): the window for one model, or for every model on a host (step 3 below).
88
88
  - `SAMAGOTCHI_CONTEXT_CHARS_PER_TOKEN` (default `4.0`): heuristic ratio for char-to-token estimation.
89
- - `SAMAGOTCHI_CONTEXT_STATUS_THRESHOLDS` / `context.status_thresholds` (default `20,40,60,80`): comma-separated threshold percentages. They set the buckets, and the buckets' guidance gates the model's own `[CONTEXT: …]` line. (`context.status_cadence` is gone: the event streams on every request.)
89
+ - `SAMAGOTCHI_CONTEXT_STATUS_THRESHOLDS` / `context.status_thresholds` (default `20,40,60,80`): comma-separated threshold percentages. They set the buckets, and the buckets' guidance gates the model's own `[CONTEXT: …]` line; the `:context_status` event streams on every request.
90
90
 
91
91
  The `context.*` settings are read once, when the turn's `ContextStatus` is built, so a change counts from the next turn.
92
92
 
@@ -348,12 +348,12 @@ One iteration with tool calls:
348
348
  - For edit and write: diffs the file before and after, and refreshes a memory's index line
349
349
  (`MemoryBundle::IndexSync`).
350
350
  - Attaches returned images, at most `MAX_IMAGES_PER_RESULT` (4) per result.
351
- - Scrubs bytes that aren't UTF-8, and caps the output at `max_tool_output_chars` by the tool's `ToolOutputCut`
351
+ - Scrubs bytes that aren't UTF-8, and caps the output at `max_tool_output_chars` (a `memory_read`'s is `memory.read_max_chars`, 0: none, with an overflow guard at half a known window) by the tool's `ToolOutputCut`
352
352
  policy: `read` and most tools are cut to their head and end with `[cut: N of M chars; read it in parts]` (N
353
353
  kept of M, the whole within the cap unless the cap is shorter than that line); an `execute` result keeps a head
354
354
  and a long tail with a gap line between them, its whole output saved in `sessions/<id>/tool_output/`
355
355
  (`ToolOutputFiles`, through `KernelLoop#session_dir`, which the Engine sets). See
356
- [Tool output guardrails](tool-guardrails.md#the-runners-cut-max_tool_output_chars). The model (both loops), the
356
+ [Tool output guardrails](tool-output-limits.md#the-runners-cut-max_tool_output_chars). The model (both loops), the
357
357
  event and the after_tool_call hook get that text.
358
358
  - Emits `:tool_call_completed` {iteration, call_count, call_index, tool, output, output_truncated, output_file,
359
359
  activity, images, diff, waited_ms, view, ref, refs}, then fires `:after_tool_call` {iteration, tool, output,
@@ -598,9 +598,12 @@ Where a participant's author (`"p:<id>"`; `Steer.participant_author?`: any autho
598
598
  `Worker#run_prompt` checks their link and cap (`participant_refusal`: `participants.json`'s entry and cap against
599
599
  `SessionMetrics#spend`) before their turn starts and after it ends (the owner's notice, once per cap). A Continue
600
600
  keeps the offer turn's author (`ContinueOffer#run_continue_turn`, from `last_turn.author`).
601
- - **The gate**: `GuardrailWiring#review_boundary` (`Guardrails::ReviewBoundary`) is the first core check; it reads
602
- `#turn_author` and denies a participant's turn all but `Toolkit::REVIEW_TOOLS` (not `forget_outputs`)
603
- (docs/guardrails.md). The declared tools stay the session's (D3: no rebuild per author).
601
+ - **The gate**: `GuardrailWiring#participant_boundary` is the core check after the load failures, picked by
602
+ `#turn_mode`: `Guardrails::PairBoundary` for a pair turn, `Guardrails::ReviewBoundary` for a review one. Both read
603
+ `#turn_author` and are no-ops for the owner's turns; the review boundary denies a participant's turn everything
604
+ outside `Toolkit::REVIEW_TOOLS` (not `forget_outputs`), the pair boundary denies secrets, owner-voiced tools and
605
+ `ask_user_question` and always asks before a write that may land outside the project (docs/guardrails.md).
606
+ The declared tools stay the session's (D3: no rebuild per author).
604
607
  - **Hooks and plugins**: `Engine#turn_author` (the running turn's participant author, nil otherwise) is the hook
605
608
  runtime's `turn_author` (`Hooks::Registry#with_runtime` adds `author:` to every event of such a turn) and the
606
609
  plugin host's (`ctx.turn_author`).
@@ -719,7 +722,7 @@ loops). It is display-only and never reaches the wire (prompt-caching.md, rule 7
719
722
  model's opens, never from `QuestionDesk`: `Engine#request_question` (the kernel's `question_handler`, after
720
723
  `AskUserQuestion.validate`; not with `interface == :non_interactive`) and the guardrail wiring's `ask:` lambda
721
724
  (`GuardrailWiring#request_approval`, after a stored approval and the non-interactive deny). So `ask_for_hook`, a
722
- relayed approval (`EnginePeers#open_question` → `Engine#open_question`) and `post` (the step-limit question) never
725
+ relayed approval (`Engine::RelayPeer#open_question` → `Engine#open_question`) and `post` (the step-limit question) never
723
726
  fire it. `QuestionAsked.fire` is a no-op without a subscriber (`Registry#any?`); else it finds the live
724
727
  conversation's last `role: "model"` entry (`Engine#live_conversation`, one lookup for both loops: a ChatLoop batch may
725
728
  end in a tool result), builds an `AnswerRefs` on that step alone with the question caps (50 / 32 KiB), fires with the
@@ -752,16 +755,12 @@ step's text ([architecture.md](../architecture.md), the `display_refs` paragraph
752
755
  `ToolRunner` asks the kernel's `Guardrails::Gate` (set by the Engine from `GuardrailWiring`) before each call;
753
756
  the gate is not itself a hook. `Gate#evaluate` fires the `:before_tool_call` hooks (the voters, e.g. the
754
757
  known-names bundle or a config hook), which vote through `event[:guardrail]` (`deny!`, `ask!`) or the legacy
755
- `event[:blocked]` flag. A deny stays a deny for the hooks after it. Then the core checks run on the final call,
756
- in `GuardrailWiring#checks` order:
757
-
758
- 1. load failures (`Guardrails::LoadFailures`): a required hook or rule file that failed to load denies every call;
759
- 2. `rules.hook_asks`: with guardrails disabled, a hook's ask is dropped (a deny still applies);
760
- 3. protected paths;
761
- 4. `ScratchWrites` (a `chi scratch` session only): write and edit under the memories folder are denied;
762
- 5. `ChildBoundary` (a delegate child only): a change outside its folder asks;
763
- 6. the rules (config and bundles, e.g. the guardrails bundle's `rules.yml`; a rule can match the tool, its
764
- targets and the model).
758
+ `event[:blocked]` flag. A deny stays a deny for the hooks after it; a hook's ask is dropped when guardrails are
759
+ disabled. Then the core checks run on the final call in `GuardrailWiring#checks` order
760
+ (`lib/samagotchi/guardrail_wiring.rb`): the load failures first, then the participant's boundary
761
+ (`participant_boundary`, `PairBoundary` for a pair turn else `ReviewBoundary`), then the hook asks, the protected
762
+ paths, the scratch writes, the delegate child's boundary, the rules. That method is the order; it is not spelled
763
+ out here so this page can't drift from it.
765
764
 
766
765
  A gate that raises denies the call.
767
766
 
@@ -795,6 +794,13 @@ Engine
795
794
 
796
795
  Both loops:
797
796
 
797
+ - <a id="a-reply-that-is-only-a-tool-call"></a>**A reply that is only a tool call** (`LLM::StrayToolCall`, `LLM::ChatLoop` only): a reply with no tool
798
+ calls whose whole text is one closed tool-call block, which the host couldn't read, isn't an answer. A reply opening with prose or a fence stays
799
+ an answer, and the check is one string test on a normal one. Nothing is guessed from the block and it never enters the history (`:generation_completed`
800
+ and `:after_generation` have already seen the text): `:generation_retrying` with `restarted` voids the streamed text, `:empty_answer_retry` with
801
+ `malformed` goes out, and a hidden `TurnNote.malformed_retry` asks again at the retry temperature. It has its own budget, so `retry.empty_answer: 0`
802
+ doesn't turn it off, and a real tool call resets it. Two in a row fail the turn with `LLM::MalformedGeneration`, keeping its earlier steps. Each is
803
+ logged as `generation_malformed` with the shape, finish reason, served model and the text's tail. `KernelLoop` has its own path (`ToolCallParser#malformed`).
798
804
  - **Empty-answer retry** (`EmptyAnswerRetry`): a generation with no visible text and no tool calls is asked
799
805
  again, up to `retry.empty_answer` times (at most 3), with a hidden nudge (`TurnNote.empty_retry`) on the tail.
800
806
  The retry request runs at temperature 0.6 when the configured sampling sets no temperature; a configured one
@@ -884,7 +890,7 @@ Paths are under `lib/samagotchi/`.
884
890
  | `llm/native_backend.rb` | Wraps KernelLoop as a backend (`#complete` calls `#run`) |
885
891
  | `llm/model_result.rb` | `LLM::ModelResult`, what a backend returns |
886
892
  | `tool_runner.rb` | Per-call path (gate, start/complete events, output cap, images, diffs) |
887
- | `tool_output_cut.rb`, `tool_output_files.rb` | `ToolOutputCut`: how a tool's output longer than `max_tool_output_chars` is cut, by the tool's policy (`POLICIES`; `:head` for a tool with none); `ToolOutputFiles`: the whole output of an execute cut that way, saved in the session's `tool_output/` folder ([tool-guardrails.md](tool-guardrails.md)) |
893
+ | `tool_output_cut.rb`, `tool_output_files.rb` | `ToolOutputCut`: how a tool's output longer than `max_tool_output_chars` (a `memory_read`'s: `memory.read_max_chars`) is cut, by the tool's policy (`POLICIES`; `:head` for a tool with none); `ToolOutputFiles`: the whole output of an execute cut that way, saved in the session's `tool_output/` folder ([tool-output-limits.md](tool-output-limits.md)) |
888
894
  | `tool_row_fields.rb`, `row_refs.rb` | `ToolRowFields`: a tool row's fields beyond its params (`ACTIVITY_KEYS`, `EVENT_KEYS`), the same live and on a reload; `RowRefs`: a plugin tool's row refs (`refs:`), checked, capped and saved with the result |
889
895
  | `tool_response.rb` | Per-batch dispatch and tool_response entries (joined for native, single for chat) |
890
896
  | `empty_answer_retry.rb` | The empty-answer and cut retry budget |
@@ -914,5 +920,5 @@ Paths are under `lib/samagotchi/`.
914
920
  | `worker_wakes.rb` | `WorkerWakes`: a worker's wake budget for the turns nobody typed (`session.max_wakes`) |
915
921
  | `task_watch.rb`, `task_finish_note.rb` | `TaskWatch`: the background tasks a worker's session started that the model hasn't seen finish; `TaskFinishNote`: the note one leaves when it does ([background-tasks.md](background-tasks.md#finish-wake)) |
916
922
  | `side_usage.rb` | `SideUsage`: one side request's usage (a recap, `ctx.ask_model`), folded into the session's side sums (`:side_usage`) |
917
- | `bridge/client_event.rb` | `Bridge::ClientEvent.project`: what an SSE client gets for an engine event ([architecture.md](../architecture.md)) |
923
+ | `bridge/client_event.rb` | `Bridge::ClientEvent.project`: what an SSE client gets for an engine event ([bridge.md](bridge.md)) |
918
924
  | `guardrails/parent_policy.rb` | `Guardrails::ParentPolicy`: which options of a question a parent agent may pick (`guardrails.parent_answers`) |
@@ -1,11 +1,12 @@
1
1
  # Participants: identity and routes
2
2
 
3
3
  A session's owner can invite **participants** to review it: people, or agents, who reach that one session through
4
- chi web with a link of their own. This page is the identity layer (the shared-sessions plan's P2): grants, the gate,
5
- the routes a participant reaches, and what is written where; and their **review turns** (P3b): queued, read-only,
6
- limited and capped; and what they **see** (P4a): their page and its reads, all through one filter. Participants are
7
- experimental. An agent participant waits on its own turns' replies through the API. The code and docs say
8
- "participants" and "review", never "shared": `session.shared` already means "attached by default".
4
+ chi web with a link of their own. This page is the identity layer: grants, the gate, the routes a participant
5
+ reaches, and what is written where; and their **turns**: review turns queued, read-only, limited and capped, pair
6
+ turns that act under the owner's guardrails; and what they **see**: their page and its reads, all through one
7
+ filter. Participants are experimental. An agent participant waits on its own turns' replies through the API. The
8
+ code and docs say "participants", "review" and "pair", never "shared": `session.shared` already means "attached by
9
+ default".
9
10
 
10
11
  Participants reach chi web on the **LAN** (`web.host: lan`), or **behind an auth proxy** with `web.public_url`
11
12
  (experimental; docs/configuration.md, "Behind an auth proxy"): there every request needs a token, this machine's too,
@@ -43,9 +44,9 @@ participant** and **one role**, until it expires or is revoked.
43
44
  newer chi's: read (its links work, but a grant with keys this chi doesn't know is left out: such a key may
44
45
  restrict it, so it fails closed) and never written (`Grants::Newer`, one message saying to update chi; a revoke
45
46
  the gate can't write still refuses the grant). A grant's keys this chi doesn't know are kept on a rewrite
46
- (`Grant#extra`), so a field a later chi adds the omit-if-default way survives. A chi from before the version check
47
- doesn't look at it: **mixed installs** on one state dir are a hazard, as a chi before 0.109 (no `identity`) rewriting
48
- the store unbinds every grant, and one before this change drops unknown keys. A stored `identity` that isn't one
47
+ (`Grant#extra`), so a field a later chi adds the omit-if-default way survives. **Mixed installs** on one state dir
48
+ are a hazard: an older chi that doesn't know `identity` unbinds every grant when it rewrites the store, and one
49
+ that doesn't keep a grant's unknown keys drops them. A stored `identity` that isn't one
49
50
  `#issue` would write (`false`, `""`, a list, upper case) makes the store Corrupt (fails closed). Readers follow its mtime (a `chi share revoke` in another process takes effect at chi web's next request).
50
51
  `last_used_at` is written at most once a minute per grant. **chi web refuses to start** when that folder or file can
51
52
  be read by another user, or isn't this user's (`Grants.check_permissions!`, one message naming the `chmod`). Only
@@ -648,7 +649,7 @@ invited to has none: its files stay byte for byte as they were.
648
649
  expires_at, removed_at, cap}]}`, rewritten by whoever changes a grant (chi web, `chi share`), inside the grants
649
650
  store's lock (`Grants#update`'s `on_write`), so two writers can't finish out of order and lose a revoke here. Never
650
651
  a token or a digest.
651
- The worker reads it with `Participants.read` (P3a's system-prompt line, `ctx`).
652
+ The worker reads it with `Participants.read` (`SessionParticipants#refresh!`, for the system prompt's participants line).
652
653
  - **`participants.jsonl`** (0600, append-only, `Participants.log`): one JSON line per event, with `at`: `issued`
653
654
  (participant, name, kind, role, expires_at), `revoked` (participant, by), `expired` (the first refused use after the
654
655
  expiry, once per chi web), `peer` (the first use and each new peer since chi web started: ip, user agent; behind a
@@ -668,8 +669,8 @@ invited to has none: its files stay byte for byte as they were.
668
669
 
669
670
  What a participant writes is marked as theirs everywhere it is kept or sent. The
670
671
  author is `"p:<id>"` (`Grant#author`), stamped by chi web from the principal and never taken from a body. Absent
671
- means the owner: old sessions and every owner path read as the owner's, byte for byte as before (a pre-P3 session
672
- fixture loads and saves unchanged, `spec/turn_author_record_spec.rb`).
672
+ means the owner: a turn or session with no author keys reads as the owner's and gains none on save
673
+ (`spec/turn_author_record_spec.rb`).
673
674
 
674
675
  - **Messages.** A participant's opening message is saved raw with `author`, `author_name` (the name the sidecar gave
675
676
  the participant when it was written) and `participant_mode` (`ParticipantMode.of` when `Engine#run_turn` builds it, before any request carries it). Merged input and
@@ -741,7 +742,7 @@ participant TUI in v1.
741
742
  worker announces `:participants_changed` (`Engine#announce_participants`: `{participants: [{id, name, kind}],
742
743
  joined: […], left: [{…, reason: revoked | expired}]}`) to every UI; its start announces nobody. "Joined" means
743
744
  invited: participants have no presence of their own yet. `session_state_snapshot`'s `participants` names who is in
744
- the review now, so a UI that joins has them. Owner frames only: no participant stream carries them (P4a's filter).
745
+ the review now, so a UI that joins has them. Owner frames only: no participant stream carries them (`Web::ParticipantView`).
745
746
  - **Lines.** A participant's prompt (`turn_enqueued`'s `author`, an origin's, a queued row's, a saved message's
746
747
  `author` / `author_name`) is led by their name (`Formatting#participant_prefix`: `Alex ▸ review>`, cleaned by
747
748
  `ParticipantName`, the id when the TUI knows no name), in a colour picked from their id. Each change is one line per
@@ -7,7 +7,7 @@ sessions, workers, memories and code stay on that machine; this one only sends r
7
7
  User-facing behaviour is in [cli.md](../cli.md#remote-sessions). This page is the code's shape, for changing it.
8
8
 
9
9
  **The one rule:** the remote side is that machine's **chi web**, never a worker's Bridge. Bridges stay on loopback,
10
- and answer only a request that names their secret from `bridge.json` ([architecture.md](../architecture.md)).
10
+ and answer only a request that names their secret from `bridge.json` ([bridge.md](bridge.md)).
11
11
  chi web answers each request by calling the same `SessionAccess::Local` operation the CLI would call there.
12
12
 
13
13
  ## The seam: `SessionAccess`
@@ -58,7 +58,7 @@ Implementation note:
58
58
 
59
59
  ## The runner's cut (`max_tool_output_chars`)
60
60
 
61
- After a tool's own limits, `ToolRunner` caps every result at `max_tool_output_chars` (default `10000`
61
+ After a tool's own limits, `ToolRunner` caps every result but a `memory_read`'s at `max_tool_output_chars` (default `10000`
62
62
  characters, both loops). How a longer one is cut is the tool's policy, `ToolOutputCut::POLICIES` (by the
63
63
  built-in name; a tool with none gets `:head`):
64
64
 
@@ -67,7 +67,26 @@ built-in name; a tool with none gets `:head`):
67
67
  | `execute` | `:head_tail_file` | a short head, a long tail, one gap line between them; the whole output saved in the session's folder |
68
68
  | `task_wait` | `:head_tail_ref` | the same head and tail, the gap line naming the task's log (`output_path`, `Tools::RefText`); nothing saved |
69
69
  | `delegate`, `delegate_result` | `:head_tail_ref` | the same, the gap line naming the child's reply file (its `output/<timestamp>.txt`); nothing saved |
70
- | `read` and every other tool (`task_get`/`task_list`/`task_create`/`task_stop`, `web_fetch`, `memory_read`, `context_read`, plugin and MCP tools) | `:head` | the first chars, then `[cut: N of M chars; read it in parts]` |
70
+ | `read` and every other tool (`task_get`/`task_list`/`task_create`/`task_stop`, `web_fetch`, `context_read`, plugin and MCP tools) | `:head` | the first chars, then `[cut: N of M chars; read it in parts]` |
71
+ | `memory_read` | `:head`, its own cap | not cut by `max_tool_output_chars`; see below |
72
+
73
+ `memory_read` is the one tool `max_tool_output_chars` does not cap, in any form (a name, a comma list, a blank name for the
74
+ indexes): a memory is read whole, since a model that needs the skill pays for all of it anyway. `ToolRunner#output_cap`
75
+ resolves the cap per call, so both loops get it (they both come through `ToolResponse.run_batch`). Two things can still
76
+ cut a `memory_read` result, both with the same `:head` cut and a line that says where the rest is, since `memory_read`
77
+ has no parts to read:
78
+
79
+ - `memory.read_max_chars` (default `0`: none). A positive value caps the result at that many characters.
80
+ - The overflow guard, with `memory.read_max_chars` at 0: a result over half the model's context window (in characters,
81
+ tokens x `context.chars_per_token`) is cut. A context overflow is a hard failure (`LLM::FailedTurn.keeps_work?` is
82
+ false), so the turn's work rolls back and a retry would repeat the same read. The window is the loop's own:
83
+ `KernelLoop#tool_output_window` (the client's probe, the turn's `window_tokens` setting), or for `LLM::ChatLoop` its
84
+ adapter's model list too (`ToolRunner.new(kernel, window:)`). An unknown window (`ContextWindow` falling back to
85
+ its default) has no guard. It checks this one result, not the context already used.
86
+
87
+ The line is `[cut: N of M chars; the rest is in the memory's .md file in <the memories dir(s) searched>: read it with
88
+ start_line]` ("the memories' .md files" for a comma list or the indexes). `ToolOutputCut::HEAD_CUT` does not match it.
89
+ `read` on a memory's file keeps the `max_tool_output_chars` cut: it has line ranges.
71
90
 
72
91
  `:head_tail_file` is for output whose end matters: a test run's summary, an error, the `exit:` line. The head is
73
92
  `execute.cut_head_chars` at most (default `1000`) and never more than a quarter of the room; the tail gets