samagotchi 0.84.0 → 0.86.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +44 -6
  4. data/docs/bundle-authoring.md +125 -0
  5. data/docs/cli.md +54 -4
  6. data/docs/configuration.md +8 -5
  7. data/docs/guardrails.md +63 -35
  8. data/docs/hooks.md +20 -4
  9. data/docs/internals/anthropic.md +2 -2
  10. data/docs/internals/bundles.md +11 -2
  11. data/docs/internals/context-telemetry.md +19 -1
  12. data/docs/internals/engine.md +4 -3
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +59 -2
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +8 -6
  17. data/docs/sub-agent.md +35 -24
  18. data/docs/testing.md +7 -2
  19. data/lib/samagotchi/answer_command.rb +21 -25
  20. data/lib/samagotchi/bridge/client_event.rb +50 -0
  21. data/lib/samagotchi/bridge/sse_writer.rb +6 -2
  22. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  23. data/lib/samagotchi/bridge.rb +76 -12
  24. data/lib/samagotchi/bridge_client.rb +14 -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/ruby/manifest.yml +11 -0
  36. data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
  37. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  38. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  39. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  40. data/lib/samagotchi/client_id.rb +1 -1
  41. data/lib/samagotchi/config.rb +195 -115
  42. data/lib/samagotchi/context_status.rb +1 -1
  43. data/lib/samagotchi/continue_offer.rb +2 -1
  44. data/lib/samagotchi/engine.rb +5 -5
  45. data/lib/samagotchi/guardrail_wiring.rb +4 -0
  46. data/lib/samagotchi/guardrails/approval.rb +12 -2
  47. data/lib/samagotchi/guardrails/load_failures.rb +17 -3
  48. data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
  49. data/lib/samagotchi/guardrails/parent_policy.rb +258 -0
  50. data/lib/samagotchi/guardrails/verdict.rb +6 -3
  51. data/lib/samagotchi/hook_routes.rb +5 -3
  52. data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
  53. data/lib/samagotchi/hooks/registry.rb +13 -6
  54. data/lib/samagotchi/hooks.rb +5 -0
  55. data/lib/samagotchi/kernel_loop.rb +10 -3
  56. data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
  57. data/lib/samagotchi/llm/chat_loop.rb +5 -1
  58. data/lib/samagotchi/log_subscriber.rb +1 -0
  59. data/lib/samagotchi/memory_bundle/shas.rb +127 -0
  60. data/lib/samagotchi/memory_bundle.rb +1 -0
  61. data/lib/samagotchi/parent_report.rb +92 -16
  62. data/lib/samagotchi/pending_question.rb +8 -4
  63. data/lib/samagotchi/plugin/api.rb +16 -0
  64. data/lib/samagotchi/plugin/context.rb +22 -12
  65. data/lib/samagotchi/plugin/loader.rb +5 -3
  66. data/lib/samagotchi/question_desk.rb +37 -24
  67. data/lib/samagotchi/queued_command.rb +5 -4
  68. data/lib/samagotchi/relay.rb +5 -1
  69. data/lib/samagotchi/relay_verifier.rb +4 -5
  70. data/lib/samagotchi/reply_wait.rb +22 -6
  71. data/lib/samagotchi/send_command.rb +34 -8
  72. data/lib/samagotchi/session_commands.rb +21 -7
  73. data/lib/samagotchi/session_inbox.rb +68 -0
  74. data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +102 -12
  76. data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
  77. data/lib/samagotchi/terminal_ui.rb +15 -9
  78. data/lib/samagotchi/testing/boundary.rb +0 -3
  79. data/lib/samagotchi/testing/bundle.rb +7 -0
  80. data/lib/samagotchi/testing/host.rb +6 -4
  81. data/lib/samagotchi/testing/install.rb +119 -0
  82. data/lib/samagotchi/testing/recorder.rb +13 -4
  83. data/lib/samagotchi/testing.rb +1 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/delegate_wait.rb +6 -1
  86. data/lib/samagotchi/tools/task_runtime.rb +5 -3
  87. data/lib/samagotchi/turn_checkpoint.rb +64 -2
  88. data/lib/samagotchi/turn_flow.rb +10 -0
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/web/app.rb +76 -10
  91. data/lib/samagotchi/web/public/app.js +17 -0
  92. data/lib/samagotchi/web/public/bubbles.js +9 -0
  93. data/lib/samagotchi/web/public/chunk_router.js +8 -19
  94. data/lib/samagotchi/web/public/commands.js +1 -0
  95. data/lib/samagotchi/web/public/ctx.js +12 -26
  96. data/lib/samagotchi/web/public/data.js +11 -0
  97. data/lib/samagotchi/web/public/index.html +9 -0
  98. data/lib/samagotchi/web/public/stage_view.js +6 -0
  99. data/lib/samagotchi/web/public/stream_handlers.js +4 -1
  100. data/lib/samagotchi/web/public/turn_events.js +4 -0
  101. data/lib/samagotchi/web/public/turn_html.js +9 -1
  102. data/lib/samagotchi/web/public/unqueue.js +56 -0
  103. data/lib/samagotchi/web/server.rb +4 -0
  104. data/lib/samagotchi/worker.rb +19 -5
  105. data/lib/samagotchi/worker_inbound.rb +4 -4
  106. metadata +12 -2
  107. 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`, `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). `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,11 @@ 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
101
+ `guardrails.parent_answers: hard` in chi's config.yml (config.yml only, no
102
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.
103
+ refuse a Continue (exit 1, `a parent agent may only stop this turn here`), the
104
+ question block's command is the Stop, and the user continues it from the web or
105
+ the terminal. See [Guardrails](guardrails.md#answers-from-a-parent-agent).
105
106
 
106
107
  A chi parent's `delegate_result` gets a child's step-limit question back as a
107
108
  `question` with these commands, for its model to decide: continue it (the
@@ -122,23 +123,26 @@ sees only how each was answered. See
122
123
  parents (Claude Code, Codex, a script), and for a chi parent that can't host
123
124
  the relay (a `--no-shared` REPL, `--non-interactive`).
124
125
 
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).
126
+ An approval is a guardrail rule with `verdict: ask`: its user said "ask me". A
127
+ parent may always deny it (`--option Deny --text "why"`, `--text` alone, or
128
+ `--dismiss`). By default (`guardrails.parent_answers: soft` in chi's
129
+ config.yml, which has no environment variable) it may also give "Allow once";
130
+ the wider scopes never go, and neither does any allow on chi's own config,
131
+ hooks or guardrail rules: `chi answer` refuses those with exit 1 and a line
132
+ like `only Allow once (guardrails.parent_answers: soft) can be given here: deny
133
+ it (--option Deny --text WHY), and tell your user`. With `standard` or `hard`
134
+ no allow goes (`allowing a tool call is up to the user: …`). The question
135
+ block lists what the parent may give: the Allow once command when it may, and
136
+ the deny command (`answer_with` is the deny), never `chi --attach`. The block
137
+ also offers to leave it open: tell your user it waits in chi web (the bell and
138
+ badge show it there), and `chi send --wait --format json --turn <turn_id> ID`
139
+ waits until they answer it. A plugin's question names the options a parent may
140
+ pick when the strategy leaves some out (`allowed_options` in the JSON). See
141
+ [Guardrails](guardrails.md#answers-from-a-parent-agent).
138
142
 
139
143
  Start a child in the folder it should change (`chi send --new --dir PATH`):
140
144
  writes and mutating git outside its own repo ask (`write-outside-repo`,
141
- `git-outside-repo`), which a parent can only deny.
145
+ `git-outside-repo`), which a parent can at most allow once per call.
142
146
 
143
147
  An answer typed into `chi --attach` or the REPL counts as a parent's too when
144
148
  stdin isn't a terminal or an agent marker (`CLAUDECODE`, `AI_AGENT`,
@@ -208,12 +212,19 @@ Start a task: `chi send --new --wait --format json --timeout 500 -m "<task>"`
208
212
  JSON. If you won't answer, add `--dismiss` instead: chi does nothing it asked
209
213
  about, finishes with what it found, and the reply comes back (exit 0).
210
214
  - `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.
