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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0e5c6acd4a2774291e421b71b7b5729b0e3031c9afaa46715f40a1d976ebed2
4
- data.tar.gz: 6390322560ddd65a847ea4d3a122b3751be778c40eb573d457c617880daa0e06
3
+ metadata.gz: 02be0edba759be799428a67ac174beba8a77c2869f3de8f677dec9a14977ae60
4
+ data.tar.gz: d28e0688aec5428264fc7a48162a7f4e03ea461371cc48aba4da3c64ecf2e9d8
5
5
  SHA512:
6
- metadata.gz: c1d209965a45e687b89280cbcecfe0b51798dcff1a418837b3604f4b3b1e69e8d0f210d8b4972c84912e274c7f738c322a2270af36833fe17049e999081a2705
7
- data.tar.gz: 9c5d2a4e2205da68d70d99db385ab3f79ebec1f83f8762baaf36d3ae466fc98204c68bcd93ab1868127e703f5d50eaed0dd363ac910541849298c3bbc72bb931
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.81.0...HEAD
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` prints one JSON object instead; 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))
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))
@@ -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, never in the session's `cost:`.
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 neither (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
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` (no message) waits until
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 won't
189
- answer, add `--dismiss` instead: chi does nothing it asked about, finishes
190
- with what it found, and the reply comes back (exit 0).
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,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.81.0
3
+ version: 0.82.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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
@@ -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 = @session&.id
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
- return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && command.to_s.match?(/\brm\b/)
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
- # lexer keeps no body).
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
- return true if tokens.any? { |_, value| value.include?(ShellLex::SUBST) } && command.to_s.match?(text)
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
- # The request's token counts and reported cost (a broadcast's triage
191
- # has no session to count them in). The cache counts show whether the
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 || {}
@@ -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).to_s[0, 300])
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.to_s[0, 300])
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 timeout ? "still running after #{format("%g", timeout)} s: #{attach}" : "still running: #{attach}"
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
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], turn: turn, queued: !turn.nil?)
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: Session.session_dir(id, state_dir: @state_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, turn: turn)
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
- kinds = hash["by_kind"].is_a?(Hash) ? hash["by_kind"] : {}
35
- new(**COUNTS.to_h { |key| [key, count(hash[key.to_s])] }, cost_sum: cost(hash["cost_sum"]),
36
- cost_estimate_sum: number(hash["cost_estimate_sum"]),
37
- by_kind: kinds.each_with_object({}) do |(kind, row), rows|
38
- row = row.is_a?(Hash) ? row.transform_keys(&:to_s) : {}
39
- rows[kind.to_s] = { requests: count(row["requests"]), cost_sum: cost(row["cost_sum"]),
40
- cost_estimate_sum: number(row["cost_estimate_sum"]) }.freeze
41
- end.freeze)
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
- def initialize(failure)
31
- @failure = failure
32
- error = failure.error
33
- super("#{failure.hook} raised on #{failure.event}: #{error.class}: #{error.message}")
34
- set_backtrace(error.backtrace) if error.backtrace
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 = Hooks::BundleLoader.namespace_for(@name).const_set(:"KitHooks#{object_id}", Module.new)
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 (first = failures.first)
335
- raise HookFailed, first
360
+ elsif failures.any?
361
+ raise HookFailed, failures
336
362
  end
337
363
  result
338
364
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.81.0"
4
+ VERSION = "0.82.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
- function focusWhenIdle(card, id) {
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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.81.0
4
+ version: 0.82.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov