samagotchi 0.105.0 → 0.106.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 +48 -1
- data/docs/architecture.md +6 -3
- data/docs/cli.md +49 -8
- data/docs/configuration.md +9 -5
- data/docs/guardrails.md +56 -0
- data/docs/hooks.md +9 -0
- data/docs/internals/engine.md +22 -4
- data/docs/internals/participants.md +206 -28
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/internals/working-set.md +8 -2
- data/docs/plugins.md +2 -0
- data/docs/testing.md +3 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +13 -3
- data/lib/samagotchi/bridge.rb +35 -1
- data/lib/samagotchi/bridge_client.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/client_id.rb +21 -9
- data/lib/samagotchi/config.rb +16 -1
- data/lib/samagotchi/continue_offer.rb +13 -3
- data/lib/samagotchi/delivery.rb +10 -0
- data/lib/samagotchi/engine.rb +62 -6
- data/lib/samagotchi/guardrail_wiring.rb +13 -3
- data/lib/samagotchi/guardrails/review_boundary.rb +148 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hook_routes.rb +5 -2
- data/lib/samagotchi/hooks/registry.rb +12 -3
- data/lib/samagotchi/idle_recap.rb +9 -0
- data/lib/samagotchi/parent_report.rb +17 -5
- data/lib/samagotchi/participant_messages.rb +16 -0
- data/lib/samagotchi/participants.rb +29 -6
- data/lib/samagotchi/participants_watch.rb +90 -0
- data/lib/samagotchi/plugin/context.rb +7 -1
- data/lib/samagotchi/plugin/side_question.rb +9 -1
- data/lib/samagotchi/reply_wait/poll.rb +18 -3
- data/lib/samagotchi/run_options.rb +1 -1
- data/lib/samagotchi/send_command.rb +41 -3
- data/lib/samagotchi/session_access/contract.rb +2 -0
- data/lib/samagotchi/session_access/local.rb +31 -9
- data/lib/samagotchi/session_access/remote.rb +19 -2
- data/lib/samagotchi/session_access.rb +6 -3
- data/lib/samagotchi/session_commands.rb +6 -0
- data/lib/samagotchi/session_inbox.rb +31 -5
- data/lib/samagotchi/session_manager.rb +43 -3
- data/lib/samagotchi/session_metrics/side_totals.rb +21 -7
- data/lib/samagotchi/session_metrics.rb +88 -1
- data/lib/samagotchi/share_command.rb +61 -22
- data/lib/samagotchi/side_usage.rb +4 -2
- data/lib/samagotchi/steer.rb +37 -1
- data/lib/samagotchi/terminal_ui/attach_link.rb +5 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +54 -7
- data/lib/samagotchi/terminal_ui/formatting.rb +66 -5
- data/lib/samagotchi/terminal_ui/local_link.rb +7 -0
- data/lib/samagotchi/terminal_ui/participant_commands.rb +77 -0
- data/lib/samagotchi/terminal_ui/remote_link.rb +6 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +11 -2
- data/lib/samagotchi/terminal_ui.rb +5 -0
- data/lib/samagotchi/testing/host.rb +4 -2
- data/lib/samagotchi/testing/recorder.rb +3 -1
- data/lib/samagotchi/toolkit.rb +21 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +45 -20
- data/lib/samagotchi/web/grants.rb +85 -5
- data/lib/samagotchi/web/participant_access.rb +206 -35
- data/lib/samagotchi/web/participant_review.rb +165 -0
- data/lib/samagotchi/web/participant_stream_filter.rb +157 -0
- data/lib/samagotchi/web/participant_view.rb +415 -0
- data/lib/samagotchi/web/public/app.js +15 -1
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/data.js +20 -0
- data/lib/samagotchi/web/public/index.html +71 -27
- data/lib/samagotchi/web/public/info_bar.js +12 -5
- data/lib/samagotchi/web/public/info_bar_model.js +8 -5
- data/lib/samagotchi/web/public/participant.html +119 -0
- data/lib/samagotchi/web/public/participant.js +300 -0
- data/lib/samagotchi/web/public/participant_model.js +160 -0
- data/lib/samagotchi/web/public/participant_names.js +21 -0
- data/lib/samagotchi/web/public/participants_panel.js +311 -0
- data/lib/samagotchi/web/public/stream_handlers.js +8 -1
- data/lib/samagotchi/web/public/tokens.css +12 -0
- data/lib/samagotchi/web/public/turn_events.js +16 -5
- data/lib/samagotchi/web/public/turn_html.js +8 -4
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/web/public/working_set_view.js +3 -3
- data/lib/samagotchi/web/qr.rb +14 -0
- data/lib/samagotchi/web/running.rb +52 -0
- data/lib/samagotchi/web/server.rb +13 -0
- data/lib/samagotchi/web/session_access_routes.rb +3 -0
- data/lib/samagotchi/worker.rb +107 -5
- data/lib/samagotchi/worker_inbound.rb +12 -4
- metadata +15 -1
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
A session's owner can invite **participants** to review it: people, or agents, who reach that one session through
|
|
4
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
|
|
6
|
-
|
|
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
|
|
7
8
|
"participants" and "review", never "shared": `session.shared` already means "attached by default".
|
|
8
9
|
|
|
9
10
|
Until `web.public_url` lands, participants are a **LAN** feature (`web.host: lan`): a loopback peer is always the
|
|
@@ -18,12 +19,12 @@ participant** and **one role**, until it expires or is revoked.
|
|
|
18
19
|
look up only participant tokens, and a leaked one is recognisable. It is shown **once**, in the link chi share or
|
|
19
20
|
`POST …/participants` prints; chi keeps only its **SHA-256 digest**. Lookup digests the presented token and
|
|
20
21
|
compares it with every grant's digest (`Rack::Utils.secure_compare`).
|
|
21
|
-
- **Participant.** The grant's `id`, `p_<8 hex>`, names the participant: a turn they send
|
|
22
|
-
`"p:<id>"` (`Grant#author`, `Principal#author
|
|
22
|
+
- **Participant.** The grant's `id`, `p_<8 hex>`, names the participant: a turn they send is authored
|
|
23
|
+
`"p:<id>"` (`Grant#author`, `Principal#author`). `kind` is `human` or `agent` (D11: an agent uses the same
|
|
23
24
|
token, as a Bearer header; there is no agent UI). `role` is `reviewer`, the only one in v1. `name` is cleaned to
|
|
24
|
-
one line of at most 64 characters with no brackets (`Grants.clean_name`), so it can't forge
|
|
25
|
+
one line of at most 64 characters with no brackets (`Grants.clean_name`), so it can't forge the
|
|
25
26
|
`[Review contribution from …]` header.
|
|
26
|
-
- **Lifetime (D8).** 7 days by default (`chi share --expires Nh|Nd`, 1 hour to 30 days). Revoked by the owner
|
|
27
|
+
- **Lifetime (D8).** 7 days by default (`web.participants.expires`; `chi share --expires Nh|Nd`, 1 hour to 30 days). Revoked by the owner
|
|
27
28
|
(`chi share revoke`, `DELETE …/participants`), and by an **archive** (the session and its delegates) or a
|
|
28
29
|
**delete** (`SessionManager.archive_session` / `delete_session` → `Participants.end_sessions`). A session pruned by
|
|
29
30
|
retention, or archived or deleted by a process that didn't revoke, is caught by the gate: a live grant whose session
|
|
@@ -64,21 +65,160 @@ Then `#route`, for each matching `ROUTES` row:
|
|
|
64
65
|
`spec/web/participant_gate_spec.rb` walks `ROUTES`: every `:owner` row answers a participant 403, and every session
|
|
65
66
|
row answers another session's id 404, so a new route is covered without a new example.
|
|
66
67
|
|
|
67
|
-
## What a participant reaches
|
|
68
|
+
## What a participant reaches
|
|
68
69
|
|
|
69
70
|
| Route | Permission | A participant gets |
|
|
70
71
|
|---|---|---|
|
|
71
|
-
| `
|
|
72
|
+
| `POST /api/sessions/:id/turn` | participant | a queued review turn of their own ([Turns](#turns)) |
|
|
73
|
+
| `POST /api/sessions/:id/turn/:eid/dequeue` | participant | their own queued turn back; another's (the owner's included) is 403 `not_permitted` |
|
|
74
|
+
| `GET /api/sessions/resolve?id=` | participant | their session for its id or a prefix of it; anything else 404 as no session (never an ambiguity listing others) |
|
|
75
|
+
| `POST /api/sessions/:id/wait/prepare`, `…/wait` | participant | only for a turn of theirs (`turn`: the enqueued id `POST …/turn` answered; else 403); the answer trimmed to `{status, text, turn_id, cancel_reason}`; a question or approval their turn raised, or their turn held by the owner's step-limit Continue offer ([Turns](#turns)), is `{status: "waiting_for_owner", detail: "waiting for the session's owner"}` with nothing of it |
|
|
76
|
+
| `GET /` | participant | the participant page's shell (`participant.html`, its body's `data-session` and `data-name` the grant's; `participant.js` reads the rest through the two routes below). Never the owner's page, where every route would refuse them. |
|
|
77
|
+
| `GET /api/sessions/:id/review` | participant | the page's data, filtered ([What a participant sees](#what-a-participant-sees)): `{viewer, session: {id, title, status}, participants, messages, current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}` |
|
|
78
|
+
| `GET /api/sessions/:id/stream` | participant | the session's stream through `ParticipantStreamFilter` (only `from_seq` and `snapshot` reach the Bridge); it ends within a poll of a revoke or the expiry |
|
|
72
79
|
| `GET /api/info` | participant | `{app, version, features}` (what `SessionAccess::Remote`'s check needs); not `pid`, `cwd`, `lan`, `installed` |
|
|
73
80
|
| `GET /api/sessions/:id` | participant | their session only, whatever the query (`brief`, `cards`, `tail`, `timing`): `{session: {id, title, status, participants: [{id, name, kind, role}]}}` (the active ones). `title` is the session's own title, or "an untitled session": never `display_title`'s fallback, the first prompt's opening words |
|
|
74
81
|
|
|
75
|
-
Everything else is `:owner`,
|
|
76
|
-
principal past the gate: they are the public frontend.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
Everything else is `:owner`: `/answer`, `/command`, `/images` (an image a prompt carried is "[image]" on their page),
|
|
83
|
+
`/output` (the raw replies), the hub's `/api/events`, the cap route. Static assets (`/app.js`, …) are served to any principal past the gate: they are the public frontend.
|
|
84
|
+
|
|
85
|
+
## Turns
|
|
86
|
+
|
|
87
|
+
A participant's `POST /api/sessions/:id/turn` (`ParticipantAccess#handle_participant_turn`) takes only the body's
|
|
88
|
+
`prompt`. Three layers keep it a review turn.
|
|
89
|
+
|
|
90
|
+
**chi web.** The author is the grant's (`p:<id>`) and the client id `review:<id>` (`ClientId.review`: a human's
|
|
91
|
+
input, origin `review`, source `participant`, labelled `review`); the body's `client_id`, `author`, `delivery`,
|
|
92
|
+
`no_interrupt` and `images` never count. Refused before delivery, each audited:
|
|
93
|
+
|
|
94
|
+
- a line starting `!` or `/` (after leading spaces): 403 `not_permitted`, so no shell line, session command or
|
|
95
|
+
plugin command (v1);
|
|
96
|
+
- images: 403 `not_permitted` (D10);
|
|
97
|
+
- the grant's cap: 429 `cap_reached`, from the session's saved analytics (`SessionMetrics.saved_spend`).
|
|
98
|
+
|
|
99
|
+
It goes out as `delivery: queue` (D6) with `pending_limit` = the grant's queue limit + 1 (the running one), never
|
|
100
|
+
into the owner's prompt history. The Bridge counts the author's running and queued turns under its event lock and
|
|
101
|
+
answers 429 `too_many_turns` past it; with no worker, their waiting input files are counted. The ack is 202 with
|
|
102
|
+
`delivery: "queue"`; chi web keeps enqueued id → grant (the last 1000), which `dequeue` and `wait` check, falling
|
|
103
|
+
back to a waiting file's author after a restart.
|
|
104
|
+
|
|
105
|
+
**The worker.** A participant's input file waits for its own turn whatever its delivery says
|
|
106
|
+
(`SessionInbox.waits_for_turn_end?`), so a drain never merges it into another author's turn; the Bridge queues their
|
|
107
|
+
`POST /turn` whatever it asks and never cuts for it; a participant's `/word` or `!line` is refused by the Bridge (403)
|
|
108
|
+
and dropped from the worker's file path. A participant's turn goes only to a worker that names the `review_turns`
|
|
109
|
+
feature (409 `review_unavailable` otherwise: it has no boundary; restart it), and never as a file while a worker
|
|
110
|
+
that may be older holds the session (504 `worker_timeout`: send again). Before a queued participant turn starts, the
|
|
111
|
+
worker checks their link (`participants.json`: revoked, expired) and their cap again: either way the turn is
|
|
112
|
+
dequeued (`:turn_dequeued`) and audited (`revoked`, `cap_reached`) instead of run, so a revoke also stops what they
|
|
113
|
+
had queued. A step-limit Continue of a participant's turn (the owner answers it) continues as theirs: its origin keeps
|
|
114
|
+
their author (`ContinueOffer#run_continue_turn`), so it runs inside the boundary and on their spend; it isn't
|
|
115
|
+
continued once their link ended or their cap is reached. The step-limit Continue offer of the owner's turn is the
|
|
116
|
+
owner's to answer: while one stands, the worker **holds** a participant's input file (`Worker#run_input_files`,
|
|
117
|
+
`SessionInbox.participant_input?`; an unreadable file counts) instead of running it, which would drop the offer. It
|
|
118
|
+
stays in the inbox: still queued on the Bridge (counted against their queue limit, `turn/dequeue` takes it back),
|
|
119
|
+
and it doesn't hold back a command written after it (`#ready_command?`: that may be the owner's `/continue`). Once
|
|
120
|
+
the owner continues, stops or sends a turn of their own (which drops the offer, as any owner prompt does), the held
|
|
121
|
+
turns run in the order they came, each through the link and cap check above as it starts; a reminder turn drops the
|
|
122
|
+
offer too and so releases them. A held file isn't queued input for the idle-exit policy (`Worker#input_pending?`): `/exit`
|
|
123
|
+
and `chi sessions restart` name what waits, the offer (`continue_offered`), not a queued prompt. The offer lives in
|
|
124
|
+
the worker: a worker that restarts has none, and runs them.
|
|
125
|
+
Their `wait` always waits as for a queued turn of theirs, never for the next reply past a cursor (which could be
|
|
126
|
+
anyone's); on a held turn it answers `waiting_for_owner` (`ReplyWait::Poll#held`), the offer asked before the wait
|
|
127
|
+
began too. A chi client gets it through `--remote` with a participant's token in `remotes.yml`: `chi --remote NAME
|
|
128
|
+
send --wait --turn T ID` reaches only participant routes (resolve, `wait/prepare` and `wait` on their turn), and
|
|
129
|
+
`ParentReport` reads `waiting_for_owner` as its own status, exit 3 like a question, its detail
|
|
130
|
+
`ParentReport::WAITING_FOR_OWNER` (the route's words too) and the command that waits again. With a
|
|
131
|
+
participant's token (`chp_`, `SessionAccess::Remote#participant?`) `chi send -m` prints the queued turn's id and the
|
|
132
|
+
command that waits for it, and `send --wait -m` skips the owner's busy check (its turn is queued, so a running turn
|
|
133
|
+
doesn't refuse it; that check's `wait/prepare` without a turn would be 403 `not_permitted`) and waits on the turn it
|
|
134
|
+
queued, as `--turn T` does.
|
|
135
|
+
|
|
136
|
+
**The tool calls.** `Guardrails::ReviewBoundary` denies a participant's turn everything but reads of the project
|
|
137
|
+
([guardrails](../guardrails.md#a-participants-review-boundary)). Approvals and questions are owner-only: the boundary
|
|
138
|
+
denies the model's own question, and `/answer` is an owner route.
|
|
139
|
+
|
|
140
|
+
**Hooks and plugins** run on a participant's turn with the owner's power; every event of the turn carries `author:`
|
|
141
|
+
and `ctx.turn_author` names it ([hooks](../hooks.md#participants-turns)).
|
|
142
|
+
|
|
143
|
+
### Limits and the cap
|
|
144
|
+
|
|
145
|
+
- **Queue.** One running turn plus the grant's `queue` (default 3, `web.participants.queue`, 0 to 20) waiting.
|
|
146
|
+
- **Cap (D7).** The owner pays. A grant's `cap` is dollars (default $2, `web.participants.cap`; `chi share --cap`,
|
|
147
|
+
`chi share cap ID P DOLLARS`, `POST /api/sessions/:id/participants/:p/cap {cap}`). Spend is counted by author:
|
|
148
|
+
each turn's reported and estimated cost (the analytics turn record's `author`), and the side requests the
|
|
149
|
+
participant caused (their turn's `ctx.ask_model`, the idle recap after their turn; `SideTotals#by_author`). An
|
|
150
|
+
owner command's side request is the owner's. `/stats` shows each participant's spend by name. At the cap the
|
|
151
|
+
participant's next turn is 429 `cap_reached`, and the owner gets one notice per participant and cap: "Alex reached
|
|
152
|
+
their $2.00 review cap: their comments are refused until you raise it (chi share cap ID p_… AMOUNT)." A running
|
|
153
|
+
turn's spend counts from its end, so a turn can finish past the cap; the session-wide `cost-cap` bundle still
|
|
154
|
+
applies, and its question in a participant's turn goes to the owner.
|
|
155
|
+
|
|
156
|
+
**Streams.** A participant's stream is filtered ([below](#what-a-participant-sees)), and it ends with their link:
|
|
157
|
+
`App#stream_running(principal)` is the proxy's running check (asked every `BridgeClient::STREAM_POLL`), false for a
|
|
158
|
+
participant once the grant is revoked or expired, so their stream closes within a poll of a revoke, including one
|
|
159
|
+
made by `chi share revoke` in another process; the page then reads a 401 and shows the ended-link page.
|
|
160
|
+
|
|
161
|
+
## What a participant sees
|
|
162
|
+
|
|
163
|
+
D5 (a): prompts and replies with their authors, one-line tool rows, turn boundaries, the queue and waiting states.
|
|
164
|
+
Not tool output or arguments, thinking, approvals' and questions' content, memories (text or names), context notes,
|
|
165
|
+
plugin steers, automatic and delegate input, cards, command output, the working set (the user decided: not), cost,
|
|
166
|
+
timing, host paths or the owner's prompt history. Every read a participant reaches goes through one module,
|
|
167
|
+
`Web::ParticipantView` (`lib/samagotchi/web/participant_view.rb`), whose every answer is a new hash built from named
|
|
168
|
+
fields: a field added to an event later reaches nobody until it is named there.
|
|
169
|
+
|
|
170
|
+
- **Events** (`.event`, the stream). `EVENTS` names the types a participant gets, each rebuilt by its own method:
|
|
171
|
+
`turn_started` (turn id, the author and name, `mine` for their own, the prompt only when a person sent the turn:
|
|
172
|
+
`ClientId.human?`, else `automatic`; `continue`), the turn's end (`turn_completed`, `turn_canceled`, `turn_failed`:
|
|
173
|
+
type and turn id), `generation_chunk` (its visible `text` only; a thinking-only chunk is dropped),
|
|
174
|
+
`generation_retrying` (`restarted`), `tool_call_started` (the tool's name, and for a file tool the path relative to
|
|
175
|
+
the session's folder when it is inside it; never when that folder is home or `/`), `tool_call_completed` (its status word), and bare markers (the type
|
|
176
|
+
alone) for the queue (`turn_enqueued`, `turn_dequeued`, `input_merged`), the owner's question, approval or Continue
|
|
177
|
+
offer and their answers, and `snapshot` / `reset`: the page re-reads its review data at each. Every other type,
|
|
178
|
+
known (`DROPPED`) or new, is dropped. `spec/web/participant_view_spec.rb` scans every `type: :name` chi emits and
|
|
179
|
+
every handler of the web's stream, and fails until a new one is named in `EVENTS` or `DROPPED`.
|
|
180
|
+
- **The frames** (`Web::ParticipantStreamFilter`). The Bridge's chunks are cut into frames (`\r?\n\r?\n`, a partial
|
|
181
|
+
one kept for the next chunk, one over 1 MiB dropped to its end); each frame's JSON goes through `.event` and is
|
|
182
|
+
written again (`id:` the Bridge's cursor when it looks like one, `event:` the filtered type); a comment is a
|
|
183
|
+
`: ping`; anything that doesn't parse is dropped. The reply's text goes out by whole lines, so a secret the model
|
|
184
|
+
streams over two chunks is redacted as one, and a private key block is withheld from its BEGIN line to its END
|
|
185
|
+
line; the rest of a line waits for its end, a tool call, the turn's end or the owner's question (or 400
|
|
186
|
+
characters). The owner's stream never goes through it (a byte pipe).
|
|
187
|
+
- **The conversation** (`.messages`, over `App#messages_for_display(parts: true)`): a user message only when it is a
|
|
188
|
+
turn's prompt (it has the turn's id) or a person's merged line, and never one from a source that isn't a person's;
|
|
189
|
+
so the owner's `!` line and its output, a delegate's report (a wake turn's too), and the notes chi adds (a declined
|
|
190
|
+
Continue) never show. A turn no person sent (its record's `automatic`, `SessionMetrics`: a task's or a plugin's,
|
|
191
|
+
read from analytics.json and the live worker's records; the running turn's by its start; a delegate child's first
|
|
192
|
+
turn, its parent's task) shows as an automatic turn without its prompt, as the stream does. A raw loop's saved
|
|
193
|
+
reply is cut before the display reads it (`.visible_raw`): what came before a bare `</think>` and everything from
|
|
194
|
+
an unclosed thought or tool call on, which the display would only untag. Each
|
|
195
|
+
prompt carries its author and an image count; replies their text and their step's tool rows. Notes, steers and
|
|
196
|
+
empty-answer markers are dropped. The reply's
|
|
197
|
+
`html` is rendered from the filtered text. The turn in progress (`.current_turn`, over the Bridge snapshot's) gives
|
|
198
|
+
its start's fields, the text so far, its tool rows (with `running`) and `waiting_for_owner`.
|
|
199
|
+
- **The queue.** Their own queued turns (enqueued id and text, for Take back) and how many others wait.
|
|
200
|
+
- **Redaction** (`ParticipantView::Redact`, on every string that goes out): secret shapes (`sk-…`, `ghp_…`,
|
|
201
|
+
`github_pat_…`, `AKIA…`, `xox…-`, `chp_…`, `AIza…`, `rk_live_…`, JWTs, Slack webhooks, private key blocks, an
|
|
202
|
+
`Authorization:` header's value, any `…token` / `…api_key` / `…secret` / `…password` value, `=` or `:`, quoted or
|
|
203
|
+
not, and a URL's password), the owner's web token, the session's folder (shown as relative paths) and the home
|
|
204
|
+
folder (`~`). Defence in depth only: the allowlist is the control; a reply that paraphrases a secret isn't caught.
|
|
205
|
+
- **Errors.** A participant's 404 and 500 carry fixed words, never an exception's (which may name a path).
|
|
206
|
+
- **The owner's participant view.** The owner reaches the same reads: `/?as=participant#s=<id>` serves the participant
|
|
207
|
+
page, `GET …/review` is always the filtered view, and `…/stream?as=participant` goes through the same filter, with
|
|
208
|
+
no participant of their own (nothing `mine`, no composer). It is the server's filter, never a page imitating it.
|
|
209
|
+
|
|
210
|
+
The red-team spec (`spec/participant_red_team_spec.rb`) reads the stream, the review, every show variant, `/output`
|
|
211
|
+
and an image as a participant, with an approval, a question, a tool's call and output, thinking, a memory, a steer,
|
|
212
|
+
a delegate report, an unknown event and host paths in the session, and finds none of them.
|
|
213
|
+
|
|
214
|
+
## The owner's controls
|
|
215
|
+
|
|
216
|
+
The info bar's participants chip (`participants_panel.js`) reads `GET …/participants` (which adds each one's `spend`
|
|
217
|
+
and `turns` from the saved analytics, `SessionMetrics.saved_by_author`) when the session opens, when the panel opens,
|
|
218
|
+
at a participant's turn end and on `participants_changed`. The panel invites (`POST …/participants`, whose answer
|
|
219
|
+
carries `qr_svg`, the link's QR, `Web::QR.svg`), revokes and sets caps through the owner routes below. A participant's
|
|
220
|
+
prompt carries their name and colour (`--author-1…6` in `tokens.css`, by id, the same on both pages), from the saved
|
|
221
|
+
message's `author_name`, `turn_started`'s, or the panel's list (`participant_names.js`).
|
|
82
222
|
|
|
83
223
|
## The owner's routes
|
|
84
224
|
|
|
@@ -89,9 +229,11 @@ participant streams close within a poll of a revoke, including one made by `chi
|
|
|
89
229
|
| `POST /api/sessions/:id/participants` | `name`, `kind` (`human`/`agent`), `ttl` (seconds) | 201 `{participant: grant, token, link, lan}`: the token and the link once; `link` names this chi web's LAN address (`http://IP:PORT/?token=…#s=<id>`), `lan` false when it has none | 400 `invalid_participant` (name, kind, ttl, an archived session); 404 |
|
|
90
230
|
| `DELETE /api/sessions/:id/participants[?id=p_…]` | | 200 `{revoked: [grant]}`: one participant, or the session's | 400 `invalid_participant` |
|
|
91
231
|
| `DELETE /api/participants` | | 200 `{revoked: [grant]}`: every session's | |
|
|
232
|
+
| `POST /api/sessions/:id/participants/:p/cap` | `cap` (dollars, 0 to 1000) | 200 `{participant: grant}`: their cap from their next turn on | 400 `invalid_participant` (the cap, no such active participant) |
|
|
92
233
|
|
|
93
234
|
A grant here is `Grant#public_h`: `id, session_id, kind, name, role, created_at, expires_at, revoked_at, revoked_by,
|
|
94
|
-
last_used_at, state` (`active | revoked | expired`)
|
|
235
|
+
last_used_at, state` (`active | revoked | expired`), `cap` (dollars) and `queue`. Never the token or its digest.
|
|
236
|
+
`POST …/participants` also takes `cap`. A grant from before caps takes the config's.
|
|
95
237
|
|
|
96
238
|
`chi share` (docs/cli.md) uses them through `--remote`, and `SessionAccess::Local`'s same-named operations here
|
|
97
239
|
(`participants`, `invite`, `revoke_participants`; `ParticipantView`, `Invitation`). A local invite's link names
|
|
@@ -105,16 +247,19 @@ neither is copied to a continue, a fork or a delegate (a continue starts with no
|
|
|
105
247
|
invited to has neither: its files stay byte for byte as they were.
|
|
106
248
|
|
|
107
249
|
- **`participants.json`** (0600, `Participants.write`): `{participants: [{id, kind, name, role, added_at,
|
|
108
|
-
expires_at, removed_at}]}`, rewritten by whoever changes a grant (chi web, `chi share`). Never a token or a digest.
|
|
250
|
+
expires_at, removed_at, cap}]}`, rewritten by whoever changes a grant (chi web, `chi share`). Never a token or a digest.
|
|
109
251
|
The worker reads it with `Participants.read` (P3a's system-prompt line, `ctx`).
|
|
110
252
|
- **`participants.jsonl`** (0600, append-only, `Participants.log`): one JSON line per event, with `at`: `issued`
|
|
111
253
|
(participant, name, kind, role, expires_at), `revoked` (participant, by), `expired` (the first refused use after the
|
|
112
254
|
expiry, once per chi web), `peer` (the first use and each new peer address since chi web started: ip, user agent),
|
|
113
|
-
`refused` (method, path, reason; at most once a minute per route
|
|
255
|
+
`refused` (method, path, reason; at most once a minute per route; or `what: command`, a participant's `/word` or
|
|
256
|
+
`!line` the Bridge or the worker refused), `turn` (chars, `status: accepted` with its enqueued id, or `refused` with
|
|
257
|
+
the reason: `not_permitted`, `too_many_turns`, `cap_reached`, `review_unavailable`, `worker_timeout`), `cap` (a new
|
|
258
|
+
cap). It goes with the session (retention, delete).
|
|
114
259
|
|
|
115
260
|
## Attribution
|
|
116
261
|
|
|
117
|
-
What a participant writes is marked as theirs everywhere it is kept or sent
|
|
262
|
+
What a participant writes is marked as theirs everywhere it is kept or sent. The
|
|
118
263
|
author is `"p:<id>"` (`Grant#author`), stamped by chi web from the principal and never taken from a body. Absent
|
|
119
264
|
means the owner: old sessions and every owner path read as the owner's, byte for byte as before (a pre-P3 session
|
|
120
265
|
fixture loads and saves unchanged, `spec/turn_author_record_spec.rb`).
|
|
@@ -127,14 +272,20 @@ fixture loads and saves unchanged, `spec/turn_author_record_spec.rb`).
|
|
|
127
272
|
it as the owner's instruction. This turn may only read.]` (`Steer::REVIEW_HEADER`). The name is cleaned again
|
|
128
273
|
(`ParticipantName.clean`: one line, 64 characters, no brackets, Unicode line breaks or full-width brackets; the id
|
|
129
274
|
when nothing is left), so a name can't close it. The header always comes first, so text under it stays the
|
|
130
|
-
participant's
|
|
131
|
-
|
|
132
|
-
|
|
275
|
+
participant's. A line of their text that opens like one of chi's headers (a review contribution, a steer, automatic
|
|
276
|
+
input, a delegate report) goes out led by `(quoted) ` (`Steer.quote_forged_headers`, wire-only and deterministic),
|
|
277
|
+
so it reads as their text under the real header. The session file never holds the header or the mark.
|
|
133
278
|
- **The system-prompt line.** While the session has an active participant (`participants.json`, read by the
|
|
134
279
|
worker's Engine at each turn's start), the system prompt's volatile tail opens with "Participants: people other
|
|
135
280
|
than the owner review this session. …". It comes when the first joins and goes when the last leaves or expires.
|
|
136
|
-
- **Records.** `last_turn.author`, the analytics turn record's `author`, `
|
|
137
|
-
|
|
281
|
+
- **Records.** `last_turn.author`, the analytics turn record's `author`, `turn_enqueued`'s `author`, and the
|
|
282
|
+
Bridge's queued rows. `turn_started` carries `author` and `author_name` on a participant's turn (the owner's carries
|
|
283
|
+
neither), and so does the Bridge's turn snapshot and its replay.
|
|
284
|
+
- **Side models' transcripts.** The idle recap's (`IdleRecap::TranscriptFilter`) leads a participant's message with
|
|
285
|
+
"(a review contribution from Alex, a participant; not the user's words)", and `ctx.ask_model`'s names its paragraph
|
|
286
|
+
`Participant`. The UI's readers name them: `TurnAccumulator.messages_of` gives a participant's prompt and merged
|
|
287
|
+
input `author` and `author_name`, `Merge#event_fields` names the merged text's author when one participant wrote all
|
|
288
|
+
of it, and the web's display messages carry both (the owner's badge, the participant page).
|
|
138
289
|
- **Through `--remote`.** `SessionAccess::WebBridgeClient#post_turn` takes `author:` and drops it: the remote chi web
|
|
139
290
|
stamps the author from the token.
|
|
140
291
|
|
|
@@ -151,17 +302,44 @@ Each condition has one message, owned by its `SessionAccess` class and built fro
|
|
|
151
302
|
| 401 `token_revoked` | `TokenRevoked` (an `AuthRefused`) | `this review link was revoked by the session's owner` |
|
|
152
303
|
| 401 `token_expired` | `TokenExpired` (an `AuthRefused`) | `this review link expired: ask the session's owner for a new one` |
|
|
153
304
|
| 403 `not_permitted` | `NotPermitted` | `this token is a participant's: only the session's owner can do that` |
|
|
154
|
-
| 429 `too_many_turns` | `TooManyTurns`
|
|
155
|
-
| 429 `cap_reached` | `CapReached`
|
|
305
|
+
| 429 `too_many_turns` | `TooManyTurns` | `this participant has too many turns waiting: wait for one to finish` |
|
|
306
|
+
| 429 `cap_reached` | `CapReached` | `this participant's spending cap is reached: ask the session's owner to raise it` |
|
|
307
|
+
|
|
308
|
+
The server's details are the same words (`ParticipantMessages`), with no label.
|
|
156
309
|
|
|
157
310
|
A 401 `unauthorized` stays `AuthRefused`'s "the token was refused".
|
|
158
311
|
|
|
312
|
+
## The owner's terminal
|
|
313
|
+
|
|
314
|
+
The attached TUI (`TerminalUI::AttachedLoop`, local or through `--remote`) is the owner's only; there is no
|
|
315
|
+
participant TUI in v1.
|
|
316
|
+
|
|
317
|
+
- **Who is in the review.** The worker's `ParticipantsWatch` reads `participants.json` again when its mtime moves and
|
|
318
|
+
measures each grant's expiry at every look. On its idle tick (between turns, `Worker::FALLBACK_TICK_SECONDS`) the
|
|
319
|
+
worker announces `:participants_changed` (`Engine#announce_participants`: `{participants: [{id, name, kind}],
|
|
320
|
+
joined: […], left: [{…, reason: revoked | expired}]}`) to every UI; its start announces nobody. "Joined" means
|
|
321
|
+
invited: participants have no presence of their own yet. `session_state_snapshot`'s `participants` names who is in
|
|
322
|
+
the review now, so a UI that joins has them. Owner frames only: no participant stream carries them (P4a's filter).
|
|
323
|
+
- **Lines.** A participant's prompt (`turn_enqueued`'s `author`, an origin's, a queued row's, a saved message's
|
|
324
|
+
`author` / `author_name`) is led by their name (`Formatting#participant_prefix`: `Alex ▸ review>`, cleaned by
|
|
325
|
+
`ParticipantName`, the id when the TUI knows no name), in a colour picked from their id. Each change is one line per
|
|
326
|
+
participant; the status row counts who is in.
|
|
327
|
+
- **Commands.** `/share`, `/participants` (`TerminalUI::ParticipantCommands`) run `ShareCommand` for the attached
|
|
328
|
+
session through the link's `SessionAccess` (`AttachLink#session_access`: `Local`, or the remote's), so their words
|
|
329
|
+
and refusals are `chi share`'s, led by the command as typed. The REPL answers them with a note.
|
|
330
|
+
|
|
159
331
|
## Invariants
|
|
160
332
|
|
|
161
333
|
- The owner is a loopback peer or the owner's token; nothing else. A participant token never widens to the owner.
|
|
162
334
|
- A participant reaches one session, through the `:participant` rows only; another session is a 404.
|
|
163
335
|
- No token is written anywhere: the grants store has digests, the sidecar and the audit trail have neither.
|
|
164
336
|
- Owner sessions are unchanged: no sidecar, no audit file, no key in the session JSON until a grant exists.
|
|
165
|
-
-
|
|
166
|
-
|
|
337
|
+
- No participant reaches `/command`, `/answer`, `/output` or an image. Their turn's author and client id are stamped
|
|
338
|
+
from the principal, never the body.
|
|
339
|
+
- Everything a participant reads (their stream, the review, the show) is built by `ParticipantView` from named fields;
|
|
340
|
+
an event type it doesn't name is dropped. The owner's participant view reads through the same filter.
|
|
341
|
+
- A participant's turn is always a queued turn of its own (chi web, the Bridge, the inbox), never a command or a
|
|
342
|
+
shell line (chi web, the Bridge, the worker), and runs only on a worker with the review boundary, which denies
|
|
343
|
+
everything but reads of the project.
|
|
167
344
|
- A participant's message is never sent without its review header, and never merged into the owner's message.
|
|
345
|
+
- A participant's turn never answers or drops the owner's Continue offer: it waits until the owner has.
|
|
@@ -122,7 +122,8 @@ right-hand column happens.
|
|
|
122
122
|
and on a Messages host). The participants line opens the system prompt's volatile tail, after breakpoint #1:
|
|
123
123
|
a new session's cache is untouched. The Engine checks the sidecar at each turn's start and rebuilds (`reset!`)
|
|
124
124
|
only when the first participant joins and when the last leaves or expires: one history re-read each, then stable.
|
|
125
|
-
Under D3(a) the declared tools stay the session's, so an author switch costs nothing
|
|
125
|
+
Under D3(a) the declared tools stay the session's, so an author switch costs nothing: the review boundary is a
|
|
126
|
+
gate on the calls, not a toolkit switch. The forged-header mark (`(quoted) `) is a pure function of the text too.
|
|
126
127
|
- Side requests (idle recap, `/btw`) go through `IdleClient` with their own short system prompt. On a current
|
|
127
128
|
llama.cpp they don't evict the session (see the server settings below); chi never pins them to a slot.
|
|
128
129
|
|
|
@@ -106,7 +106,7 @@ pending: [Steering: add and unadd](#steering-add-and-unadd)) (`Engine#emit_worki
|
|
|
106
106
|
- once in `Engine#end_turn`, after `replace_session_messages` and before the end event, from the kept messages (the
|
|
107
107
|
turn-end apply has run by then);
|
|
108
108
|
- between turns, from `Engine#announce_working_set` (the session's messages, under the event lock; after a
|
|
109
|
-
`/working-set` op, and after an `/llm-context` change: the states are the next turn's strategy's): announced
|
|
109
|
+
`/working-set` op, after a `rollback_to`, and after an `/llm-context` change: the states are the next turn's strategy's): announced
|
|
110
110
|
(`working_set_updated` is in `ANNOUNCEABLE_EVENTS`; held after a command's `command_ran` inside
|
|
111
111
|
`holding_announcements`), under the *next* turn's strategy (`SessionModelSettings#turn_llm_context`, as the
|
|
112
112
|
turn-end warm-up reads it; `TurnSettings` keeps the last turn's, stale after `/llm-context` or `/model`). While a
|
|
@@ -115,7 +115,9 @@ pending: [Steering: add and unadd](#steering-add-and-unadd)) (`Engine#emit_worki
|
|
|
115
115
|
It is emitted only when the set (its items and adds) differs from the last one emitted for the session (kept per
|
|
116
116
|
session id), and never while the set is empty (no items, no adds), so a tool-free turn on a fresh session emits nothing. One exception: when the last one had
|
|
117
117
|
items and the set is empty now (a rollback removed the tool calls), one empty event goes out, so the page drops the
|
|
118
|
-
stale items; it is remembered, so the next empty request sends none.
|
|
118
|
+
stale items; it is remembered, so the next empty request sends none. `Engine#rollback_to` between turns (`!rollback`,
|
|
119
|
+
a canceled continue's restore, a failed turn's) announces the set at once (`announce_working_set`), so an emptied or
|
|
120
|
+
smaller set reaches the page then, not at the next request. It goes out whatever the web's flag;
|
|
119
121
|
the Bridge (`ClientEvent.project`) passes it on unchanged, and the attached TUI ignores it.
|
|
120
122
|
|
|
121
123
|
**The route.** `GET /api/sessions/:id/working_set` (`Web::App#handle_working_set`) answers the snapshot's `to_h`
|
|
@@ -288,3 +290,7 @@ at `opacity: .5`. A group shows `✂n` for its forgotten members. It is struck o
|
|
|
288
290
|
forgotten, and faded when all are stale or forgotten (`groupState`). The list's ops text says `stale` or `forgotten`
|
|
289
291
|
in words (colour is never alone) and shows `~1.2k` tokens per row (`tokensText`). A footprint adds `n stale` (plain; a file the turn's
|
|
290
292
|
end found changed shows there, the rail is gone by then) and `n forgotten`, struck. Titles add `(stale)` or `(forgotten)` per member.
|
|
293
|
+
The secondary text and the rail's rule read through their own tokens, a step brighter on the dark palette and the
|
|
294
|
+
base tokens on the light one: `--ws-faint` (the counts, the ops text, step tags; `--text-muted` on dark, `--text-faint`
|
|
295
|
+
on light), `--ws-muted` (the rail's head, the list's `files · n` rows, the drop/pin buttons; `--text-3` / `--text-muted`),
|
|
296
|
+
`--ws-rule` (the rail's left rule; `--border-strong` / `--border`) and `--ws-act-border` (the buttons' border).
|
data/docs/plugins.md
CHANGED
|
@@ -752,6 +752,7 @@ full label, `plugin.rb (bundle my-bundle)`.
|
|
|
752
752
|
| `ctx.model_key` | that model's memory overlay key (`<name>.<key>.md`, what `memory_write current_model_only` writes) |
|
|
753
753
|
| `ctx.cost` | the session's spend so far, read now, as a frozen value: `total` (USD, reported + estimated, side requests included; 0.0 before any), `reported` (by providers, `usage.cost`), `estimated` (from `hosts.<name>.models` prices; a generation is one or the other, never both), `side` (recap, `/btw`, `ask_model`: their share of `total`) and `known?` (false while generations or side requests ran but none was reported or priced, so a limit on it can't be enforced; a session with some priced generations reads known, the unpriced ones counting 0). It includes the running turn's finished generations and what earlier workers of the session spent. Use its methods, not its class. Needs chi 0.83.0 |
|
|
754
754
|
| `ctx.usage` | the session's work so far, read now, as a frozen value: `turns` (finished ones and the running one), `tool_calls` and `tool_calls_by_tool` (every call, running ones included; canonical names: an alias counts under its tool, a `call_tool` call under the inner tool), `steps` (the loop rounds that dispatched tools, `/stats`' iterations) and `active_ms` (`active_minutes` too): work time, the generations' time plus the finished tool calls' time, never the waits between them (a question, an approval, the user's answer to `ask_user_question`), with `tool_ms_by_tool` its tool part by tool, so a waiting tool's time (`task_wait`) can be left out. Its counts are the ones `/stats` and `chi sessions stats` show, earlier workers' included. Use its methods, not its class. Needs chi 0.100.0 (guard it with `ctx.respond_to?(:usage)`) |
|
|
755
|
+
| `ctx.turn_author` | who wrote the running turn when it isn't the session's owner: a participant's `"p:<id>"` (`chi share`); nil for the owner's turns and between turns. Every hook event of such a turn carries the same as `author:`. The handler runs with the owner's power whatever the author ([hooks](hooks.md#participants-turns)). Needs chi 0.105.0 (guard it with `ctx.respond_to?(:turn_author)`) |
|
|
755
756
|
| `ctx.settings` | the bundle's settings, frozen: config.yml's `bundles: <bundle>:` with the session's own `--set` values laid over it ([Per-session settings](#per-session-settings)); the config alone until the session is bound (in `register` and `chi.ctx`) |
|
|
756
757
|
| `ctx.data_dir` | `$XDG_STATE_HOME/samagotchi/plugins/<bundle>/`, created on first use. A session's own state goes in `sessions/<id>.json` or `sessions/<id>/` there: chi removes it when the session is deleted, discarded or pruned |
|
|
757
758
|
| `ctx.log` | `ctx.log.info(:event, key: value)`: debug-log records tagged `plugins`, with `bundle=<bundle>` |
|
|
@@ -1192,6 +1193,7 @@ bundle.http.stub(:post, "http://127.0.0.1:9000/classify", json: { label: "safe"
|
|
|
1192
1193
|
bundle.set(running: false) # the session's values: cwd:, messages:, cancelled: …
|
|
1193
1194
|
bundle.set(cost: 2.5) # ctx.cost: reported USD, or { reported:, estimated:, side:, known: }
|
|
1194
1195
|
bundle.set(usage: { tool_calls: 40, active_ms: 600_000 }) # ctx.usage: { turns:, tool_calls:, tool_calls_by_tool:, steps:, active_ms:, tool_ms_by_tool: }
|
|
1196
|
+
bundle.set(turn_author: "p:p_0123abcd") # a participant's turn: ctx.turn_author, and author: on every event
|
|
1195
1197
|
bundle.set(session_settings: { "cap" => 1 }) # the session's --set values (Bundle.load takes session_settings: too)
|
|
1196
1198
|
bundle.tool_loads.last.names # ctx.load_tools calls; set(load_tools_staged: [...]) limits what they stage
|
|
1197
1199
|
bundle.shutdown
|
data/docs/testing.md
CHANGED
|
@@ -138,7 +138,9 @@ When `parallel_rspec` fails in CI's `rspec` or `rspec-macos` job, the next step
|
|
|
138
138
|
only the failed examples, once, with plain `rspec`. It reads their ids from `tmp/parallel_failures.log`
|
|
139
139
|
(`.rspec_parallel`'s `FailuresLogger`) and writes the job summary: **Flaky: passed on rerun** or **Failed twice**,
|
|
140
140
|
with the example ids, the Ruby version and the job. It skips the rerun when no ids were logged (a load error) or more
|
|
141
|
-
than 20 failed (a real breakage). The job fails either way: a flake is a bug to fix, not a pass.
|
|
141
|
+
than 20 failed (a real breakage). The job fails either way: a flake is a bug to fix, not a pass. The `bundles` job
|
|
142
|
+
does the same after `rake bundles:specs`: that run writes rspec's `--format json` report to
|
|
143
|
+
`tmp/bundle_failures.json` (the failed examples' ids and full descriptions, as the main suite's summary has), and the rerun (`--rspec`) runs on the bundle test kit alone, as the specs ran.
|
|
142
144
|
|
|
143
145
|
Every flake is classified into one of the four patterns, each with its own fix:
|
|
144
146
|
|
|
@@ -86,7 +86,7 @@ module Samagotchi
|
|
|
86
86
|
text.clear
|
|
87
87
|
end
|
|
88
88
|
unless turn[:prompt].nil?
|
|
89
|
-
prompt = { role: "user", content: turn[:prompt].to_s }
|
|
89
|
+
prompt = { role: "user", content: turn[:prompt].to_s, **author_of(turn) }
|
|
90
90
|
prompt[:images] = turn[:images] if turn[:images]
|
|
91
91
|
messages << prompt
|
|
92
92
|
end
|
|
@@ -95,7 +95,7 @@ module Samagotchi
|
|
|
95
95
|
when "text" then text << part[:text].to_s
|
|
96
96
|
when "input"
|
|
97
97
|
flush.call
|
|
98
|
-
messages << { role: "user", content: part[:text].to_s }
|
|
98
|
+
messages << { role: "user", content: part[:text].to_s, **author_of(part) }
|
|
99
99
|
when "steer"
|
|
100
100
|
flush.call
|
|
101
101
|
messages << Steer.message(text: part[:text], source: part[:source])
|
|
@@ -105,6 +105,12 @@ module Samagotchi
|
|
|
105
105
|
messages
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
+
# A turn's or an input part's author fields (author:, author_name:),
|
|
109
|
+
# {} for the owner's: the same keys a saved message carries.
|
|
110
|
+
def self.author_of(item)
|
|
111
|
+
Steer.author_fields(item[:author], item[:author_name])
|
|
112
|
+
end
|
|
113
|
+
|
|
108
114
|
# The turn in progress (#current_turn's shape, symbol keys) as the live
|
|
109
115
|
# events that would have drawn it, in order, as turn_events.js
|
|
110
116
|
# snapshotEvents replays it for the web (spec/shared/turn_snapshot.json
|
|
@@ -125,6 +131,7 @@ module Samagotchi
|
|
|
125
131
|
started = { type: :turn_started, prompt: turn[:prompt], origin: turn[:origin], continue: !!turn[:continue] }
|
|
126
132
|
started[:resumed] = true if turn[:resumed]
|
|
127
133
|
started[:started_at] = started_at if started_at
|
|
134
|
+
started.merge!(author_of(turn))
|
|
128
135
|
events = [with_images(started, turn[:images])]
|
|
129
136
|
text_iteration = nil # a text part is open for this iteration
|
|
130
137
|
close_text = lambda do
|
|
@@ -265,6 +272,7 @@ module Samagotchi
|
|
|
265
272
|
@working_set = nil
|
|
266
273
|
@turn[:images] = event[:images] if event[:images]
|
|
267
274
|
@turn[:cut_after] = event[:cut_after] if event[:cut_after]
|
|
275
|
+
Steer::AUTHOR_KEYS.each { |key| @turn[key] = event[key] if event[key] }
|
|
268
276
|
when :input_merged
|
|
269
277
|
@merged_origins = Array(event[:origins])
|
|
270
278
|
dequeue(@merged_origins)
|
|
@@ -330,7 +338,9 @@ module Samagotchi
|
|
|
330
338
|
# A steer-only merge (count 0) has no user part: the origins stay
|
|
331
339
|
# for the user lines' own merge.
|
|
332
340
|
unless event[:content].nil?
|
|
333
|
-
|
|
341
|
+
input = { kind: "input", iteration: event[:iteration], text: event[:content].to_s.dup, origins: @merged_origins }
|
|
342
|
+
Steer::AUTHOR_KEYS.each { |key| input[key] = event[key] if event[key] }
|
|
343
|
+
parts << input
|
|
334
344
|
@merged_origins = []
|
|
335
345
|
end
|
|
336
346
|
Array(event[:steers]).each do |steer|
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -22,6 +22,7 @@ require_relative "engine"
|
|
|
22
22
|
require_relative "session_commands"
|
|
23
23
|
require_relative "queued_command"
|
|
24
24
|
require_relative "steer"
|
|
25
|
+
require_relative "participants"
|
|
25
26
|
require_relative "image_store"
|
|
26
27
|
require_relative "log"
|
|
27
28
|
require_relative "version"
|
|
@@ -543,8 +544,11 @@ module Samagotchi
|
|
|
543
544
|
# Stop's last_turn carry the turn's enqueued_id (ReplyWait.own_turn).
|
|
544
545
|
# unqueue: POST turn/dequeue (#handle_dequeue_turn) takes a queued
|
|
545
546
|
# message back before it runs.
|
|
547
|
+
# review_turns: a participant's turn runs as its own, inside the review
|
|
548
|
+
# boundary (Guardrails::ReviewBoundary); a worker without it has none,
|
|
549
|
+
# so chi web sends it no participant's turn (Delivery::REVIEW_FEATURE).
|
|
546
550
|
FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
|
|
547
|
-
SessionInbox::UNQUEUE_FEATURE].freeze
|
|
551
|
+
SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE].freeze
|
|
548
552
|
|
|
549
553
|
# The routes, by method and what follows /session/:id/: a handler takes
|
|
550
554
|
# the session id and the request body and returns [headers, status, body].
|
|
@@ -916,6 +920,19 @@ module Samagotchi
|
|
|
916
920
|
end
|
|
917
921
|
|
|
918
922
|
delivery = Delivery.parse(raw_delivery)
|
|
923
|
+
# How many of a participant's turns may be running or waiting
|
|
924
|
+
# (chi web's, from their grant: its queue limit plus the running one).
|
|
925
|
+
pending_limit = nil
|
|
926
|
+
if Steer.participant_author?(author)
|
|
927
|
+
# A participant's message is a turn of its own, whatever the body
|
|
928
|
+
# asks (D6), and never a command or a shell line: chi web refuses
|
|
929
|
+
# those first, and this is the worker's own layer.
|
|
930
|
+
return participant_refused(sid, author, "command") if command_registry.command?(prompt.to_s)
|
|
931
|
+
|
|
932
|
+
delivery = Delivery::QUEUE
|
|
933
|
+
pending_limit = fetched(parsed, "pending_limit")
|
|
934
|
+
pending_limit = nil unless pending_limit.is_a?(Integer) && pending_limit.positive?
|
|
935
|
+
end
|
|
919
936
|
|
|
920
937
|
# A session command sent as a message (chi send -m "/model x", chi -p,
|
|
921
938
|
# a UI whose command list lags) runs as the command, as typed in a
|
|
@@ -938,6 +955,7 @@ module Samagotchi
|
|
|
938
955
|
enqueued = @engine.synchronize_events do
|
|
939
956
|
raise IntakeClosed if stopped?
|
|
940
957
|
next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
|
|
958
|
+
next :too_many if pending_limit && pending_turns(author) >= pending_limit
|
|
941
959
|
|
|
942
960
|
queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
|
|
943
961
|
enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
@@ -953,6 +971,7 @@ module Samagotchi
|
|
|
953
971
|
end
|
|
954
972
|
end
|
|
955
973
|
return deadline_passed("turn") if enqueued == :expired
|
|
974
|
+
return [{}, 429, { error: "too_many_turns", detail: ParticipantMessages::TOO_MANY_TURNS }] if enqueued == :too_many
|
|
956
975
|
return [{}, 500, { error: "enqueue_failed", detail: "could not write turn input" }] unless enqueued
|
|
957
976
|
|
|
958
977
|
# Queued (outside the event lock): a message sent as +cut+ cuts a
|
|
@@ -1006,6 +1025,21 @@ module Samagotchi
|
|
|
1006
1025
|
dequeue_reply(taken, id, input)
|
|
1007
1026
|
end
|
|
1008
1027
|
|
|
1028
|
+
# +author+'s turns running or waiting: the running turn when it is
|
|
1029
|
+
# theirs, and their queued ones. Read with the event log held.
|
|
1030
|
+
def pending_turns(author)
|
|
1031
|
+
running = Steer.sender_author(@accumulator.current_turn&.dig(:origin, :author)) == author ? 1 : 0
|
|
1032
|
+
running + @accumulator.queued.count { |row| row[:author] == author }
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# 403 not_permitted for a participant's +what+ (a command, a shell
|
|
1036
|
+
# line), with its audit line.
|
|
1037
|
+
def participant_refused(sid, author, what)
|
|
1038
|
+
Participants.log(sid, :refused, state_dir: File.dirname(session_dir), participant: author.delete_prefix("p:"), what: what,
|
|
1039
|
+
reason: "not_permitted")
|
|
1040
|
+
[{}, 403, { error: "not_permitted", detail: Participants::NOT_PERMITTED }]
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1009
1043
|
def dequeue_reply(taken, id, input)
|
|
1010
1044
|
case taken
|
|
1011
1045
|
when :removed then [{}, 200, { status: "removed", enqueued_id: id, prompt: input.prompt.to_s, session_id: @session_id }]
|
|
@@ -167,10 +167,13 @@ module Samagotchi
|
|
|
167
167
|
# the session's images/
|
|
168
168
|
# @param author [String, nil] who wrote it when it isn't the session's
|
|
169
169
|
# owner; sent only when given (SessionManager.deliver_turn)
|
|
170
|
-
|
|
170
|
+
# @param pending_limit [Integer, nil] with a participant's +author+: how
|
|
171
|
+
# many of their turns may be running or waiting (429 too_many_turns past it)
|
|
172
|
+
def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil, pending_limit: nil)
|
|
171
173
|
body = { session_id: @session_id, prompt: prompt, client_id: client_id }
|
|
172
174
|
body[:no_interrupt] = true if no_interrupt
|
|
173
175
|
body[:author] = author if author
|
|
176
|
+
body[:pending_limit] = pending_limit if author && pending_limit
|
|
174
177
|
body[:images] = images if images && !images.empty?
|
|
175
178
|
body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
|
|
176
179
|
body[:deadline] = deadline
|
data/lib/samagotchi/client_id.rb
CHANGED
|
@@ -34,12 +34,16 @@ module Samagotchi
|
|
|
34
34
|
PLUGIN = "plugin"
|
|
35
35
|
# A relayed answer (RelayVerifier): relay:<parent8>.
|
|
36
36
|
RELAY_PREFIX = "relay:"
|
|
37
|
+
# A participant's review turn: review:<participant id> (chi web stamps
|
|
38
|
+
# it from the participant's token, never from the request's body).
|
|
39
|
+
REVIEW_PREFIX = "review:"
|
|
37
40
|
|
|
38
|
-
# Input a human typed: a web tab, a chi TUI, chi send,
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
41
|
+
# Input a human typed: a web tab, a chi TUI, chi send, a participant
|
|
42
|
+
# (review:, a person or an agent they run: never automatic input), and
|
|
43
|
+
# nil (a worker's initial prompt; a web client may send none). An
|
|
44
|
+
# allowlist: delegates, plugins, reminders, context changes and any id
|
|
45
|
+
# chi doesn't know are not.
|
|
46
|
+
HUMAN_PREFIXES = [WEB_PREFIX, TUI_PREFIX, REVIEW_PREFIX].freeze
|
|
43
47
|
HUMAN_IDS = [CLI_SEND].freeze
|
|
44
48
|
|
|
45
49
|
# A person at a screen that shows a command's reply and can answer it
|
|
@@ -47,16 +51,17 @@ module Samagotchi
|
|
|
47
51
|
INTERACTIVE_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
|
|
48
52
|
|
|
49
53
|
# A turn's origin by its sender (#origin, LastTurn#origin): the ids chi
|
|
50
|
-
# runs turns for on its own, by prefix
|
|
51
|
-
# anything else "client".
|
|
54
|
+
# runs turns for on its own, by prefix, and a participant's ("review");
|
|
55
|
+
# system:reminder is "reminder", anything else "client".
|
|
52
56
|
ORIGIN_PREFIXES = { DELEGATE_PREFIX => "delegate", CHILD_PREFIX => "delegate_report",
|
|
53
|
-
CONTEXT_PREFIX => "context", TASK_PREFIX => "task" }.freeze
|
|
57
|
+
CONTEXT_PREFIX => "context", TASK_PREFIX => "task", REVIEW_PREFIX => "review" }.freeze
|
|
54
58
|
|
|
55
59
|
module_function
|
|
56
60
|
|
|
57
61
|
# What sent a turn: "delegate" (a delegating parent), "delegate_report"
|
|
58
62
|
# (a child's report), "context" (a context wake), "task" (a task finish
|
|
59
|
-
# wake), "reminder", else "client" (
|
|
63
|
+
# wake), "review" (a participant), "reminder", else "client" (the
|
|
64
|
+
# owner, chi send, a plugin, nil).
|
|
60
65
|
# @return [String]
|
|
61
66
|
def origin(client_id)
|
|
62
67
|
id = client_id.to_s
|
|
@@ -81,5 +86,12 @@ module Samagotchi
|
|
|
81
86
|
id = client_id.to_s
|
|
82
87
|
HUMAN_IDS.include?(id) || HUMAN_PREFIXES.any? { |prefix| id.start_with?(prefix) }
|
|
83
88
|
end
|
|
89
|
+
|
|
90
|
+
# The client id a participant's turn carries (chi web's, from their
|
|
91
|
+
# grant): review:<participant id>.
|
|
92
|
+
def review(participant_id) = "#{REVIEW_PREFIX}#{participant_id}"
|
|
93
|
+
|
|
94
|
+
# Whether +client_id+ is a participant's (review:).
|
|
95
|
+
def review?(client_id) = client_id.to_s.start_with?(REVIEW_PREFIX)
|
|
84
96
|
end
|
|
85
97
|
end
|