samagotchi 0.85.0 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/bin/chi +39 -2
  4. data/docs/architecture.md +36 -5
  5. data/docs/bundle-authoring.md +125 -0
  6. data/docs/cli.md +51 -3
  7. data/docs/configuration.md +6 -5
  8. data/docs/guardrails.md +112 -39
  9. data/docs/hooks.md +19 -3
  10. data/docs/internals/anthropic.md +2 -2
  11. data/docs/internals/bundles.md +11 -2
  12. data/docs/internals/context-telemetry.md +19 -1
  13. data/docs/internals/engine.md +1 -1
  14. data/docs/memory.md +1 -1
  15. data/docs/plugins.md +59 -2
  16. data/docs/releasing.md +1 -1
  17. data/docs/sessions.md +19 -7
  18. data/docs/sub-agent.md +39 -25
  19. data/docs/testing.md +7 -2
  20. data/lib/samagotchi/answer_command.rb +21 -25
  21. data/lib/samagotchi/bridge/client_event.rb +50 -0
  22. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  23. data/lib/samagotchi/bridge.rb +22 -11
  24. data/lib/samagotchi/bridge_client.rb +2 -2
  25. data/lib/samagotchi/bundle_command/check.rb +348 -0
  26. data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
  27. data/lib/samagotchi/bundle_command/test_run.rb +89 -0
  28. data/lib/samagotchi/bundle_command.rb +125 -2
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  31. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  32. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
  34. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  35. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
  36. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
  38. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  39. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  40. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  41. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  42. data/lib/samagotchi/cli/parent_wait.rb +3 -1
  43. data/lib/samagotchi/client_id.rb +1 -1
  44. data/lib/samagotchi/config.rb +195 -115
  45. data/lib/samagotchi/context_status.rb +1 -1
  46. data/lib/samagotchi/continue_offer.rb +2 -1
  47. data/lib/samagotchi/engine.rb +4 -4
  48. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  49. data/lib/samagotchi/guardrails/approval.rb +12 -2
  50. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  51. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  52. data/lib/samagotchi/guardrails/parent_policy.rb +327 -0
  53. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  54. data/lib/samagotchi/hook_routes.rb +5 -3
  55. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  56. data/lib/samagotchi/hooks/registry.rb +13 -6
  57. data/lib/samagotchi/hooks.rb +5 -0
  58. data/lib/samagotchi/kernel_loop.rb +5 -2
  59. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  61. data/lib/samagotchi/memory_bundle.rb +1 -0
  62. data/lib/samagotchi/parent_answers_command.rb +80 -0
  63. data/lib/samagotchi/parent_report.rb +122 -30
  64. data/lib/samagotchi/pending_question.rb +8 -4
  65. data/lib/samagotchi/plugin/api.rb +16 -0
  66. data/lib/samagotchi/plugin/context.rb +11 -5
  67. data/lib/samagotchi/plugin/loader.rb +5 -3
  68. data/lib/samagotchi/question_desk.rb +39 -25
  69. data/lib/samagotchi/queued_command.rb +5 -4
  70. data/lib/samagotchi/relay.rb +5 -1
  71. data/lib/samagotchi/relay_verifier.rb +4 -5
  72. data/lib/samagotchi/reply_wait.rb +14 -5
  73. data/lib/samagotchi/send_command.rb +103 -12
  74. data/lib/samagotchi/session.rb +26 -2
  75. data/lib/samagotchi/session_commands.rb +45 -8
  76. data/lib/samagotchi/session_setup.rb +10 -8
  77. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
  78. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -12
  79. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  80. data/lib/samagotchi/terminal_ui.rb +20 -11
  81. data/lib/samagotchi/testing/boundary.rb +0 -3
  82. data/lib/samagotchi/testing/bundle.rb +7 -0
  83. data/lib/samagotchi/testing/host.rb +6 -4
  84. data/lib/samagotchi/testing/install.rb +119 -0
  85. data/lib/samagotchi/testing/recorder.rb +13 -4
  86. data/lib/samagotchi/testing.rb +1 -0
  87. data/lib/samagotchi/tools/builtins.rb +2 -1
  88. data/lib/samagotchi/tools/delegate.rb +4 -1
  89. data/lib/samagotchi/tools/delegate_wait.rb +16 -7
  90. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  91. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  92. data/lib/samagotchi/turn_flow.rb +10 -0
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +48 -10
  95. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  96. data/lib/samagotchi/web/public/ctx.js +12 -26
  97. data/lib/samagotchi/web/public/stream_handlers.js +1 -1
  98. data/lib/samagotchi/web/server.rb +4 -0
  99. data/lib/samagotchi/worker.rb +27 -5
  100. data/lib/samagotchi/worker_inbound.rb +4 -4
  101. metadata +12 -2
  102. data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
