samagotchi 0.81.0 → 0.82.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 +28 -1
- data/docs/cli.md +1 -1
- data/docs/configuration.md +2 -1
- data/docs/plugins.md +1 -0
- data/docs/sessions.md +1 -1
- data/docs/sub-agent.md +19 -8
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +9 -3
- data/lib/samagotchi/engine.rb +1 -1
- data/lib/samagotchi/guardrails/rm_targets.rb +4 -2
- data/lib/samagotchi/guardrails/shell_paths.rb +5 -3
- data/lib/samagotchi/idle_client.rb +2 -7
- data/lib/samagotchi/llm/http.rb +7 -2
- data/lib/samagotchi/parent_report.rb +7 -3
- data/lib/samagotchi/reply_wait.rb +68 -2
- data/lib/samagotchi/send_command.rb +47 -8
- data/lib/samagotchi/session_metrics/side_totals.rb +16 -9
- data/lib/samagotchi/session_metrics.rb +3 -0
- data/lib/samagotchi/testing/boundary.rb +15 -0
- data/lib/samagotchi/testing/bundle.rb +35 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +3 -1
- data/lib/samagotchi/web/public/question_cards.js +10 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 02be0edba759be799428a67ac174beba8a77c2869f3de8f677dec9a14977ae60
|
|
4
|
+
data.tar.gz: d28e0688aec5428264fc7a48162a7f4e03ea461371cc48aba4da3c64ecf2e9d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1df47ccd973923c62848fef02807f3982ede1b86ebf032425c1ecd311014103286803822a290b24f117d705e3e8f845b22cb4804e90cc731a8dd48a3946a3b6f
|
|
7
|
+
data.tar.gz: 3c901380929266d60c820ebba9ad3581bdb4392f498fbb43dcef6ba157e675c100a8dba752ee27a79d464692ffb0a407b75ead89961f38c89e6551bf8a9a6ada
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,32 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.82.0] - 2026-10-18
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `chi send --wait --format json` names the turn it waited for (`turn_id`), and `chi send --wait --turn T ID` waits
|
|
16
|
+
for exactly that turn: running, queued or the last one that ended. A parent waiting again after exit 3 or 4 uses it
|
|
17
|
+
(docs/sub-agent.md).
|
|
18
|
+
- Bundles: the experimental test kit's `before_tool_call_event` builds the real guardrail event without firing
|
|
19
|
+
bundle hooks.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- `chi send --wait ID` with no message, on an idle session whose last turn already ended, returns that turn (its
|
|
24
|
+
reply, or its end when it left none) instead of timing out `running`.
|
|
25
|
+
- Web: a question or approval card arriving in the stage no longer snaps the stage to it; the stage eases it into
|
|
26
|
+
view.
|
|
27
|
+
- Guardrails: `rm` and protected-path checks treat a `$(cat <<'EOF' … EOF)` commit or PR message as data; a
|
|
28
|
+
substitution that runs, a piped message and a real command beside it are still checked.
|
|
29
|
+
- A late side request (an idle recap, `ctx.ask_model`) after the engine moved to another session no longer recreates
|
|
30
|
+
the deleted old session's folder.
|
|
31
|
+
- HTTP: failed-request messages in the log are one short UTF-8 line.
|
|
32
|
+
- Bundles, test kit: one `HookFailed` names every hook that failed; the boundary scan catches a `require_relative`
|
|
33
|
+
into chi's `lib/`; `before_tool_call_event` says when the guardrails didn't dispatch the event.
|
|
34
|
+
|
|
35
|
+
Update with `chi update` (the system bundle 0.82.0), then restart `chi web`.
|
|
36
|
+
|
|
11
37
|
## [0.81.0] - 2026-10-18
|
|
12
38
|
|
|
13
39
|
### Added
|
|
@@ -3338,7 +3364,8 @@ and long-lived sessions.
|
|
|
3338
3364
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3339
3365
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3340
3366
|
|
|
3341
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3367
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.82.0...HEAD
|
|
3368
|
+
[0.82.0]: https://github.com/dm1try/samagotchi/compare/v0.81.0...v0.82.0
|
|
3342
3369
|
[0.81.0]: https://github.com/dm1try/samagotchi/compare/v0.80.0...v0.81.0
|
|
3343
3370
|
[0.80.0]: https://github.com/dm1try/samagotchi/compare/v0.79.0...v0.80.0
|
|
3344
3371
|
[0.79.0]: https://github.com/dm1try/samagotchi/compare/v0.78.0...v0.79.0
|
data/docs/cli.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
- `chi sessions list|stop|restart|archive|unarchive|delete|prune|clean|stats` — manage persisted sessions; `list` shows this git project's, `list --scope=all` every one, a delegated session with `↳ <parent>`, `list --archived` the archived ones too (see [Sessions](sessions.md)). `stats ID` prints a session's cost, tokens and progress (a live worker's `GET stats`, else its saved `analytics.json`; `--format json` one object) without running a model turn. A usage error (an unknown subcommand or flag, a flag missing its value, `stop`, `restart` or `delete` with no ids, a bad `list --format` or `--scope`, a `--days`, `--keep` or `--limit` that isn't a whole number) exits 2; an unknown or refused session exits 1
|
|
19
19
|
- `chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all` — add a context note (TEXT or stdin) to sessions: background the model sees on its next turn; it starts no turn (see [Sessions: Context notes](sessions.md#context-notes))
|
|
20
20
|
- `chi context <add|push|ls|show|refresh|rm|mute|unmute> …` — attach live external text to sessions (a command chi runs every so often, or text pushed in); the model gets a short note when it changes and reads it with `context_read`; `chi context add <URL>` attaches a GitHub PR with the github-pr bundle (see [Attached context](context.md))
|
|
21
|
-
- `chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it; `--queue` runs it as a turn of its own after the running one, and `--queue --wait` waits for that turn's own answer); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending; `--format json`
|
|
21
|
+
- `chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it; `--queue` runs it as a turn of its own after the running one, and `--queue --wait` waits for that turn's own answer); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending — to an idle session whose last turn already ended, that ended turn's reply or end comes back; `--wait --turn T ID` with no message waits for exactly turn T, whether running, queued or last ended; `--format json` includes its `turn_id` when known, for waiting again after exit 3 or 4; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
|
|
22
22
|
- `chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]` (or `--dismiss`) — answer the question a session waits on (the one `chi send --wait` exited 3 with), then wait and print what comes next as `chi send --wait` does: the reply (0), the next question (3), still running after `--timeout` (4). `--option` is 1-based or the label, repeated on a multi-select question; `--text` is free text, or a Deny's reason; `--dismiss` leaves it unanswered and the model finishes its reply. A question no longer open (the web answered first) isn't answered again: it waits for that turn's reply. An option the question doesn't offer exits 2; a worker that is gone exits 1 (`send the task again: chi send --wait -m "…" ID`). An approval can be denied, not allowed, unless `guardrails.parent_approvals: once` (a convention, not a security boundary; see [Guardrails](guardrails.md#approvals-from-a-parent-agent))
|
|
23
23
|
- `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text or images to a session (live, stopped or new) as a message (⏎, a turn runs) or a context note (⌘⏎) (see [Desktop helper](desktop.md))
|
|
24
24
|
- `chi models [--format text|json] [--timeout S] [TEXT]` — list the models every configured host offers, as the names `--model` takes (see [Listing the models](#listing-the-models))
|
data/docs/configuration.md
CHANGED
|
@@ -575,7 +575,8 @@ hosts:
|
|
|
575
575
|
- A server that sends no cached counts has its whole prompt priced at `input`: an overestimate.
|
|
576
576
|
- Side requests (the idle recap, `/btw`, a plugin's `ctx.ask_model`) are priced by the same rule from the price of
|
|
577
577
|
the model they asked on its host: the recap's host and model (`recap.*`, else the session's), `ask_model(model:)`'s
|
|
578
|
-
host. They count on `/stats`' own `side requests:` line,
|
|
578
|
+
host. A recap using only `recap.base_url` has no host price. They count on `/stats`' own `side requests:` line,
|
|
579
|
+
never in the session's `cost:`.
|
|
579
580
|
|
|
580
581
|
## Thinking
|
|
581
582
|
|
data/docs/plugins.md
CHANGED
|
@@ -1134,6 +1134,7 @@ require "samagotchi/testing"
|
|
|
1134
1134
|
bundle = Samagotchi::Testing::Bundle.load("path/to/my-bundle", settings: { "every" => 3 },
|
|
1135
1135
|
host: { session_id: "s1", cwd: Dir.pwd })
|
|
1136
1136
|
bundle.fire(:after_turn, status: "completed") # → the event, after the hooks
|
|
1137
|
+
event = bundle.before_tool_call_event({ name: "read", path: "a.txt" }, params: "read a.txt")
|
|
1137
1138
|
bundle.command("/mine stop") # → what the command returned
|
|
1138
1139
|
bundle.tool("my_tool", path: "a.txt") # → the text the model reads
|
|
1139
1140
|
verdict = bundle.tool_call({ name: "execute", content: "rm -rf /" })
|
data/docs/sessions.md
CHANGED
|
@@ -208,7 +208,7 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
208
208
|
- `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would merge into it, and its reply would come back as the answer. `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
|
|
209
209
|
- Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
|
|
210
210
|
- `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
|
|
211
|
-
- `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs. With
|
|
211
|
+
- `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs; with an idle session whose last turn already ended, that ended turn is the answer: its reply, or its end when it left no reply — a turn that finished in the gap between waits is reported, not waited for. With none of those (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
|
|
212
212
|
- `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level and toolkit, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool` and `--no-tool` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1, and a `--model` it can't start on is `chi send: could not start a session: …`, exit 1, nothing archived. A fallback to the default model (below) prints `chi send: warning: …`.
|
|
213
213
|
- The 16 KiB cap applies: `git diff | chi send --new …` on a big diff is refused; name the branch in the message instead and let the session read it.
|
|
214
214
|
- A session nobody attaches to stalls at its first question or guardrail ask until someone answers it (`chi answer`, the web, `chi --attach`), and its worker stays up meanwhile: it doesn't idle-exit while a question waits (`chi sessions list` shows it as `waiting`; `chi sessions stop ID` frees it). Otherwise it idle-exits after `session.idle_exit_minutes` like any worker. These are ordinary sessions: delete them like any other.
|
data/docs/sub-agent.md
CHANGED
|
@@ -30,6 +30,7 @@ sources](context.md#peer-sources-the-peer-loop).
|
|
|
30
30
|
```sh
|
|
31
31
|
chi send --new --wait --format json [--dir D] [--model M] [--timeout S] -m "task"
|
|
32
32
|
chi send --wait --format json [--timeout S] [-m "follow-up"] ID
|
|
33
|
+
chi send --wait --format json [--timeout S] --turn T ID
|
|
33
34
|
chi send --queue --wait --format json [--timeout S] -m "follow-up" ID
|
|
34
35
|
chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]
|
|
35
36
|
chi answer ID --question QID --dismiss [--timeout S] [--format json]
|
|
@@ -60,6 +61,11 @@ Exit codes, both commands:
|
|
|
60
61
|
|
|
61
62
|
With `--format json` stdout is one JSON object (see
|
|
62
63
|
[Starting a session](sessions.md#starting-a-session) for every field):
|
|
64
|
+
when the wait is about a known turn, it includes `turn_id`. Use
|
|
65
|
+
`chi send --wait --format json --turn <turn_id> <session_id>` to wait for
|
|
66
|
+
that exact turn again after exit 4, or after exit 3 if someone else will
|
|
67
|
+
answer the question. A turn id can name the running turn, a queued turn or
|
|
68
|
+
the last turn that ended; any other id is an error.
|
|
63
69
|
|
|
64
70
|
```json
|
|
65
71
|
{"status":"question","session_id":"297da360-…","question":{"id":"3f1c…","kind":"question",
|
|
@@ -115,7 +121,7 @@ a parent may deny it (`--option Deny --text "why"`, `--text` alone, or
|
|
|
115
121
|
and tell your user`. For an approval, the question block and `answer_with` give
|
|
116
122
|
the deny command, not `--option N` or `chi --attach`. The block also offers to
|
|
117
123
|
leave it open: tell your user it waits in chi web (the bell and badge show
|
|
118
|
-
it there), and `chi send --wait --format json ID`
|
|
124
|
+
it there), and `chi send --wait --format json --turn <turn_id> ID` waits until
|
|
119
125
|
they answer it. With
|
|
120
126
|
`guardrails.parent_approvals: once` in chi's config.yml (it has no environment
|
|
121
127
|
variable), "Allow once" goes through; the wider scopes never do,
|
|
@@ -161,7 +167,11 @@ answers from any local process.
|
|
|
161
167
|
- `chi send --wait ID` with no message waits past a question already reported
|
|
162
168
|
(the step-limit question too), until someone answers it. It waits for the
|
|
163
169
|
turn running when it starts (or the one the step-limit question continues),
|
|
164
|
-
and prints that turn's reply, not a queued one's after it.
|
|
170
|
+
and prints that turn's reply, not a queued one's after it. To an idle
|
|
171
|
+
session whose last turn already ended (no question standing), that ended
|
|
172
|
+
turn is the answer: its reply comes back (`answered`), or its end when it
|
|
173
|
+
left no reply (`failed`, `canceled`, `limit`, …) — so a turn that finished
|
|
174
|
+
in the gap between waits is reported, not waited for.
|
|
165
175
|
- An answer to a question that is no longer open (you answered it in the web
|
|
166
176
|
first) isn't sent; `chi answer` says so and waits for what comes next. Its
|
|
167
177
|
JSON then has `"answered_here": false` (absent when the answer went in), so
|
|
@@ -185,18 +195,19 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
|
|
|
185
195
|
user, showing them the question and its options; if you can't ask (no user in
|
|
186
196
|
this run), stop and report the question and options instead of guessing. To
|
|
187
197
|
answer, run the `answer_with` command with `--option N` (1-based, repeat for
|
|
188
|
-
multi-select) and/or `--text "…"`. It returns the same JSON. If you
|
|
189
|
-
|
|
190
|
-
|
|
198
|
+
multi-select) and/or `--text "…"`. It returns the same JSON. If you leave it
|
|
199
|
+
open for someone else to answer, wait again with `--turn <turn_id>` from the
|
|
200
|
+
JSON. If you won't answer, add `--dismiss` instead: chi does nothing it asked
|
|
201
|
+
about, finishes with what it found, and the reply comes back (exit 0).
|
|
191
202
|
- `question.kind: approval`: chi wants to run something its user's guardrails
|
|
192
203
|
flagged, and allowing it is up to your user. Deny it (the `answer_with`
|
|
193
204
|
command: `--option Deny --text "why"`), and tell your user what it wanted to
|
|
194
205
|
run and why. Or leave it open: tell your user it waits in chi web (the
|
|
195
|
-
session's short id), then `chi send --wait --format json --timeout 500 <session_id>`
|
|
206
|
+
session's short id), then `chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`
|
|
196
207
|
waits until they answer it there. Never allow it yourself, through
|
|
197
208
|
`chi answer`, a piped `chi --attach` or chi's web API.
|
|
198
|
-
- `status: running` (exit 4): still working. Wait again:
|
|
199
|
-
`chi send --wait --format json --timeout 500 <session_id>`.
|
|
209
|
+
- `status: running` (exit 4): still working. Wait for that same turn again:
|
|
210
|
+
`chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`.
|
|
200
211
|
- `question.kind: continue`: chi ran out of steps before it answered. If it is
|
|
201
212
|
getting somewhere, continue it (`answer_with`, `--option Continue`, with
|
|
202
213
|
`--text "…"` to steer it on the way); if not,
|
|
@@ -62,19 +62,25 @@ module Samagotchi
|
|
|
62
62
|
# a --wait with no message is about (ReplyWait.own_turn)
|
|
63
63
|
# @param queued [Boolean] +turn+ is a queued message's (ReplyWait's
|
|
64
64
|
# queued:)
|
|
65
|
+
# @param ended_before [Boolean] a last_turn that ended before the
|
|
66
|
+
# baseline is the answer (ReplyWait's ended_before:) — the
|
|
67
|
+
# --wait-with-no-message's settled session
|
|
68
|
+
# @param report_turn_id [String, nil] the turn id to include in the
|
|
69
|
+
# report, even when ReplyWait uses the next-reply path
|
|
65
70
|
# @return [Integer] the exit status
|
|
66
71
|
def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER, turn: nil,
|
|
67
|
-
queued: false)
|
|
72
|
+
queued: false, ended_before: false, report_turn_id: turn)
|
|
68
73
|
result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
|
|
69
74
|
owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL, turn: turn,
|
|
70
|
-
queued: queued)
|
|
75
|
+
queued: queued, ended_before: ended_before)
|
|
76
|
+
result.turn_id = report_turn_id if report_turn_id && !report_turn_id.to_s.empty?
|
|
71
77
|
result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
72
78
|
@reported = true
|
|
73
79
|
ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
|
|
74
80
|
json: @json, timeout: timeout, extra: report_extra)
|
|
75
81
|
rescue Interrupt
|
|
76
82
|
if @json
|
|
77
|
-
@stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled), session_id: id,
|
|
83
|
+
@stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled, turn_id: report_turn_id), session_id: id,
|
|
78
84
|
extra: report_extra))
|
|
79
85
|
@stdout.flush
|
|
80
86
|
@reported = true
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -569,7 +569,7 @@ module Samagotchi
|
|
|
569
569
|
block = @metrics.add_side(usage)
|
|
570
570
|
@session_observer.notify(type: :side_usage, kind: usage.kind, side: block)
|
|
571
571
|
end
|
|
572
|
-
sid = @
|
|
572
|
+
sid = @metrics.session_id
|
|
573
573
|
return if sid.nil? || turn_running?
|
|
574
574
|
return unless File.exist?(Session.session_file(sid, state_dir: session_state_dir))
|
|
575
575
|
|
|
@@ -33,8 +33,10 @@ module Samagotchi
|
|
|
33
33
|
# @param session_root [String] the session's repo root (or cwd)
|
|
34
34
|
def outside_tmp?(command, cwd:, tmp_roots:, session_root:, home: Dir.home, env: ENV)
|
|
35
35
|
tokens = ShellLex.lex(command.to_s)
|
|
36
|
-
# rm inside $(…) or backticks: the lexer keeps no body.
|
|
37
|
-
|
|
36
|
+
# rm inside $(…) or backticks: the lexer keeps no body. A message
|
|
37
|
+
# heredoc's text is data, so leave it out of this fallback.
|
|
38
|
+
code = ShellLex.code_text(command.to_s)
|
|
39
|
+
return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && code.match?(/\brm\b/)
|
|
38
40
|
|
|
39
41
|
session = ProtectedPaths.real(session_root.to_s)
|
|
40
42
|
roots = tmp_roots.reject { |tmp| ShellPaths.within?(session, tmp) }
|
|
@@ -16,8 +16,9 @@ module Samagotchi
|
|
|
16
16
|
# A word it can't resolve (another $VAR, $(…), a glob, a relative path
|
|
17
17
|
# after cd $X) or one that reads as a script (sh -c '…', ruby -e '…':
|
|
18
18
|
# spaces, quotes, operators in it) falls back to a text match (+text+);
|
|
19
|
-
# so does the whole command when it holds a $(…) or backticks (the
|
|
20
|
-
#
|
|
19
|
+
# so does the whole command when it holds a $(…) or backticks (the lexer
|
|
20
|
+
# keeps no body), except for message heredoc bodies ShellLex.code_text
|
|
21
|
+
# recognizes as data.
|
|
21
22
|
module ShellPaths
|
|
22
23
|
# A word that is a script rather than a path.
|
|
23
24
|
SCRIPT = /[\s;&|<>()'"`]/
|
|
@@ -42,7 +43,8 @@ module Samagotchi
|
|
|
42
43
|
roots = dirs.compact.map { |dir| ProtectedPaths.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
|
|
43
44
|
walk = Walk.new(cwd, home, env)
|
|
44
45
|
tokens = ShellLex.lex(command.to_s)
|
|
45
|
-
|
|
46
|
+
code = ShellLex.code_text(command.to_s)
|
|
47
|
+
return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && code.match?(text)
|
|
46
48
|
|
|
47
49
|
ShellLex.simple_commands(tokens).any? do |words|
|
|
48
50
|
walk.step(words).any? { |word, path| path ? names?(path, roots, tmp_roots) : word.match?(text) }
|
|
@@ -187,13 +187,8 @@ module Samagotchi
|
|
|
187
187
|
raise SummarizeError, "server returned no parseable assistant content (#{e.message})"
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
# host reused a cached prefix. A side request evicts a session's cached
|
|
193
|
-
# prompt only on a llama.cpp with no host-memory cache (--cache-ram 0)
|
|
194
|
-
# or an old single-slot build, or when pinned to the session's slot,
|
|
195
|
-
# which chi never does; paid hosts and current llama.cpp aren't
|
|
196
|
-
# affected (docs/internals/prompt-caching.md).
|
|
190
|
+
# Log reported request usage when cache counts are present; session
|
|
191
|
+
# side-usage counting is handled separately by #report_usage.
|
|
197
192
|
def log_usage(response, kind)
|
|
198
193
|
usage = response.usage
|
|
199
194
|
fields = usage&.cache_fields || {}
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -390,7 +390,7 @@ module Samagotchi
|
|
|
390
390
|
end
|
|
391
391
|
Log.public_send(failure_level, :http, event, **log_base(current), status: e.is_a?(ProviderError) ? e.status : nil,
|
|
392
392
|
ms: elapsed_ms(current), attempts: current[:attempts], error: e.class.name,
|
|
393
|
-
msg: (e.respond_to?(:summary) ? e.summary : e.message)
|
|
393
|
+
msg: log_message(e.respond_to?(:summary) ? e.summary : e.message))
|
|
394
394
|
raise
|
|
395
395
|
end
|
|
396
396
|
|
|
@@ -410,13 +410,18 @@ module Samagotchi
|
|
|
410
410
|
def retry_after(error, attempts, delay, on_retry, cancel_controller, current)
|
|
411
411
|
Log.warn(:http, "retry", **log_base(current), attempt: attempts, max_retries: @retry_policy.max, delay_s: delay,
|
|
412
412
|
status: error.is_a?(ProviderError) ? error.status : nil, error: error.class.name,
|
|
413
|
-
msg: error.message
|
|
413
|
+
msg: log_message(error.message))
|
|
414
414
|
on_retry&.call(attempt: attempts, max_retries: @retry_policy.max, next_delay: delay,
|
|
415
415
|
error_class: error.class.name, error_message: error.message,
|
|
416
416
|
status: error.is_a?(ProviderError) ? error.status : nil)
|
|
417
417
|
wait(delay, cancel_controller)
|
|
418
418
|
end
|
|
419
419
|
|
|
420
|
+
def log_message(value)
|
|
421
|
+
value.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
|
|
422
|
+
.gsub(/[[:cntrl:]\u2028\u2029]+/, " ").split.join(" ")[0, 300]
|
|
423
|
+
end
|
|
424
|
+
|
|
420
425
|
def check_status!(response)
|
|
421
426
|
status = response.code.to_i
|
|
422
427
|
return if status.between?(200, 299)
|
|
@@ -76,6 +76,7 @@ module Samagotchi
|
|
|
76
76
|
# @return [Hash] the --format json object
|
|
77
77
|
def json(result, session_id:, timeout: nil, extra: {})
|
|
78
78
|
report = { status: status(result), session_id: session_id }
|
|
79
|
+
report[:turn_id] = result.turn_id unless result.turn_id.to_s.empty?
|
|
79
80
|
case report[:status]
|
|
80
81
|
when "answered" then report[:text] = result.text.to_s
|
|
81
82
|
when "question"
|
|
@@ -122,7 +123,7 @@ module Samagotchi
|
|
|
122
123
|
text = if result.status == :waiting_for_answer
|
|
123
124
|
question_text(result.question, session_id: session_id)
|
|
124
125
|
else
|
|
125
|
-
"#{detail(result, session_id: session_id, timeout: timeout)}\n"
|
|
126
|
+
"#{detail(result, session_id: session_id, timeout: timeout, turn_id: result.turn_id)}\n"
|
|
126
127
|
end
|
|
127
128
|
stderr.print("#{command}: #{text}")
|
|
128
129
|
end
|
|
@@ -200,7 +201,7 @@ module Samagotchi
|
|
|
200
201
|
end
|
|
201
202
|
|
|
202
203
|
# What a wait that ended without an answer says, one line.
|
|
203
|
-
def detail(result, session_id:, timeout: nil)
|
|
204
|
+
def detail(result, session_id:, timeout: nil, turn_id: nil)
|
|
204
205
|
attach = "chi --attach #{session_id}"
|
|
205
206
|
case result.status
|
|
206
207
|
when :waiting_for_answer
|
|
@@ -217,7 +218,10 @@ module Samagotchi
|
|
|
217
218
|
when :error then "the worker failed: #{result.text}; #{attach} shows what happened"
|
|
218
219
|
when :worker_gone then "the worker is gone; #{attach} shows what happened"
|
|
219
220
|
when :stopped then "the session was stopped (chi sessions stop)"
|
|
220
|
-
else
|
|
221
|
+
else
|
|
222
|
+
wait_again = "chi send --wait --turn #{turn_id} #{session_id}" unless turn_id.to_s.empty?
|
|
223
|
+
hint = wait_again || attach
|
|
224
|
+
timeout ? "still running after #{format("%g", timeout)} s: #{hint}" : "still running: #{hint}"
|
|
221
225
|
end
|
|
222
226
|
end
|
|
223
227
|
|
|
@@ -52,8 +52,10 @@ module Samagotchi
|
|
|
52
52
|
# @!attribute session [Session, nil] the session as the look that
|
|
53
53
|
# decided the outcome loaded it (a caller's next baseline); nil for
|
|
54
54
|
# :canceled and :timeout
|
|
55
|
+
# @!attribute turn_id [String, nil] the id of the turn this result is
|
|
56
|
+
# about, when known
|
|
55
57
|
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
56
|
-
:stopped_by, :kept_steps, :session, keyword_init: true)
|
|
58
|
+
:stopped_by, :kept_steps, :session, :turn_id, keyword_init: true)
|
|
57
59
|
|
|
58
60
|
module_function
|
|
59
61
|
|
|
@@ -94,10 +96,18 @@ module Samagotchi
|
|
|
94
96
|
# @param queued [Boolean] the turn: input was sent as a queue (or with
|
|
95
97
|
# images), which a running turn never merges: no fallback, even when
|
|
96
98
|
# its file was taken before the first look
|
|
99
|
+
# @param ended_before [Boolean] a last_turn that ended before the
|
|
100
|
+
# baseline (chi send --wait with no message, on a session that went
|
|
101
|
+
# idle in the gap between waits) is the turn's end too: with the
|
|
102
|
+
# session idle and its last_turn still the baseline's, its end is
|
|
103
|
+
# the answer — its reply, or its no-reply outcome. Its reply file
|
|
104
|
+
# sorts at the cursor, not past it, and its end precedes the
|
|
105
|
+
# baseline, so without it the wait would time out "running" again.
|
|
106
|
+
# Not for a turn: wait (chi answer) whose own end it waits for.
|
|
97
107
|
# @return [Result]
|
|
98
108
|
# @raise [ArgumentError] no such session
|
|
99
109
|
def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
|
|
100
|
-
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false)
|
|
110
|
+
baseline: nil, owner_grace: nil, interject: nil, turn: nil, queued: false, ended_before: false)
|
|
101
111
|
deadline = timeout && (monotonic + timeout.to_f)
|
|
102
112
|
seen_running = false
|
|
103
113
|
gone_since = nil
|
|
@@ -159,6 +169,34 @@ module Samagotchi
|
|
|
159
169
|
|
|
160
170
|
return Result.new(status: :canceled) if cancelled.call
|
|
161
171
|
|
|
172
|
+
# The turn that ended before this wait (ended_before: chi send
|
|
173
|
+
# --wait with no message, on a session that went idle in the gap
|
|
174
|
+
# between waits): its last_turn still stands, the session is idle,
|
|
175
|
+
# so its end is the answer, not a wait that times out "running"
|
|
176
|
+
# again. Its reply file sorts at the cursor (the newest), not past
|
|
177
|
+
# it: it is the turn's when it is at least as new as the turn's
|
|
178
|
+
# end (the worker writes it after recording the ended_at), else the
|
|
179
|
+
# turn left no reply and an earlier turn's file is not its.
|
|
180
|
+
if ended_before && turn.nil? && session.status == Session::STATUS_IDLE &&
|
|
181
|
+
(ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
|
|
182
|
+
baseline && ended == baseline[:last_turn]
|
|
183
|
+
file = newest_reply(id, state_dir: state_dir)
|
|
184
|
+
if file && reply_is_turns?(file, ended)
|
|
185
|
+
reply = Result.new(status: :done, text: File.read(File.join(reply_dir(id, state_dir: state_dir), file)), file: file)
|
|
186
|
+
reply.session = settled(session, id, state_dir: state_dir)
|
|
187
|
+
return reply
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
return no_reply(session, baseline, fresh: true)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if ended_before && turn && session.last_turn&.enqueued_id == turn &&
|
|
194
|
+
!(session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn) &&
|
|
195
|
+
(ended = session.last_turn&.ended_at) && !ended.to_s.empty? &&
|
|
196
|
+
baseline && ended == baseline[:last_turn]
|
|
197
|
+
return no_reply(session, baseline, fresh: true)
|
|
198
|
+
end
|
|
199
|
+
|
|
162
200
|
if turn
|
|
163
201
|
# Only the turn's own end: the session runs another turn when the
|
|
164
202
|
# wait starts, and that one's end is not the answer. A last_turn
|
|
@@ -257,6 +295,18 @@ module Samagotchi
|
|
|
257
295
|
session.last_turn&.enqueued_id
|
|
258
296
|
end
|
|
259
297
|
|
|
298
|
+
# Whether +turn+ is a turn the caller can wait for: running now, queued
|
|
299
|
+
# as its own turn, or the last one to have ended.
|
|
300
|
+
def known_turn?(session, session_dir:, turn:)
|
|
301
|
+
id = turn.to_s
|
|
302
|
+
return false if id.empty?
|
|
303
|
+
|
|
304
|
+
running = session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == id
|
|
305
|
+
queued = SessionInbox.waiting_input(session_dir, id) == :turn_end
|
|
306
|
+
ended = session.last_turn&.enqueued_id == id
|
|
307
|
+
running || queued || ended
|
|
308
|
+
end
|
|
309
|
+
|
|
260
310
|
# Whether the session's worker sidecar names TURN_IDS_FEATURE.
|
|
261
311
|
def turn_ids?(session_dir)
|
|
262
312
|
WorkerSidecar.read(session_dir)&.features&.include?(TURN_IDS_FEATURE) || false
|
|
@@ -390,6 +440,22 @@ module Samagotchi
|
|
|
390
440
|
cursor.nil? || file > cursor
|
|
391
441
|
end
|
|
392
442
|
|
|
443
|
+
# Whether the reply file +file+ is the turn that ended at +ended_at+'s,
|
|
444
|
+
# not an earlier turn's: the worker records the ended_at, then writes
|
|
445
|
+
# the reply (SessionInbox.write_output: the name's timestamp is the
|
|
446
|
+
# write time, at least the ended_at); every earlier turn's file is
|
|
447
|
+
# older than the turn's end. The wait and the session run on one
|
|
448
|
+
# machine, so the name's local-time read is the writer's. A name that
|
|
449
|
+
# doesn't read as a time isn't the turn's.
|
|
450
|
+
def reply_is_turns?(file, ended_at)
|
|
451
|
+
name = file.to_s
|
|
452
|
+
return false if name.length < 23
|
|
453
|
+
|
|
454
|
+
Time.parse(ended_at.to_s) <= Time.strptime(name[0, 14], "%Y%m%d%H%M%S") + (name[14, 9].to_f / (10**9))
|
|
455
|
+
rescue ArgumentError
|
|
456
|
+
false
|
|
457
|
+
end
|
|
458
|
+
|
|
393
459
|
def monotonic
|
|
394
460
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
395
461
|
end
|
|
@@ -43,7 +43,7 @@ module Samagotchi
|
|
|
43
43
|
chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
|
|
44
44
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
45
45
|
chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
|
|
46
|
-
chi send --wait [--timeout S] [--format json] ID
|
|
46
|
+
chi send --wait [--timeout S] [--format json] [--turn T] ID
|
|
47
47
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
48
48
|
or a running one takes it at its next step. A stopped session's
|
|
49
49
|
worker starts.
|
|
@@ -91,7 +91,10 @@ module Samagotchi
|
|
|
91
91
|
the web). With no message (no -m, nothing piped)
|
|
92
92
|
nothing is sent: it waits for the session's next
|
|
93
93
|
reply, a running turn's too (after exit 3, 4 or 130,
|
|
94
|
-
wait again this way)
|
|
94
|
+
wait again this way). To an idle session whose last
|
|
95
|
+
turn already ended, that ended turn is the answer:
|
|
96
|
+
its reply, or its end when it left none. --turn T
|
|
97
|
+
waits for that running, queued or last-ended turn
|
|
95
98
|
--cut ask the running turn to cut the generation it is
|
|
96
99
|
streaming and take the message now (a generation that
|
|
97
100
|
has streamed only thinking for steer.cut_after; else
|
|
@@ -108,10 +111,12 @@ module Samagotchi
|
|
|
108
111
|
than queueing (it would steer; restart it)
|
|
109
112
|
--timeout S (--wait) give up after S seconds, exit 4 (the turn
|
|
110
113
|
goes on); default no limit
|
|
114
|
+
--turn T (--wait, no message) wait for this turn id, from the
|
|
115
|
+
JSON's turn_id; not with -m
|
|
111
116
|
--format json
|
|
112
117
|
(--wait) one JSON object on stdout, whatever the end:
|
|
113
118
|
status answered (text), question (question,
|
|
114
|
-
answer_with), running, or failed, canceled,
|
|
119
|
+
answer_with), turn_id when known, running, or failed, canceled,
|
|
115
120
|
limit, no_answer, error, worker_gone, stopped,
|
|
116
121
|
command (detail); with --cut or --queue also
|
|
117
122
|
delivery (what the worker applied: next_step, cut,
|
|
@@ -142,6 +147,7 @@ module Samagotchi
|
|
|
142
147
|
f.switch "--queue"
|
|
143
148
|
f.value "--timeout"
|
|
144
149
|
f.value "--format"
|
|
150
|
+
f.value "--turn"
|
|
145
151
|
# Starting a turn in every live session at once is too easy to do
|
|
146
152
|
# by accident.
|
|
147
153
|
f.refuse "--all", "there is no --all: name the sessions"
|
|
@@ -204,6 +210,7 @@ module Samagotchi
|
|
|
204
210
|
prompt = compose(utf8(read_stdin), utf8(options[:message]))
|
|
205
211
|
# Before the wait-only case, which would drop the images.
|
|
206
212
|
return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
|
|
213
|
+
return usage_error("--turn cannot be used with a message") if options[:turn] && prompt
|
|
207
214
|
return run_wait_only(options) if prompt.nil? && options[:wait] && !options[:new]
|
|
208
215
|
|
|
209
216
|
# The next link of a chain may start idle, waiting for its first message.
|
|
@@ -268,6 +275,8 @@ module Samagotchi
|
|
|
268
275
|
options[:timeout] = Float(options[:timeout], exception: false)
|
|
269
276
|
return usage_error("--timeout takes seconds") unless options[:timeout]&.positive?
|
|
270
277
|
end
|
|
278
|
+
return usage_error("--turn needs --wait") if options[:turn] && !options[:wait]
|
|
279
|
+
return usage_error("--turn is only for waiting without a message") if options[:turn] && (options[:new] || options[:queue] || options[:cut])
|
|
271
280
|
return usage_error("--wait takes one session") if options[:wait] && options[:ids].uniq.size > 1
|
|
272
281
|
return usage_error("at most #{MAX_IMAGES} images") if options[:images].size > MAX_IMAGES
|
|
273
282
|
return new_options(options) if options[:new]
|
|
@@ -388,7 +397,7 @@ module Samagotchi
|
|
|
388
397
|
return report_command(session.id) if @sent_command
|
|
389
398
|
|
|
390
399
|
wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
|
|
391
|
-
timeout: options[:timeout])
|
|
400
|
+
timeout: options[:timeout], report_turn_id: @sent_enqueued_id)
|
|
392
401
|
end
|
|
393
402
|
|
|
394
403
|
# The model the new session will run, for the images check: --model
|
|
@@ -450,6 +459,9 @@ module Samagotchi
|
|
|
450
459
|
|
|
451
460
|
cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
|
|
452
461
|
baseline = baseline_of(session)
|
|
462
|
+
running_turn = if session.status == Session::STATUS_RUNNING
|
|
463
|
+
ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
|
|
464
|
+
end
|
|
453
465
|
return 1 unless deliver(id, prompt)
|
|
454
466
|
return report_command(id) if @sent_command
|
|
455
467
|
|
|
@@ -457,7 +469,15 @@ module Samagotchi
|
|
|
457
469
|
# running at the check ended before it went in (an idle worker's ack
|
|
458
470
|
# says next_step, and the file still runs as that turn).
|
|
459
471
|
turn = @queue ? @sent_enqueued_id : nil
|
|
460
|
-
|
|
472
|
+
report_turn_id = if @queue
|
|
473
|
+
turn
|
|
474
|
+
elsif session.status == Session::STATUS_RUNNING
|
|
475
|
+
running_turn
|
|
476
|
+
else
|
|
477
|
+
@sent_enqueued_id
|
|
478
|
+
end
|
|
479
|
+
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?,
|
|
480
|
+
report_turn_id: report_turn_id)
|
|
461
481
|
end
|
|
462
482
|
|
|
463
483
|
# --wait with no message: the session's next reply, sending nothing,
|
|
@@ -468,16 +488,33 @@ module Samagotchi
|
|
|
468
488
|
# waits for whatever wakes one (the web, chi send) rather than calling
|
|
469
489
|
# it gone. The turn running now, or the one whose continue offer
|
|
470
490
|
# stands, is the one waited for (ReplyWait.own_turn): its reply, not a
|
|
471
|
-
# turn's queued behind it; with none known, the next reply.
|
|
491
|
+
# turn's queued behind it; with none known, the next reply. An idle
|
|
492
|
+
# session whose last turn already ended (no question standing) went
|
|
493
|
+
# idle in the gap between waits: that turn's end is the answer — its
|
|
494
|
+
# reply, or its no-reply outcome — so the wait returns it at once
|
|
495
|
+
# (ReplyWait's ended_before:) instead of timing out "running" again.
|
|
472
496
|
# @return [Integer] the exit status
|
|
473
497
|
def run_wait_only(options)
|
|
474
498
|
id = resolve(options[:ids].first) or return 1
|
|
475
499
|
session = Session.load(id, state_dir: @state_dir)
|
|
500
|
+
session_dir = Session.session_dir(id, state_dir: @state_dir)
|
|
501
|
+
requested_turn = options[:turn]&.to_s
|
|
502
|
+
if requested_turn && !ReplyWait.known_turn?(session, session_dir: session_dir, turn: requested_turn)
|
|
503
|
+
error_line("chi send: turn #{requested_turn} is not a running, queued, or last ended turn")
|
|
504
|
+
return 1
|
|
505
|
+
end
|
|
476
506
|
live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
|
|
477
|
-
turn = ReplyWait.own_turn(session, session_dir:
|
|
507
|
+
turn = requested_turn || ReplyWait.own_turn(session, session_dir: session_dir)
|
|
508
|
+
# The gate for the settled case: idle, a turn ended (last_turn), no
|
|
509
|
+
# question standing (a continue offer waits for its answer).
|
|
510
|
+
settled = session.status == Session::STATUS_IDLE && session.last_turn && session.pending_question.nil?
|
|
511
|
+
ended_before = requested_turn ? session.last_turn&.enqueued_id == requested_turn : settled
|
|
512
|
+
report_turn_id = turn
|
|
513
|
+
report_turn_id ||= session.last_turn&.enqueued_id if settled
|
|
478
514
|
wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
|
|
479
515
|
baseline: baseline_of(session).merge(messages: nil),
|
|
480
|
-
timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil,
|
|
516
|
+
timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil,
|
|
517
|
+
turn: turn, ended_before: ended_before, report_turn_id: report_turn_id)
|
|
481
518
|
end
|
|
482
519
|
|
|
483
520
|
# The session as it was before the message went in (ReplyWait's
|
|
@@ -514,6 +551,8 @@ module Samagotchi
|
|
|
514
551
|
return false
|
|
515
552
|
end
|
|
516
553
|
|
|
554
|
+
ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
|
|
555
|
+
@sent_enqueued_id = (ack["enqueued_id"] || ack[:enqueued_id])&.to_s
|
|
517
556
|
@sent_command = command_ack?(result[:ack])
|
|
518
557
|
@info.puts("#{session.id} started#{continued_words(session)}#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
|
|
519
558
|
true
|
|
@@ -31,14 +31,21 @@ module Samagotchi
|
|
|
31
31
|
return zero unless hash.is_a?(Hash)
|
|
32
32
|
|
|
33
33
|
hash = hash.transform_keys(&:to_s)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
new(
|
|
35
|
+
**COUNTS.to_h { |key| [key, count(hash[key.to_s])] },
|
|
36
|
+
cost_sum: cost(hash["cost_sum"]),
|
|
37
|
+
cost_estimate_sum: number(hash["cost_estimate_sum"]),
|
|
38
|
+
by_kind: saved_kinds(hash["by_kind"])
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.saved_kinds(hash)
|
|
43
|
+
kinds = hash.is_a?(Hash) ? hash : {}
|
|
44
|
+
kinds.each_with_object({}) do |(kind, row), rows|
|
|
45
|
+
row = row.is_a?(Hash) ? row.transform_keys(&:to_s) : {}
|
|
46
|
+
rows[kind.to_s] = { requests: count(row["requests"]), cost_sum: cost(row["cost_sum"]),
|
|
47
|
+
cost_estimate_sum: number(row["cost_estimate_sum"]) }.freeze
|
|
48
|
+
end.freeze
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
# These totals with one side request counted in.
|
|
@@ -87,7 +94,7 @@ module Samagotchi
|
|
|
87
94
|
|
|
88
95
|
def self.cost(value) = value.is_a?(Numeric) ? value : nil
|
|
89
96
|
|
|
90
|
-
private_class_method :count, :number, :cost
|
|
97
|
+
private_class_method :count, :number, :cost, :saved_kinds
|
|
91
98
|
|
|
92
99
|
private
|
|
93
100
|
|
|
@@ -351,6 +351,9 @@ module Samagotchi
|
|
|
351
351
|
end
|
|
352
352
|
end
|
|
353
353
|
|
|
354
|
+
# @return [String, nil] the session id #persist writes under
|
|
355
|
+
def session_id = @mutex.synchronize { @session_id }
|
|
356
|
+
|
|
354
357
|
# Count one side request (a recap, a plugin's ctx.ask_model) in the
|
|
355
358
|
# side block, apart from the turns' totals. Safe from any thread.
|
|
356
359
|
# @param usage [SideUsage]
|
|
@@ -9,6 +9,8 @@ module Samagotchi
|
|
|
9
9
|
# why: the APIs to design before that bundle could leave chi's repo
|
|
10
10
|
# (spec/samagotchi/testing/boundary_spec.rb keeps both exact).
|
|
11
11
|
module Boundary
|
|
12
|
+
LIB_DIR = File.expand_path("../..", __dir__)
|
|
13
|
+
|
|
12
14
|
# A name a bundle may use, and the file that defines it (a require of
|
|
13
15
|
# it is fine too). A name allows itself, its methods (".x") and what
|
|
14
16
|
# it holds ("::X"); one ending in a method or constant allows only that.
|
|
@@ -124,6 +126,8 @@ module Samagotchi
|
|
|
124
126
|
REFERENCE = /\bSamagotchi(?:::[A-Z]\w*)+(?:\.[a-z_]\w*[?!]?)?/
|
|
125
127
|
# A require of one of chi's files.
|
|
126
128
|
REQUIRE = %r{\brequire\s*\(?\s*["'](samagotchi(?:/[\w/]+)?)["']}
|
|
129
|
+
# A relative require that may leave the bundle.
|
|
130
|
+
REQUIRE_RELATIVE = /\brequire_relative\s*\(?\s*["']([^"']+)["']/
|
|
127
131
|
|
|
128
132
|
# One reference outside ALLOWED, in a file of the bundle.
|
|
129
133
|
Finding = Data.define(:reference, :file)
|
|
@@ -140,11 +144,22 @@ module Samagotchi
|
|
|
140
144
|
text = File.read(path)
|
|
141
145
|
refs = text.scan(REFERENCE).reject { |name| allowed?(name) }
|
|
142
146
|
refs += text.scan(REQUIRE).flatten.reject { |file| ALLOWED_REQUIRES.include?(file) }.map { |file| %(require "#{file}") }
|
|
147
|
+
refs += text.scan(REQUIRE_RELATIVE).flatten.filter_map do |relative|
|
|
148
|
+
target = File.expand_path(relative, File.dirname(path))
|
|
149
|
+
next unless within?(target, LIB_DIR) && !within?(target, File.expand_path(dir))
|
|
150
|
+
|
|
151
|
+
%(require_relative "#{relative}")
|
|
152
|
+
end
|
|
143
153
|
file = path.delete_prefix("#{File.expand_path(dir)}/")
|
|
144
154
|
refs.uniq.map { |reference| Finding.new(reference: reference, file: file) }
|
|
145
155
|
end
|
|
146
156
|
end
|
|
147
157
|
|
|
158
|
+
def within?(path, dir)
|
|
159
|
+
path == dir || path.start_with?("#{dir}#{File::SEPARATOR}")
|
|
160
|
+
end
|
|
161
|
+
private_class_method :within?
|
|
162
|
+
|
|
148
163
|
# Whether a bundle may use +name+ ("Samagotchi::X::Y", maybe ".method").
|
|
149
164
|
def allowed?(name)
|
|
150
165
|
ALLOWED.any? do |entry|
|
|
@@ -26,12 +26,17 @@ module Samagotchi
|
|
|
26
26
|
class HookFailed < StandardError
|
|
27
27
|
# @return [Hooks::Failure]
|
|
28
28
|
attr_reader :failure
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
# @return [Array<Hooks::Failure>]
|
|
30
|
+
attr_reader :failures
|
|
31
|
+
|
|
32
|
+
def initialize(failures)
|
|
33
|
+
@failures = failures.dup.freeze
|
|
34
|
+
@failure = @failures.first
|
|
35
|
+
super(@failures.map do |failure|
|
|
36
|
+
error = failure.error
|
|
37
|
+
"#{failure.hook} raised on #{failure.event}: #{error.class}: #{error.message}"
|
|
38
|
+
end.join("; "))
|
|
39
|
+
set_backtrace(@failure.error.backtrace) if @failure.error.backtrace
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
def cause = @failure.error
|
|
@@ -142,7 +147,7 @@ module Samagotchi
|
|
|
142
147
|
@plugin = built
|
|
143
148
|
end
|
|
144
149
|
end
|
|
145
|
-
@hooks_namespace =
|
|
150
|
+
@hooks_namespace = Module.new
|
|
146
151
|
@manifest.hooks.each do |basename, hook|
|
|
147
152
|
next if hook.event.empty?
|
|
148
153
|
|
|
@@ -168,6 +173,27 @@ module Samagotchi
|
|
|
168
173
|
payload
|
|
169
174
|
end
|
|
170
175
|
|
|
176
|
+
# Build the before_tool_call payload from the real Gate, without firing
|
|
177
|
+
# this bundle's hooks.
|
|
178
|
+
# @param call [Hash] the parsed tool call
|
|
179
|
+
# @param iteration [Integer]
|
|
180
|
+
# @param params [String] the call's one-line preview
|
|
181
|
+
# @param context [Guardrails::Context, nil]
|
|
182
|
+
# @param verdict [Verdict, nil]
|
|
183
|
+
# @return [Hash]
|
|
184
|
+
def before_tool_call_event(call, params:, iteration: 1, context: nil, verdict: nil)
|
|
185
|
+
context ||= Guardrails::Context.new(cwd: @recorder[:cwd], session_id: @recorder[:session_id],
|
|
186
|
+
interface: @recorder[:frontend])
|
|
187
|
+
dispatched = nil
|
|
188
|
+
capture = Hooks::Registry.new
|
|
189
|
+
capture.register(:before_tool_call) { |event| dispatched = event.dup }
|
|
190
|
+
gate = Guardrails::Gate.new(-> { capture }, context_lookup: -> { context }, tools_lookup: -> { tools })
|
|
191
|
+
gate.evaluate(call, iteration: iteration, params: params, verdict: verdict || Verdict.new(call: call))
|
|
192
|
+
raise ArgumentError, "guardrails did not dispatch a before_tool_call event for #{call[:name].inspect}" unless dispatched
|
|
193
|
+
|
|
194
|
+
dispatched.slice(:type, :iteration, :call, :params, :blocked, :block_reason, :guardrail, :context, :targets)
|
|
195
|
+
end
|
|
196
|
+
|
|
171
197
|
# One tool call through a real Guardrails::Gate: the before_tool_call
|
|
172
198
|
# hooks vote on a Testing::Verdict (pass +verdict+ to start from one,
|
|
173
199
|
# another voter's deny in it), with the context and targets the Gate
|
|
@@ -331,8 +357,8 @@ module Samagotchi
|
|
|
331
357
|
result = Hooks.capture_failures(failures, &)
|
|
332
358
|
if @lenient
|
|
333
359
|
@errors.concat(failures)
|
|
334
|
-
elsif
|
|
335
|
-
raise HookFailed,
|
|
360
|
+
elsif failures.any?
|
|
361
|
+
raise HookFailed, failures
|
|
336
362
|
end
|
|
337
363
|
result
|
|
338
364
|
end
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -1143,10 +1143,12 @@ function updateSessionTitle() {
|
|
|
1143
1143
|
}
|
|
1144
1144
|
|
|
1145
1145
|
// The stage takes a running turn's rows (its extras); else the history.
|
|
1146
|
+
// @return true when the stage took +el+ (it scrolls it into its view itself)
|
|
1146
1147
|
function appendToHistory(el) {
|
|
1147
1148
|
// (The stage's place first moves a turn still sliding away into the history.)
|
|
1148
|
-
if (turnDom.place?.(el)) return;
|
|
1149
|
+
if (turnDom.place?.(el)) return true;
|
|
1149
1150
|
appendAboveLiveTiming(historyEl, el, liveTurn.timing.el);
|
|
1151
|
+
return false;
|
|
1150
1152
|
}
|
|
1151
1153
|
|
|
1152
1154
|
// A turn-end re-read's timing (?tail=1&recent=1: the newest turn's records
|
|
@@ -26,7 +26,8 @@ import { plainText } from "./plain_line.js";
|
|
|
26
26
|
|
|
27
27
|
// @param sessionId () => the open session's id (answers go there)
|
|
28
28
|
// @param clientId this tab's id in the session's events
|
|
29
|
-
// @param place (card) puts a new card at the end of the history
|
|
29
|
+
// @param place (card) puts a new card at the end of the history; true: the
|
|
30
|
+
// stage took it (it brings the card into its view itself, eased)
|
|
30
31
|
// @param isNearBottom () => whether the history follows its end
|
|
31
32
|
// @param scrollToEnd () scrolls the history to its end
|
|
32
33
|
// @param removeHintIfEmpty () drops the history's placeholder hint
|
|
@@ -200,10 +201,10 @@ export function createQuestionCards({
|
|
|
200
201
|
|
|
201
202
|
// Read before the card makes the history taller.
|
|
202
203
|
const follow = isNearBottom();
|
|
203
|
-
place(card);
|
|
204
|
+
const staged = place(card) === true;
|
|
204
205
|
questionEl = card;
|
|
205
206
|
if (follow) scrollToEnd();
|
|
206
|
-
focusWhenIdle(card, pq.id);
|
|
207
|
+
focusWhenIdle(card, pq.id, { staged });
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
function isPlainEnter(e) {
|
|
@@ -213,7 +214,10 @@ export function createQuestionCards({
|
|
|
213
214
|
// A new card takes the focus while nobody is typing: the focus is on
|
|
214
215
|
// the page or an empty composer. Checked after the caller is done, so a
|
|
215
216
|
// card drawn already answered (the history replay) never takes it.
|
|
216
|
-
|
|
217
|
+
// +staged+: the stage holds the card and scrolls to it once it has grown
|
|
218
|
+
// for it (eased): the focus doesn't scroll, or the stage would snap to the
|
|
219
|
+
// card before its room eases in.
|
|
220
|
+
function focusWhenIdle(card, id, { staged = false } = {}) {
|
|
217
221
|
queueMicrotask(() => {
|
|
218
222
|
if (questionEl !== card || String(pending?.id) !== String(id)) return;
|
|
219
223
|
const prompt = doc.getElementById?.("prompt");
|
|
@@ -221,7 +225,8 @@ export function createQuestionCards({
|
|
|
221
225
|
const idle = !active || active === doc.body || (active === prompt && !prompt.value);
|
|
222
226
|
if (!idle || (prompt && prompt.value)) return;
|
|
223
227
|
const target = card.querySelector(".question-option input") || card.querySelector(".question-freeform");
|
|
224
|
-
target?.focus();
|
|
228
|
+
if (staged) target?.focus({ preventScroll: true });
|
|
229
|
+
else target?.focus();
|
|
225
230
|
});
|
|
226
231
|
}
|
|
227
232
|
|