215
+ flagged. What you may give follows chi's `guardrails.parent_answers`, and the
216
+ question says it: when its `allowed_options` (and the text block's `allow:`
217
+ line) list "Allow once" (`soft`, the default), you may allow that one call if
218
+ your task clearly needs it; never a wider scope (this session, a repo, a
219
+ rule), and never a call on chi's own config, which lists only Deny. Otherwise
220
+ (`standard`, `hard`, or anything wider) allowing it is up to your user: deny
221
+ it (the `answer_with` command: `--option Deny --text "why"`), and tell your
222
+ user what it wanted to run and why. Or leave it open: tell your user it waits
223
+ in chi web (the session's short id), then
224
+ `chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`
225
+ waits until they answer it there. Never give an answer the question doesn't
226
+ offer you by another route (a piped `chi --attach`, `chi send -m '/continue
227
+ yes'`, chi's web API).
217
228
  - `status: running` (exit 4): still working. Wait for that same turn again:
218
229
  `chi send --wait --format json --timeout 500 --turn <turn_id> <session_id>`.
219
230
  - `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)
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 and the question's own tags). A deny,
164
+ # text alone or a dismissal always passes. The worker checks it again
165
+ # with its own config.
166
+ def parent_refusal(pending, selected)
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.current
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
 
@@ -232,6 +232,9 @@ module Samagotchi
232
232
  queued[:images] = event[:images] if event[:images]
233
233
  queued[:delivery] = event[:delivery] if event[:delivery]
234
234
  @queued << queued
235
+ # Taken back before it ran (Bridge#handle_dequeue_turn).
236
+ when :turn_dequeued
237
+ dequeue([{ enqueued_id: event[:enqueued_id] }])
235
238
  when :recap_ready
236
239
  # One collected just as a turn started describes the chat before it.
237
240
  @recap = event[:recap] unless @turn
@@ -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
@@ -517,7 +518,10 @@ module Samagotchi
517
518
  # turn's end (Delivery::QUEUE_FEATURE). A worker without it would steer.
518
519
  # turn_ids: the running turn's checkpoint mark, a continue turn and a
519
520
  # Stop's last_turn carry the turn's enqueued_id (ReplyWait.own_turn).
520
- FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE].freeze
521
+ # unqueue: POST turn/dequeue (#handle_dequeue_turn) takes a queued
522
+ # message back before it runs.
523
+ FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
524
+ SessionInbox::UNQUEUE_FEATURE].freeze
521
525
 
522
526
  # The routes, by method and what follows /session/:id/: a handler takes
523
527
  # the session id and the request body and returns [headers, status, body].
@@ -528,6 +532,7 @@ module Samagotchi
528
532
  %w[POST answer] => :handle_answer,
529
533
  %w[POST question/dismiss] => :handle_dismiss_question,
530
534
  %w[POST turn] => :handle_post_turn,
535
+ %w[POST turn/dequeue] => :handle_dequeue_turn,
531
536
  %w[POST command] => :handle_command,
532
537
  %w[POST exit] => :handle_exit_request,
533
538
  %w[POST recap] => :handle_recap,
@@ -645,7 +650,7 @@ module Samagotchi
645
650
  selected = fetched(parsed, "selected")
646
651
  freeform = fetched(parsed, "freeform")
647
652
  # chi answer's marker: a parent agent, held to this worker's
648
- # guardrails.parent_approvals (QuestionDesk::Refused). Not a boundary:
653
+ # Guardrails::ParentPolicy (QuestionDesk::Refused). Not a boundary:
649
654
  # any local process may post here, with or without it.
650
655
  client_id = fetched(parsed, "client_id")
651
656
  if qid.to_s.strip.empty?
@@ -663,9 +668,8 @@ module Samagotchi
663
668
  # Another client answered first, or the question was cancelled.
664
669
  [{}, 409, { error: "question_not_pending", detail: e.message }]
665
670
  rescue QuestionDesk::Refused => e
666
- Log.info(:bridge, "parent_approval_refused", sid: session_id, id: qid, reason: e.reason)
667
- [{}, 403, { error: e.reason == :stop_only ? "parent_continue_refused" : "parent_approval_refused",
668
- 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 }]
669
673
  rescue ArgumentError => e
670
674
  [{}, 400, { error: "invalid_answer", detail: e.message }]
671
675
  end
@@ -932,6 +936,55 @@ module Samagotchi
932
936
  [{}, 202, ack]
933
937
  end
934
938
 
939
+ # POST /session/:id/turn/dequeue {enqueued_id, client_id?, deadline?}:
940
+ # take back a queued message (one that waits for the running turn's
941
+ # end: a queue or an image message) before a turn takes it. With the
942
+ # event log held, as its enqueue was, so :turn_dequeued always follows
943
+ # its :turn_enqueued; the file is claimed as the drain claims it (one
944
+ # rename wins), deleted, and its tombstone left for a wait on its turn
945
+ # (SessionInbox.take_queued_input). It never wakes the worker.
946
+ # 200 {status: "removed", enqueued_id, prompt}; 409 already_started (a
947
+ # turn took it, or the snapshot still lists it with no file left: taken,
948
+ # not announced yet), 409 not_queued (a next-step message: a drain may
949
+ # merge it any moment); 404 unknown_enqueued_id (never not_found: that is
950
+ # the unrouted path's, what a worker older than the route answers);
951
+ # 408 past its +deadline+ (see #handle_post_turn), nothing removed.
952
+ def handle_dequeue_turn(session_id, body)
953
+ parsed = parse_json(body)
954
+ return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
955
+
956
+ id = fetched(parsed, "enqueued_id").to_s.strip
957
+ return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "enqueued_id required" }] if id.empty?
958
+
959
+ deadline = fetched(parsed, "deadline")
960
+ return BAD_DEADLINE unless deadline_valid?(deadline)
961
+
962
+ client_id = fetched(parsed, "client_id")
963
+ taken, input = @engine.synchronize_events do
964
+ raise IntakeClosed if stopped?
965
+ next [:expired, nil] if expired?("dequeue_expired", deadline, sid: session_id, enqueued_id: id)
966
+
967
+ SessionInbox.take_queued_input(session_dir, id).tap do |status, _|
968
+ @engine.announce(type: :turn_dequeued, enqueued_id: id, client_id: client_id) if status == :removed
969
+ end
970
+ end
971
+ dequeue_reply(taken, id, input)
972
+ end
973
+
974
+ def dequeue_reply(taken, id, input)
975
+ case taken
976
+ when :removed then [{}, 200, { status: "removed", enqueued_id: id, prompt: input.prompt.to_s, session_id: @session_id }]
977
+ when :expired then deadline_passed("removal")
978
+ when :not_queued
979
+ [{}, 409, { error: "not_queued", detail: "only a message queued for after the turn can be removed", enqueued_id: id }]
980
+ when :claimed then [{}, 409, { error: "already_started", enqueued_id: id }]
981
+ else
982
+ return [{}, 409, { error: "already_started", enqueued_id: id }] if @accumulator.queued.any? { |q| q[:enqueued_id] == id }
983
+
984
+ [{}, 404, { error: "unknown_enqueued_id", enqueued_id: id }]
985
+ end
986
+ end
987
+
935
988
  # Read-only snapshot surface. AC #4: too-old reconnects re-derive state
936
989
  # from here.
937
990
  def handle_state(_session_id, _body = nil)
@@ -1127,6 +1180,17 @@ module Samagotchi
1127
1180
  false
1128
1181
  end
1129
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
+
1130
1194
  # `?client_id=`: whose stream it is (see #open_streams_except).
1131
1195
  def stream_client_id(query)
1132
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)
@@ -132,6 +132,18 @@ module Samagotchi
132
132
  post("question/dismiss", { id: id, deadline: deadline }, read_body: true)
133
133
  end
134
134
 
135
+ # POST /session/:id/turn/dequeue: take back a queued message (one that
136
+ # waits for the running turn's end) before it runs. 200 {status:
137
+ # "removed", enqueued_id, prompt}; 409 already_started (a turn took
138
+ # it) or not_queued (a next-step message); 404 unknown_enqueued_id; a
139
+ # 404 not_found: a worker older than the route (its sidecar lacks
140
+ # "unqueue"); 408 = too late.
141
+ # @param client_id [String, nil] the removing UI, in its :turn_dequeued
142
+ # @return [Response]
143
+ def dequeue(enqueued_id:, client_id: nil)
144
+ post("turn/dequeue", { enqueued_id: enqueued_id, client_id: client_id, deadline: deadline }, read_body: true)
145
+ end
146
+
135
147
  # POST /session/:id/turn. 202 = queued (body carries the enqueued_id the
136
148
  # Bridge also announced in :turn_enqueued), 408 deadline_passed = the
137
149
  # Bridge read it too late and dropped it (see DEADLINE_SHARE).