data/docs/sub-agent.md CHANGED
@@ -59,7 +59,7 @@ Exit codes, both commands:
59
59
  | 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
60
60
  | 3 | `question` | chi waits for an answer: `question`, `answer_with` |
61
61
  | 4 | `running` | `--timeout` passed; the turn goes on, wait again |
62
- | 1 | `failed`, `canceled`, `limit`, `removed`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it; `removed`: the `--queue` message waited for was taken back before it ran, no turn ran for it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
62
+ | 1 | `failed`, `canceled`, `limit`, `removed`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it, except under `guardrails.parent_answers: hard`, where a parent's `/continue yes` is refused as its `chi answer --option Continue` is; `removed`: the `--queue` message waited for was taken back before it ran, no turn ran for it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `worker_stopped`: the `--queue` turn waited for died with its worker (detail "its worker exited mid-turn"), settled by the next one; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
63
63
  | 2 | (no JSON) | usage error, or an option the question doesn't offer |
64
64
  | 130 | `running` | Ctrl-C; the turn goes on |
65
65
 
@@ -98,10 +98,12 @@ question and starts a new turn. It can't be dismissed.
98
98
 
99
99
  A parent may Continue by default: Continue grants no permission (every tool
100
100
  call still meets the guardrails), it only spends time and tokens. With
101
- `turn.parent_continue: false` in chi's config.yml (config.yml only, no
102
- environment variable) a parent may only Stop: `chi answer` and the worker
103
- refuse a Continue (exit 1, `a parent agent may only stop this turn here`), and
104
- the user continues it from the web or the terminal.
101
+ `guardrails.parent_answers: hard` in chi's config.yml (config.yml only, no
102
+ environment variable; a session's own `parent_answers` may tighten it, see
103
+ [Guardrails](guardrails.md#a-sessions-own-level)) a parent may only Stop: `chi answer` and the worker
104
+ refuse a Continue (exit 1, `a parent agent may only stop this turn here`), the
105
+ question block's command is the Stop, and the user continues it from the web or
106
+ the terminal. See [Guardrails](guardrails.md#answers-from-a-parent-agent).
105
107
 
106
108
  A chi parent's `delegate_result` gets a child's step-limit question back as a
107
109
  `question` with these commands, for its model to decide: continue it (the
@@ -122,23 +124,27 @@ sees only how each was answered. See
122
124
  parents (Claude Code, Codex, a script), and for a chi parent that can't host
123
125
  the relay (a `--no-shared` REPL, `--non-interactive`).
124
126
 
125
- An approval is a guardrail rule with `verdict: ask`: its user said "ask me". So
126
- a parent may deny it (`--option Deny --text "why"`, `--text` alone, or
127
- `--dismiss`) but not allow it: `chi answer` refuses an Allow with exit 1 and
128
- `allowing a tool call is up to the user: deny it (--option Deny --text WHY),
129
- and tell your user`. For an approval, the question block and `answer_with` give
130
- the deny command, not `--option N` or `chi --attach`. The block also offers to
131
- leave it open: tell your user it waits in chi web (the bell and badge show
132
- it there), and `chi send --wait --format json --turn <turn_id> ID` waits until
133
- they answer it. With
134
- `guardrails.parent_approvals: once` in chi's config.yml (it has no environment
135
- variable), "Allow once" goes through; the wider scopes never do,
136
- and neither does any allow on chi's own config, hooks or guardrail rules. See
137
- [Guardrails](guardrails.md#approvals-from-a-parent-agent).
127
+ An approval is a guardrail rule with `verdict: ask`: its user said "ask me". A
128
+ parent may always deny it (`--option Deny --text "why"`, `--text` alone, or
129
+ `--dismiss`). By default (`guardrails.parent_answers: soft` in chi's
130
+ config.yml, which has no environment variable, unless the session's own
131
+ `parent_answers` is stricter) it may also give "Allow once";
132
+ the wider scopes never go, and neither does any allow on chi's own config,
133
+ hooks or guardrail rules: `chi answer` refuses those with exit 1 and a line
134
+ like `only Allow once (guardrails.parent_answers: soft) can be given here: deny
135
+ it (--option Deny --text WHY), and tell your user`. With `standard` or `hard`
136
+ no allow goes (`allowing a tool call is up to the user: …`). The question
137
+ block lists what the parent may give: the Allow once command when it may, and
138
+ the deny command (`answer_with` is the deny), never `chi --attach`. The block
139
+ also offers to leave it open: tell your user it waits in chi web (the bell and
140
+ badge show it there), and `chi send --wait --format json --turn <turn_id> ID`
141
+ waits until they answer it. A plugin's question names the options a parent may
142
+ pick when the strategy leaves some out (`allowed_options` in the JSON). See
143
+ [Guardrails](guardrails.md#answers-from-a-parent-agent).
138
144
 
139
145
  Start a child in the folder it should change (`chi send --new --dir PATH`):
140
146
  writes and mutating git outside its own repo ask (`write-outside-repo`,
141
- `git-outside-repo`), which a parent can only deny.
147
+ `git-outside-repo`), which a parent can at most allow once per call.
142
148
 
143
149
  An answer typed into `chi --attach` or the REPL counts as a parent's too when
144
150
  stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
@@ -208,12 +214,20 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
208
214
  JSON. If you won't answer, add `--dismiss` instead: chi does nothing it asked
209
215
  about, finishes with what it found, and the reply comes back (exit 0).
210
216
  - `question.kind: approval`: chi wants to run something its user's guardrails
211
- flagged, and allowing it is up to your user. Deny it (the `answer_with`
212
- command: `--option Deny --text "why"`), and tell your user what it wanted to
213
- run and why. Or leave it open: tell your user it waits in chi web (the
214
- session's short id), then `chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`
215
- waits until they answer it there. Never allow it yourself, through
216
- `chi answer`, a piped `chi --attach` or chi's web API.
217
+ flagged. What you may give follows chi's `guardrails.parent_answers` (or the
218
+ session's own, stricter `parent_answers`), and the
219
+ question says it: when its `allowed_options` (and the text block's `allow:`
220
+ line) list "Allow once" (`soft`, the default), you may allow that one call if
221
+ your task clearly needs it; never a wider scope (this session, a repo, a
222
+ rule), and never a call on chi's own config, which lists only Deny. Otherwise
223
+ (`standard`, `hard`, or anything wider) allowing it is up to your user: deny
224
+ it (the `answer_with` command: `--option Deny --text "why"`), and tell your
225
+ user what it wanted to run and why. Or leave it open: tell your user it waits
226
+ in chi web (the session's short id), then
227
+ `chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`
228
+ waits until they answer it there. Never give an answer the question doesn't
229
+ offer you by another route (a piped `chi --attach`, `chi send -m '/continue
230
+ yes'`, chi's web API).
217
231
  - `status: running` (exit 4): still working. Wait for that same turn again:
218
232
  `chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`.
219
233
  - `question.kind: continue`: chi ran out of steps before it answered. If it is
data/docs/testing.md CHANGED
@@ -47,7 +47,8 @@ Where a composer line goes is decided twice, in Ruby (`ComposerRoute`, both term
47
47
  (`composer.js#routeComposerLine`). `spec/fixtures/composer_routes.json` holds the cases both answer: rspec
48
48
  (`spec/composer_route_spec.rb`) and `npm test` (`spec/web/public/composer.test.js`) run every row. A change to
49
49
  either router adds its row there; a row where a UI differs on purpose (or a found divergence) carries a `web` or
50
- `repl` override and a `note`.
50
+ `repl` override and a `note`. The Ruby side runs each row with every core command (a new one fails its
51
+ `CORE_ROUTES` check until listed there); a row's names that core lacks stand for plugin commands.
51
52
 
52
53
  A spec that waits for something uses `wait_until(timeout:)`, or `wait_until!(what, timeout:)` where no check
53
54
  follows (stubs, helper threads: it raises "timed out after N s waiting for <what>") — never a loop with no deadline.
@@ -77,7 +78,11 @@ The kit's own specs, in `spec/samagotchi/testing/`, keep it and the bundles hone
77
78
  `Testing::Sessions` has `Plugin::Sessions`' methods and parameters, and each `ctx` helper lands in its recorder. A
78
79
  member added to the Host fails it until the kit has it.
79
80
  - `spec/samagotchi/testing/shipped_bundles_spec.rb`: each shipped bundle with a plugin or hooks registers the same
80
- commands, tools, hooks, inits, toolkits and sources loaded by the kit as installed and loaded by chi.
81
+ commands, tools, hooks, inits, toolkits and sources loaded by the kit as installed and loaded by chi
82
+ (`Testing::Install`).
83
+ - `spec/samagotchi/bundle_command/check_spec.rb`: `chi bundle check` passes every shipped bundle with no errors (one
84
+ example per bundle), so a shipped bundle that wouldn't install or load cleanly for a user fails here too. Run it
85
+ by hand on one with `ruby -Ilib bin/chi bundle check lib/samagotchi/bundles/NAME`.
81
86
  - `spec/samagotchi/testing/boundary_spec.rb`, the boundary lint: each bundle's Ruby (plugin, hooks, scripts, specs)
82
87
  reaches chi only through `Samagotchi::Testing::Boundary::ALLOWED` (the "Stable for bundles" list) and that
83
88
  bundle's `Boundary::EXCEPTIONS` entry, each with why. A new internal reference fails it, and so does an exception
@@ -6,8 +6,7 @@ require_relative "session_manager"
6
6
  require_relative "bridge_client"
7
7
  require_relative "reply_wait"
8
8
  require_relative "parent_report"
9
- require_relative "guardrails/parent_approvals"
10
- require_relative "guardrails/parent_continue"
9
+ require_relative "guardrails/parent_policy"
11
10
  require_relative "cli/command"
12
11
  require_relative "cli/flags"
13
12
  require_relative "cli/parent_wait"
@@ -19,10 +18,10 @@ module Samagotchi
19
18
  # how a parent agent running chi as a sub-agent answers; the web and
20
19
  # chi --attach answer the same question. See docs/sub-agent.md.
21
20
  #
22
- # An approval is the user's to allow: a parent may deny it, and allow it
23
- # only "once" when guardrails.parent_approvals (config.yml) says so. It is
24
- # checked here and again by the worker, which sees the answer marked as
25
- # chi answer's. That guards an honest but eager parent, not a boundary:
21
+ # What a parent may pick is Guardrails::ParentPolicy's: by each option's
22
+ # effect and this process's config.yml. It is checked here and again by
23
+ # the worker, which sees the answer marked as chi answer's. That guards
24
+ # an honest but eager parent, not a boundary:
26
25
  # the Bridge and the localhost web take answers from any local process.
27
26
  class AnswerCommand
28
27
  include CLI::Command
@@ -46,9 +45,11 @@ module Samagotchi
46
45
  about and finishes its reply
47
46
  --timeout S give up waiting after S seconds (exit 4)
48
47
  --format json one JSON object on stdout (see chi send --help)
49
- An approval (a guardrail's ask) can be denied here: deny it, and
50
- tell your user; allowing it is the user's. With
51
- guardrails.parent_approvals: once, "Allow once" is let through.
48
+ What a parent may pick follows guardrails.parent_answers in
49
+ chi's config.yml: soft (the default) lets "Allow once" and
50
+ Continue through, standard Continue but no allow, hard only a
51
+ deny, a Stop or a plain answer. A wider allow, and any allow on
52
+ chi's own config, is the user's: deny it, and tell your user.
52
53
  A step-limit question (kind continue): --option Continue [--text
53
54
  STEER] (the text joins the continued turn as a steer), or
54
55
  --option Stop [--text WHY] (exit 0, status not_continued); it
@@ -121,7 +122,7 @@ module Samagotchi
121
122
  return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
122
123
 
123
124
  selected = selection(pending, options) or return @selection_status
124
- refusal = approval_refusal(pending, selected, id) || continue_refusal(pending, selected)
125
+ refusal = parent_refusal(pending, selected, session)
125
126
  return failure(refusal) if refusal
126
127
 
127
128
  posted = post(id, qid, selected, options)
@@ -158,21 +159,16 @@ module Samagotchi
158
159
  labels.uniq
159
160
  end
160
161
 
161
- # Why a parent may not give this answer to an approval, or nil
162
- # (Guardrails::ParentApprovals, with this process's config.yml). A
163
- # Deny, text alone or a dismissal always passes: each one denies. The
164
- # worker checks it again with its own config.
165
- def approval_refusal(pending, selected, _id)
162
+ # Why a parent may not give this answer, or nil (Guardrails::ParentPolicy,
163
+ # with this process's config.yml tightened by the session file's own
164
+ # parent_answers, and the question's own tags). A deny, text alone or a
165
+ # dismissal always passes. The worker checks it again with its own.
166
+ def parent_refusal(pending, selected, session)
166
167
  offered = Array(pending.options).map(&:to_s)
167
168
  indices = selected.map { |label| offered.index(label) }
168
- reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: Guardrails::ParentApprovals.setting)
169
- reason && Guardrails::ParentApprovals.message(reason)
170
- end
171
-
172
- # Why a parent may not answer Continue (turn.parent_continue: false in
173
- # this process's config.yml), or nil; the worker checks it again.
174
- def continue_refusal(pending, selected)
175
- Guardrails::ParentContinue.refusal(pending, selected) && Guardrails::ParentContinue.message
169
+ strategy = Guardrails::ParentPolicy.for_session(session)
170
+ reason = Guardrails::ParentPolicy.refusal(pending, indices, strategy: strategy)
171
+ reason && Guardrails::ParentPolicy.message(reason, pending: pending, strategy: strategy)
176
172
  end
177
173
 
178
174
  # A step-limit question can't be left unanswered: the turn waits on it.
@@ -206,8 +202,8 @@ module Samagotchi
206
202
 
207
203
  not_open(qid)
208
204
  when 400 then usage_failure(reply.json&.dig("detail") || "the worker refused the answer")
209
- # The worker's own guardrails.parent_approvals refused the allow.
210
- when 403 then failure(reply.json&.dig("detail") || "the worker refused the allow: deny it, and tell your user")
205
+ # The worker's own strategy refused the option (ParentPolicy).
206
+ when 403 then failure(reply.json&.dig("detail") || "the worker refused that option: deny it, and tell your user")
211
207
  when 408 then failure("the worker did not take the answer in time; the question is still open: try again")
212
208
  when 404
213
209
  return failure(worker_gone(id)) if reply.json&.dig("error") == "unknown_session"
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../context_usage"
4
+
5
+ module Samagotchi
6
+ class Bridge
7
+ # What an SSE client gets for an engine event (SSEWriter#write_event, live
8
+ # and replayed alike). A :generation_chunk carries chi's own counts
9
+ # (`counts`, ContextUsage.normalize of the provider's payload) instead of
10
+ # the provider's raw `payload`, and no raw `content` (the chat loop's is
11
+ # reasoning + content again, the native loop's the raw stream with its
12
+ # tool-call bytes and thought tags): clients read `text`, `thinking`,
13
+ # `tool_call` and `counts`, never a provider's payload shape. `?raw=1` on
14
+ # the worker's stream skips this (debugging).
15
+ #
16
+ # Pure: the event is the one hash SessionObserver hands every observer
17
+ # (the ring, TurnAccumulator, the other writers, SessionMetrics, hooks),
18
+ # so it is never mutated; keys are added or removed on a copy, and any
19
+ # key not named here (event_seq included) passes through.
20
+ module ClientEvent
21
+ # The counts a client gets: ctx_pct stays out, as normalize computes it
22
+ # without the session's LLM context budget (the client counts
23
+ # total_tokens against the window or budget itself).
24
+ COUNT_KEYS = %i[prompt_tokens completion_tokens total_tokens context_window_tokens].freeze
25
+
26
+ module_function
27
+
28
+ # @param event [Hash] the engine's event (symbol keys; its payload has
29
+ # string keys)
30
+ # @return [Hash] the event as clients get it: a copy for a
31
+ # :generation_chunk, the same object for every other event
32
+ def project(event)
33
+ return event unless event.is_a?(Hash) && event[:type].to_s == "generation_chunk"
34
+
35
+ projected = event.except(:payload, :content)
36
+ found = counts(event[:payload])
37
+ found ? projected.merge(counts: found) : projected
38
+ end
39
+
40
+ # The payload's normalized counts, nil keys left out; nil when the
41
+ # payload reports none (a missing key means unknown, never 0).
42
+ # @return [Hash, nil]
43
+ def counts(payload)
44
+ usage = ContextUsage.normalize(payload)
45
+ found = usage&.slice(*COUNT_KEYS)&.compact
46
+ found.nil? || found.empty? ? nil : found
47
+ end
48
+ end
49
+ end
50
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
 
5
+ require_relative "client_event"
5
6
  require_relative "event_id"
6
7
 
7
8
  module Samagotchi
@@ -58,10 +59,12 @@ module Samagotchi
58
59
  # @param bridge [Samagotchi::Bridge, nil] for stop signalling
59
60
  # @param max_queue [Integer] bounded-queue capacity
60
61
  # @param heartbeat_interval [Float] idle-seconds between `: ping` frames
62
+ # @param raw [Boolean] write events as the engine emitted them, not as
63
+ # clients get them (ClientEvent): `?raw=1`, for debugging
61
64
  def initialize(engine:, ring:, session_id:, last_event_id: nil, epoch: nil,
62
65
  frame_provider: nil, join_with_snapshot: false, bridge: nil,
63
66
  max_queue: DEFAULT_MAX_QUEUE,
64
- heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL)
67
+ heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL, raw: false)
65
68
  @engine = engine
66
69
  @ring = ring
67
70
  @session_id = session_id
@@ -72,6 +75,7 @@ module Samagotchi
72
75
  @bridge = bridge
73
76
  @max_queue = max_queue
74
77
  @heartbeat_interval = heartbeat_interval
78
+ @raw = raw
75
79
 
76
80
  @queue = BoundedQueue.new(capacity: @max_queue)
77
81
  cursor_seq, cursor_epoch = EventId.parse(@last_event_id)
@@ -203,7 +207,7 @@ module Samagotchi
203
207
  seq = event[:event_seq]
204
208
  return if seq && seq <= @high_water
205
209
 
206
- write_frame(io, seq: seq, data: event)
210
+ write_frame(io, seq: seq, data: @raw ? event : ClientEvent.project(event))
207
211
  @high_water = seq if seq && seq > @high_water
208
212
  end
209
213
 
@@ -19,7 +19,6 @@ require_relative "bridge/sse_writer"
19
19
  require_relative "bridge/turn_accumulator"
20
20
  require_relative "session"
21
21
  require_relative "engine"
22
- require_relative "guardrails/parent_approvals"
23
22
  require_relative "session_commands"
24
23
  require_relative "queued_command"
25
24
  require_relative "steer"
@@ -309,10 +308,11 @@ module Samagotchi
309
308
  # @param after_file [String, nil] the input file it came in (its
310
309
  # basename): it runs once that file is taken (Worker#on_command)
311
310
  # @return [String] its command_id
312
- def queue_command(line, client_id: nil, card: false, after_file: nil)
311
+ def queue_command(line, client_id: nil, card: false, after_file: nil, enqueued_id: nil)
313
312
  # A card's action (the step-limit question's answer): the UIs leave
314
- # its line out.
315
- command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card)
313
+ # its line out. +enqueued_id+: a file-delivered command's input, whose
314
+ # sender's wait its command_ran settles.
315
+ command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card).with(enqueued_id: enqueued_id)
316
316
  @engine.synchronize_events { queue_command_locked(command, after_file: after_file) }
317
317
  command.command_id
318
318
  end
@@ -420,7 +420,7 @@ module Samagotchi
420
420
  cursor = reconnect_cursor(headers, request[:query])
421
421
  Log.debug(:bridge, "stream", method: method, path: request[:path], client_id: stream_client_id(request[:query]))
422
422
  serve_sse(io, route.last, last_event_id: cursor, snapshot: snapshot_requested?(request[:query]),
423
- client_id: stream_client_id(request[:query]))
423
+ client_id: stream_client_id(request[:query]), raw: raw_requested?(request[:query]))
424
424
  break # SSE owns the connection until the client disconnects.
425
425
  else
426
426
  payload, status, body = dispatch(route.first, route.last, request[:body], request[:query])
@@ -447,7 +447,7 @@ module Samagotchi
447
447
 
448
448
  # Serve an SSE stream. Owns the connection until the client disconnects.
449
449
  # The connection thread IS the writer thread: serve! blocks until then.
450
- def serve_sse(io, session_id, last_event_id:, snapshot: false, client_id: nil)
450
+ def serve_sse(io, session_id, last_event_id:, snapshot: false, client_id: nil, raw: false)
451
451
  unless own_session?(session_id)
452
452
  write_json(io, 404, {}, { error: "unknown_session" })
453
453
  return
@@ -463,7 +463,8 @@ module Samagotchi
463
463
  # A reconnect (with a cursor) replays; only a fresh join snapshots.
464
464
  join_with_snapshot: snapshot && last_event_id.nil?,
465
465
  bridge: self,
466
- heartbeat_interval: @heartbeat_interval
466
+ heartbeat_interval: @heartbeat_interval,
467
+ raw: raw
467
468
  )
468
469
  @mutex.synchronize do
469
470
  @open_streams += 1
@@ -649,7 +650,7 @@ module Samagotchi
649
650
  selected = fetched(parsed, "selected")
650
651
  freeform = fetched(parsed, "freeform")
651
652
  # chi answer's marker: a parent agent, held to this worker's
652
- # guardrails.parent_approvals (QuestionDesk::Refused). Not a boundary:
653
+ # Guardrails::ParentPolicy (QuestionDesk::Refused). Not a boundary:
653
654
  # any local process may post here, with or without it.
654
655
  client_id = fetched(parsed, "client_id")
655
656
  if qid.to_s.strip.empty?
@@ -667,9 +668,8 @@ module Samagotchi
667
668
  # Another client answered first, or the question was cancelled.
668
669
  [{}, 409, { error: "question_not_pending", detail: e.message }]
669
670
  rescue QuestionDesk::Refused => e
670
- Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
671
- [{}, 403, { error: e.reason == :stop_only ? "parent_continue_refused" : "parent_approval_refused",
672
- detail: Guardrails::ParentApprovals.message(e.reason) }]
671
+ Log.info(:bridge, "parent_answer_refused", sid: session_id, id: qid, reason: e.reason)
672
+ [{}, 403, { error: "parent_answer_refused", reason: e.reason.to_s, detail: e.detail }]
673
673
  rescue ArgumentError => e
674
674
  [{}, 400, { error: "invalid_answer", detail: e.message }]
675
675
  end
@@ -1180,6 +1180,17 @@ module Samagotchi
1180
1180
  false
1181
1181
  end
1182
1182
 
1183
+ # `?raw=1`: events as the engine emitted them (a chunk's provider
1184
+ # payload), not as clients get them (ClientEvent). For debugging on the
1185
+ # worker's port: chi web never passes it on.
1186
+ def raw_requested?(query)
1187
+ return false unless query
1188
+
1189
+ URI.decode_www_form(query).to_h["raw"].to_s == "1"
1190
+ rescue StandardError
1191
+ false
1192
+ end
1193
+
1183
1194
  # `?client_id=`: whose stream it is (see #open_streams_except).
1184
1195
  def stream_client_id(query)
1185
1196
  return nil unless query
@@ -114,8 +114,8 @@ module Samagotchi
114
114
  # POST /session/:id/answer. 200 = recorded, 409 = another client answered
115
115
  # first (or the question is gone), 400 = invalid selection, 408
116
116
  # deadline_passed = read too late and dropped (see DEADLINE_SHARE), 403
117
- # parent_approval_refused = a parent's allow (client_id cli:answer)
118
- # beyond the worker's guardrails.parent_approvals.
117
+ # parent_answer_refused (with its reason) = a parent's pick (client_id
118
+ # cli:answer) beyond the worker's guardrails.parent_answers.
119
119
  # @param client_id [String, nil] who answers (chi answer marks its own)
120
120
  # @return [Response]
121
121
  def answer(id:, selected:, freeform: nil, client_id: nil)