samagotchi 0.103.0 → 0.105.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 (83) 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 -24
  5. data/docs/cli.md +97 -21
  6. data/docs/configuration.md +1 -1
  7. data/docs/guardrails.md +15 -1
  8. data/docs/internals/anthropic.md +2 -1
  9. data/docs/internals/context-telemetry.md +6 -2
  10. data/docs/internals/engine.md +22 -1
  11. data/docs/internals/participants.md +167 -0
  12. data/docs/internals/prompt-caching.md +8 -0
  13. data/docs/internals/remote-sessions.md +153 -27
  14. data/docs/internals/working-set.md +4 -3
  15. data/docs/plugins.md +7 -1
  16. data/docs/sessions.md +4 -2
  17. data/docs/sub-agent.md +8 -0
  18. data/docs/testing.md +89 -4
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -1
  20. data/lib/samagotchi/bridge.rb +17 -10
  21. data/lib/samagotchi/bridge_client/event_stream.rb +9 -4
  22. data/lib/samagotchi/bridge_client.rb +4 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  24. data/lib/samagotchi/chi_invocation.rb +13 -16
  25. data/lib/samagotchi/config.rb +1 -1
  26. data/lib/samagotchi/context_status.rb +15 -2
  27. data/lib/samagotchi/engine.rb +52 -8
  28. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  29. data/lib/samagotchi/idle_recap.rb +1 -1
  30. data/lib/samagotchi/idle_scheduler.rb +34 -9
  31. data/lib/samagotchi/kernel_loop.rb +8 -1
  32. data/lib/samagotchi/last_turn.rb +5 -2
  33. data/lib/samagotchi/launch_mode.rb +4 -0
  34. data/lib/samagotchi/llm/chat_loop.rb +9 -1
  35. data/lib/samagotchi/owner_lock.rb +65 -5
  36. data/lib/samagotchi/participant_name.rb +21 -0
  37. data/lib/samagotchi/participants.rb +122 -0
  38. data/lib/samagotchi/queued_command.rb +9 -5
  39. data/lib/samagotchi/remote_attach_command.rb +119 -0
  40. data/lib/samagotchi/run_options.rb +14 -2
  41. data/lib/samagotchi/send_command.rb +1 -1
  42. data/lib/samagotchi/session.rb +8 -4
  43. data/lib/samagotchi/session_access/contract.rb +42 -4
  44. data/lib/samagotchi/session_access/dispatch.rb +29 -10
  45. data/lib/samagotchi/session_access/local.rb +52 -1
  46. data/lib/samagotchi/session_access/remote.rb +112 -16
  47. data/lib/samagotchi/session_access/web_bridge_client.rb +60 -3
  48. data/lib/samagotchi/session_access.rb +85 -3
  49. data/lib/samagotchi/session_continue.rb +1 -1
  50. data/lib/samagotchi/session_inbox.rb +18 -9
  51. data/lib/samagotchi/session_manager.rb +21 -5
  52. data/lib/samagotchi/session_metrics.rb +7 -1
  53. data/lib/samagotchi/session_participants.rb +59 -0
  54. data/lib/samagotchi/sessions_command.rb +14 -11
  55. data/lib/samagotchi/share_command.rb +174 -0
  56. data/lib/samagotchi/steer.rb +83 -10
  57. data/lib/samagotchi/system_prompt.rb +21 -2
  58. data/lib/samagotchi/terminal_ui/attach_launcher.rb +17 -4
  59. data/lib/samagotchi/terminal_ui/attach_link.rb +10 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +117 -29
  61. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  62. data/lib/samagotchi/terminal_ui/local_link.rb +5 -0
  63. data/lib/samagotchi/terminal_ui/reline_seam.rb +21 -1
  64. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  65. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  66. data/lib/samagotchi/terminal_ui/version_lines.rb +23 -0
  67. data/lib/samagotchi/terminal_ui.rb +4 -1
  68. data/lib/samagotchi/testing/waiting.rb +92 -9
  69. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  70. data/lib/samagotchi/turn_checkpoint.rb +5 -2
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/app.rb +187 -74
  73. data/lib/samagotchi/web/grants.rb +303 -0
  74. data/lib/samagotchi/web/participant_access.rb +286 -0
  75. data/lib/samagotchi/web/principal.rb +29 -0
  76. data/lib/samagotchi/web/public/index.html +6 -5
  77. data/lib/samagotchi/web/public/working_set.js +6 -4
  78. data/lib/samagotchi/web/public/working_set_view.js +1 -1
  79. data/lib/samagotchi/web/server.rb +3 -1
  80. data/lib/samagotchi/web/session_access_routes.rb +120 -4
  81. data/lib/samagotchi/worker.rb +44 -11
  82. data/lib/samagotchi/worker_inbound.rb +5 -2
  83. metadata +12 -1
data/docs/testing.md CHANGED
@@ -22,6 +22,9 @@ with two rules only, `no-undef` (a name used but never imported or declared: a d
22
22
  style pass: add a rule only for a bug class it would have caught. It runs in about 2 s, in CI's `npm test` job and in
23
23
  `rake release:check`; `rake lint` stays RuboCop alone (CI's rubocop job has no node). A name that is also a browser
24
24
  global (`name`, `status`, `open`…) passes even when its import is gone: give an export such a name only with care.
25
+ It also checks `spec/e2e/` for one thing, the Playwright twin of `Samagotchi/SpecSleep`: no `page.waitForTimeout`.
26
+ Wait for the state (a locator's `expect`, `data-phase`, an animation's end through `waitForFunction`); a pause that
27
+ is the behaviour (a pointer's dwell) carries `// eslint-disable-line no-restricted-syntax -- why`.
25
28
 
26
29
  CI's e2e job keeps the tests, chi and the fake model on the runner but runs Chromium in Playwright's image
27
30
  (`mcr.microsoft.com/playwright:v<version>-noble`, `playwright run-server` on the host network); the tests connect
@@ -66,11 +69,89 @@ either router adds its row there; a row where a UI differs on purpose (or a foun
66
69
  `repl` override and a `note`. The Ruby side runs each row with every core command (a new one fails its
67
70
  `CORE_ROUTES` check until listed there); a row's names that core lacks stand for plugin commands.
68
71
 
69
- A spec that waits for something uses `wait_until(timeout:)`, or `wait_until!(what, timeout:)` where no check
70
- follows (stubs, helper threads: it raises "timed out after N s waiting for <what>") — never a loop with no deadline.
72
+ ## Waiting and time
73
+
74
+ A spec waits *for* something and never sleeps to wait: a fixed pause is too short on a loaded runner (a parallel
75
+ run, CI) and too long on a fast one. The contract a new spec follows:
76
+
77
+ 1. **Never `sleep` to wait.** Wait for the condition: `wait_for("what") { condition }`, or
78
+ `eventually { expect(…) }` for a value. A `sleep` that *is* the behaviour (slowness, holding a connection,
79
+ separating mtimes) carries `# sleep-ok: why` on its line or the line above (for a call over several lines, its
80
+ first line). A bounded best-effort `wait_until`
81
+ whose timeout is fine ("at most 0.5 s, fine to cut short") carries `# wait-ok: why`; it must not become
82
+ `wait_for`, which would raise where the code moves on. A `Timeout.timeout` is a wait too (use `pop!`, `join!` or
83
+ `wait_for`); one that only guards a call that returns by itself against a hang carries `# timeout-ok: why`.
84
+ 2. **Time logic takes seams.** Intervals, graces and deadlines in lib take `clock:` / `sleeper:` / `interval:`
85
+ constructor arguments, and the unit spec moves the clock itself (`SessionAccess::Remote` is the model;
86
+ `IdleScheduler.new(interval:)`). Real time is used only in one smoke example per class. Two sessions or inbox
87
+ files that must sort apart get distinct times, not a pause: `Session.new_session(now:)`, `Session#save(now:)` and
88
+ `SessionInbox.write_input` / `write_note` / `write_output(now:)` take `now: next_stamp` (spec/support/stamps.rb:
89
+ a millisecond later on each call, starting a second in the past).
90
+ 3. **What a spec starts, it stops and waits for** before teardown: `join!(thread, what)`, `with_task_dir { … }`
91
+ (spec/support/task_dirs.rb: a tasks' folder of its own, its tasks stopped and their exit-status recorders joined
92
+ with `TaskRuntime.quiesce!`), a `stop` that joins (`IdleScheduler#stop`). Never only `Thread#kill`.
93
+ 4. **"Nothing happens" is shown by counting passes** (the worker's loop rounds, spec/support/worker_loop_rounds.rb;
94
+ a class's ticks), not by a pause.
95
+ 5. **A wall-clock bound** only as "not the full N s", with at least 5× headroom measured on the slow (x86) laptop.
96
+
97
+ The helpers are the bundle test kit's (`Samagotchi::Testing::Waiting`, [plugins.md](plugins.md#testing-a-bundle-experimental)),
98
+ so core and bundle specs share one copy; `SpecWaiting` (spec/support/waiting.rb) includes them in every example
99
+ group and also answers them as module calls (`SpecWaiting.pop!`, for a stubbed lib thread):
100
+
101
+ - `wait_for(what, timeout: 5, interval: 0.01, seen: nil) { … }` returns the block's first truthy value, or raises
102
+ `Waiting::TimedOut`. `seen:` (a callable) shows what the condition last saw, since a falsy block value tells
103
+ nothing.
104
+ - `join!(thread, what, timeout: 5)` and `pop!(queue, what, timeout: 5)`: `Thread#join` and `Queue#pop` with the
105
+ same deadline and message. A gate a stubbed lib thread waits on takes `timeout: 30`: it only ends a hang.
106
+ - `wait_until(timeout:) { … }` returns the block's last value (falsy at the deadline): for a value the example
107
+ checks after, or a `wait-ok` wait.
108
+ - `eventually(timeout: 5) { expect(…) }` (spec only) retries until the expectations hold, then re-raises the last
109
+ failure prefixed `still failing after 5 s:`, so the matcher's own expected/got is in the message.
110
+
111
+ One formatter builds the failure text of `wait_for`, `join!` (which adds "to finish") and `pop!`:
112
+
113
+ ```
114
+ timed out after 5 s waiting for task 20261023-0a1b to end
115
+ timed out after 10.0 s (5 s × SPEC_TIME_SCALE 2) waiting for task 20261023-0a1b to end; last saw: "running"
116
+ ```
117
+
118
+ **`SPEC_TIME_SCALE`** (default 1, at least 1; CI's `rspec`, `rspec-macos` and `bundles` jobs set 2) multiplies
119
+ every helper deadline, explicit `timeout:` values included: a positive wait only gets more headroom, and a passing
120
+ run is no slower. Set it locally to reproduce a CI timing (`SPEC_TIME_SCALE=2 bundle exec rspec FILE`). It does not
121
+ scale the lib's own timeouts a spec passes in (`wait: 0.05`, `grace: 0.1`): those are the code's deadlines, and a
122
+ flaky one gets a `clock:` seam instead. A helper's own spec pins the scale (stub `Waiting.time_scale`). Keep
123
+ explicit helper timeouts at 30 or below: 60 s on CI, under the per-example limit.
124
+
71
125
  Every example also runs under a 120 s limit (`spec_helper`'s `around`; `integration`, `macos_build`, `warmup` and
72
126
  `recap` examples are exempt), so a hang fails its example instead of the run. `SPEC_EXAMPLE_TIMEOUT=N` changes the
73
- limit, `0` turns it off (a debugger session).
127
+ limit, `0` turns it off (a debugger session). The limit is not scaled.
128
+
129
+ **The cops.** `Samagotchi/SpecSleep` (an untagged `sleep` in a spec) and `Samagotchi/SilentWait` (a `wait_until`
130
+ whose value is dropped, an example's last line included, `expect(wait_until { … }).to be(true)`, or an untagged
131
+ `Timeout.timeout`) live in `rubocop/` and run in `rake lint`, so CI fails on them. RSpec counts `wait_for`,
132
+ `eventually`, `join!` and `pop!` as an example's expectation (`RSpec/NoExpectationExample`), so a wait can be an
133
+ example's last line. What the cops can't see, `script/affected-specs` hints at (`timing:`, below).
134
+
135
+ ### Flakes
136
+
137
+ When `parallel_rspec` fails in CI's `rspec` or `rspec-macos` job, the next step (`script/rerun_failures.rb`) reruns
138
+ only the failed examples, once, with plain `rspec`. It reads their ids from `tmp/parallel_failures.log`
139
+ (`.rspec_parallel`'s `FailuresLogger`) and writes the job summary: **Flaky: passed on rerun** or **Failed twice**,
140
+ with the example ids, the Ruby version and the job. It skips the rerun when no ids were logged (a load error) or more
141
+ than 20 failed (a real breakage). The job fails either way: a flake is a bug to fix, not a pass.
142
+
143
+ Every flake is classified into one of the four patterns, each with its own fix:
144
+
145
+ 1. **A settle sleep**: the spec paused for "long enough". Wait for the condition (`wait_for`, `eventually`).
146
+ 2. **A live loop racing the spec's own drive of it** (a worker loop, a scheduler tick the spec also calls). Count
147
+ passes, or stop the loop before driving it by hand.
148
+ 3. **Teardown before background work stops**: a thread, task or process still running when the example's `after`
149
+ removes its folder. Stop it and wait for it (`join!`, `with_task_dir`, a joining `stop`).
150
+ 4. **A lib deadline on the real clock** (`wait: 0.05`, `grace: 0.1`) too short on a loaded runner. Give the class a
151
+ `clock:` / `sleeper:` seam and move the clock in the spec.
152
+
153
+ A second flake of the same pattern at a new site means that pattern needs a seam in lib there (for example an `on_*:`
154
+ callback the spec can wait on), not another local fix in the spec.
74
155
 
75
156
  ## Bundle specs: the test kit
76
157
 
@@ -140,7 +221,11 @@ words the REPL prints, pick it by a rule), then the other name matches (a spec n
140
221
  `toolkit.rb`; then one beside the file, so `plugin/context.rb` picks `spec/samagotchi/plugin/*context*` before
141
222
  `spec/guardrails/context_spec.rb`), then the specs that name a changed file's class. The default range is the merge-base with main (or origin/main) plus uncommitted and untracked files.
142
223
  The Ruby specs go to stdout; the node tests to run (`npm test` targets), "web changed: also run npm run e2e" (with `npm run lint` too when a frontend module changed) and a
143
- hint for a hub class (named in too many specs to list) go to stderr. Nothing printed means nothing matched.
224
+ hint for a hub class (named in too many specs to list) go to stderr, and so do the `timing:` hints, from the lines a
225
+ changed spec adds (`git diff -U0` against the base; an untracked spec, all its lines): a `Thread.new`, `.start`,
226
+ `spawn` or `fork` with no `join!`, `wait_for`, `with_task_dir` or `Process.wait` added in the same file, and a
227
+ sub-second `be < 0.x` bound ([Waiting and time](#waiting-and-time), rules 3 and 5). Nothing printed means nothing
228
+ matched.
144
229
 
145
230
  It finds a change's own specs, not the regressions it causes elsewhere. So before committing, run the full suite
146
231
  (`bundle exec parallel_rspec -n 8 spec lib/samagotchi/bundles/*/spec`), unless your task says the coordinator runs it; the coordinator runs it at merge
@@ -236,13 +236,14 @@ module Samagotchi
236
236
  case type
237
237
  when :command_queued
238
238
  # A card's action shows as its card, never as a line (the UIs skip it live).
239
- @queued_commands << event.slice(:command_id, :client_id, :line) if event[:waits] && !event[:card]
239
+ @queued_commands << event.slice(:command_id, :client_id, :line, :author) if event[:waits] && !event[:card]
240
240
  when :command_ran
241
241
  @queued_commands.reject! { |entry| entry[:command_id] == event[:command_id] } if event[:queued]
242
242
  when :turn_enqueued
243
243
  queued = { enqueued_id: event[:enqueued_id], client_id: event[:client_id], prompt: event[:prompt] }
244
244
  queued[:images] = event[:images] if event[:images]
245
245
  queued[:delivery] = event[:delivery] if event[:delivery]
246
+ queued[:author] = event[:author] if event[:author]
246
247
  @queued << queued
247
248
  # Taken back before it ran (Bridge#handle_dequeue_turn).
248
249
  when :turn_dequeued
@@ -322,11 +322,12 @@ module Samagotchi
322
322
  # @param after_file [String, nil] the input file it came in (its
323
323
  # basename): it runs once that file is taken (Worker#on_command)
324
324
  # @return [String] its command_id
325
- def queue_command(line, client_id: nil, card: false, after_file: nil, enqueued_id: nil)
325
+ def queue_command(line, client_id: nil, card: false, after_file: nil, enqueued_id: nil, author: nil)
326
326
  # A card's action (the step-limit question's answer): the UIs leave
327
327
  # its line out. +enqueued_id+: a file-delivered command's input, whose
328
- # sender's wait its command_ran settles.
329
- command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card).with(enqueued_id: enqueued_id)
328
+ # sender's wait its command_ran settles. +author+: its input's.
329
+ command = QueuedCommand.mint(line.to_s.strip, client_id: client_id, card: card, author: author)
330
+ .with(enqueued_id: enqueued_id)
330
331
  @engine.synchronize_events { queue_command_locked(command, after_file: after_file) }
331
332
  command.command_id
332
333
  end
@@ -792,10 +793,11 @@ module Samagotchi
792
793
 
793
794
  # Queues +line+ (a session command) for the worker and announces its
794
795
  # :command_queued, unless +deadline+ has passed. Returns [headers,
795
- # status, body]: 202 with its command_id, or 408.
796
- def queue_command_request(session_id, line, client_id:, deadline: nil, card: false)
796
+ # status, body]: 202 with its command_id, or 408. +author+: a POST
797
+ # /turn's (a /word sent as a message).
798
+ def queue_command_request(session_id, line, client_id:, deadline: nil, card: false, author: nil)
797
799
  # A card's action: the UIs leave its line out (the card is the echo).
798
- command = QueuedCommand.mint(line, client_id: client_id, card: card)
800
+ command = QueuedCommand.mint(line, client_id: client_id, card: card, author: author)
799
801
  queued = @engine.synchronize_events do
800
802
  raise IntakeClosed if stopped?
801
803
  next false if expired?("command_expired", deadline, sid: session_id, client_id: command.client_id)
@@ -892,6 +894,10 @@ module Samagotchi
892
894
  client_id = fetched(parsed, "client_id")
893
895
  # --no-interrupt: the turn runs with the raised iteration limit.
894
896
  no_interrupt = fetched(parsed, "no_interrupt") == true
897
+ # Who wrote it when it isn't the owner (chi web stamps it from the
898
+ # request's principal; the Bridge is loopback and trusts its caller).
899
+ author = fetched(parsed, "author")
900
+ author = nil unless author.is_a?(String) && !author.empty?
895
901
  if sid.to_s.strip.empty? || prompt.to_s.strip.empty?
896
902
  return [{ "Allow" => "POST" }, 400,
897
903
  { error: "missing_fields", detail: "session_id and prompt are required" }]
@@ -916,7 +922,7 @@ module Samagotchi
916
922
  # TUI; an unknown /word stays a prompt. Not with images: those are
917
923
  # for the model.
918
924
  if images.empty? && @on_command && command_registry.command?(prompt.to_s)
919
- return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline)
925
+ return queue_command_request(sid, prompt.to_s.strip, client_id: client_id, deadline: deadline, author: author)
920
926
  end
921
927
 
922
928
  enqueued_id = SecureRandom.uuid
@@ -935,12 +941,13 @@ module Samagotchi
935
941
 
936
942
  queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
937
943
  enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
938
- no_interrupt: no_interrupt, images: images, delivery: delivery).tap do |ok|
944
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author).tap do |ok|
939
945
  next unless ok
940
946
 
941
947
  enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
942
948
  enqueued_event[:images] = images unless images.empty?
943
949
  enqueued_event[:delivery] = Delivery::QUEUE if queued_behind_turn
950
+ enqueued_event[:author] = author if author
944
951
  @engine.announce(enqueued_event)
945
952
  @on_input&.call
946
953
  end
@@ -1082,9 +1089,9 @@ module Samagotchi
1082
1089
 
1083
1090
  # Write a turn into this session's input dir, reusing the file IPC the
1084
1091
  # worker polls. Never calls run_turn across the boundary.
1085
- def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil)
1092
+ def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil, author: nil)
1086
1093
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1087
- no_interrupt: no_interrupt, images: images, delivery: delivery)
1094
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author)
1088
1095
  end
1089
1096
 
1090
1097
  # ── HTTP plumbing ────────────────────────────────────────────────────────
@@ -31,8 +31,9 @@ module Samagotchi
31
31
  # WebBridgeClient), where the client stays the same object across
32
32
  # workers: +epoch_watch+ (a frame whose id has another epoch than the
33
33
  # cursor's is a new worker: worker_changed first, the client's key
34
- # updated), +announce_retries+ (stream_retrying once a reconnect fails,
35
- # stream_resumed at the next frame), +idle_timeout+ (no byte, not even
34
+ # updated), +announce_retries+ (stream_retrying once a reconnect fails
35
+ # and rediscovery neither moved nor ended the stream, stream_resumed
36
+ # once a stream answers again), +idle_timeout+ (no byte, not even
36
37
  # the Bridge's 15 s ping, for that long: a half-open connection,
37
38
  # reconnected with the cursor).
38
39
  class EventStream
@@ -125,7 +126,8 @@ module Samagotchi
125
126
  next
126
127
  end
127
128
 
128
- announce_retry
129
+ # Rediscovery first: a stopped session or a new worker is no
130
+ # retry to announce (a stop prints its line alone).
129
131
  case rediscover_worker
130
132
  when :moved
131
133
  failures = 0
@@ -133,6 +135,7 @@ module Samagotchi
133
135
  next
134
136
  when :gone then return finish(@reason || "unreachable")
135
137
  end
138
+ announce_retry
136
139
 
137
140
  failures += 1
138
141
  return finish(@reason || "unreachable") if failures > @reconnect_delays.size
@@ -153,6 +156,9 @@ module Samagotchi
153
156
  return :unknown_session if status == 404
154
157
  return :unreachable unless status == 200
155
158
 
159
+ # Back as soon as the stream answers: a replay may bring nothing
160
+ # (the session was quiet), and the next frame could be minutes away.
161
+ announce_resumed
156
162
  yielded = false
157
163
  parser = SSEParser.new
158
164
  loop do
@@ -163,7 +169,6 @@ module Samagotchi
163
169
  event = parse(frame[:data])
164
170
  next unless event
165
171
 
166
- announce_resumed
167
172
  watch_epoch(frame[:id]) if @epoch_watch
168
173
  @last_event_id = frame[:id] if frame[:id]
169
174
  yielded = true
@@ -165,9 +165,12 @@ module Samagotchi
165
165
  # @param no_interrupt [Boolean] run the turn with the raised iteration limit
166
166
  # @param images [Array<Hash>] refs ({file:, name:}) to images already in
167
167
  # the session's images/
168
- def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil)
168
+ # @param author [String, nil] who wrote it when it isn't the session's
169
+ # owner; sent only when given (SessionManager.deliver_turn)
170
+ def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil)
169
171
  body = { session_id: @session_id, prompt: prompt, client_id: client_id }
170
172
  body[:no_interrupt] = true if no_interrupt
173
+ body[:author] = author if author
171
174
  body[:images] = images if images && !images.empty?
172
175
  body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
173
176
  body[:deadline] = deadline
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.103.0
3
+ version: 0.105.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:
@@ -2,12 +2,10 @@
2
2
 
3
3
  module Samagotchi
4
4
  # How the follow-up commands chi prints (ParentReport's chi answer and
5
- # chi send --wait, a stop's chi --resume) are spelled so that they work
6
- # when pasted: on this machine plain chi, against a remote (chi --remote
7
- # NAME, CHI_REMOTE=NAME alike) chi --remote NAME. What runs on this
8
- # machine only (--attach, --resume: remote attach is its own phase) has
9
- # no remote form: #attach and #resume are nil there, and #open_it and
10
- # #shows name the remote's chi web instead.
5
+ # chi send --wait, a stop's chi --resume, the attached TUI's re-attach
6
+ # lines) are spelled so that they work when pasted: on this machine plain
7
+ # chi, against a remote (chi --remote NAME, CHI_REMOTE=NAME alike) chi
8
+ # --remote NAME. A remote's chi web is named where the page is meant.
11
9
  # @!attribute remote [String, nil] the remote's name; nil: this machine
12
10
  ChiInvocation = Data.define(:remote) do
13
11
  def remote? = !remote.nil?
@@ -18,19 +16,18 @@ module Samagotchi
18
16
  # +words+ after #chi: command("answer ID …").
19
17
  def command(words) = "#{chi} #{words}"
20
18
 
21
- # chi --attach ID; nil remotely.
22
- def attach(id) = ("chi --attach #{id}" unless remote?)
19
+ # chi --attach ID (chi --remote NAME --attach ID).
20
+ def attach(id) = command("--attach #{id}")
23
21
 
24
- # chi --resume ID; nil remotely.
25
- def resume(id) = ("chi --resume #{id}" unless remote?)
22
+ # chi --resume ID (chi --remote NAME --resume ID).
23
+ def resume(id) = command("--resume #{id}")
26
24
 
27
- # What shows the session ("… shows it"): chi --attach ID, or the
28
- # remote's chi web.
29
- def shows(id) = attach(id) || web
25
+ # What shows the session ("… shows it"): its attach.
26
+ def shows(id) = attach(id)
30
27
 
31
- # Where the user opens it ("open it: …"): chi --attach ID or the web,
32
- # or the remote's chi web.
33
- def open_it(id) = remote? ? web : "#{attach(id)} or the web"
28
+ # Where the user opens it ("open it: …"): its attach, or the web (the
29
+ # remote's chi web).
30
+ def open_it(id) = "#{attach(id)} or #{remote? ? web : "the web"}"
34
31
 
35
32
  # The remote's chi web, by name.
36
33
  def web = "chi web on #{remote}"
@@ -306,7 +306,7 @@ module Samagotchi
306
306
  Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn], lifecycle: :web_start),
307
307
  # The stage view's working set (files, memories and URLs the turn brought into context): a rail in the stage, a
308
308
  # footprint under each finished turn and a "set N" chip in place of the mem chip; ?working_set=1|0 overrides it per page load.
309
- Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: false, expose: %i[env config cli], lifecycle: :web_start),
309
+ Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: true, expose: %i[env config cli], lifecycle: :web_start),
310
310
  # Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
311
311
  Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli], lifecycle: :web_start),
312
312
  # What a file ref (a tool row's file, a diff line) opens on this machine: a preset (vscode, vscode-insiders, cursor, zed, none) or a URL template with {path} and an optional {line} (Web::Editor).
@@ -116,13 +116,20 @@ module Samagotchi
116
116
  # last status line's bucket is where rises count from)
117
117
  # @param llm_context [LLMContextStrategy::Resolved, nil] the turn's
118
118
  # strategy (its budget)
119
- def initialize(conversation: [], llm_context: nil)
119
+ # @param counted_chars_per_token [Float, nil] the last turn's
120
+ # #counted_chars_per_token: the turn's estimate before its first count
121
+ # uses it instead of the configured ratio, so the value doesn't dip at
122
+ # a turn's start (the chat loop's chars leave out the tool schemas the
123
+ # server counts)
124
+ def initialize(conversation: [], llm_context: nil, counted_chars_per_token: nil)
120
125
  @budget = llm_context&.budget_tokens
121
126
  @forget = llm_context&.forget? || false
122
127
  @offered = self.class.last_line(conversation).to_s.include?(OFFER_TOOL)
123
128
  @enabled = self.class.enabled?
124
129
  chars_per_token = Config.get("context.chars_per_token").to_f
125
130
  @chars_per_token = chars_per_token.positive? ? chars_per_token : DEFAULT_CHARS_PER_TOKEN
131
+ @estimate_chars_per_token = counted_chars_per_token.to_f.positive? ? counted_chars_per_token.to_f : @chars_per_token
132
+ @counted_chars_per_token = nil
126
133
  parsed = Config.get("context.status_thresholds").to_s.split(",").map { |value| value.strip.to_i }
127
134
  .select { |value| value.between?(1, 99) }.uniq.sort
128
135
  @thresholds = parsed.empty? ? DEFAULT_THRESHOLDS : parsed
@@ -137,6 +144,10 @@ module Samagotchi
137
144
  # @return [Hash, nil] the status line's value ({est_pct:, bucket:});
138
145
  # nil with context.status off or before the first request
139
146
  attr_reader :display
147
+ # @return [Float, nil] the chars per token of the last request the
148
+ # server counted (its prompt chars over its prompt tokens, images
149
+ # aside), for the next turn's tracker; nil before a count
150
+ attr_reader :counted_chars_per_token
140
151
 
141
152
  def enabled? = @enabled
142
153
 
@@ -212,6 +223,8 @@ module Samagotchi
212
223
 
213
224
  @counted = { chars: prompt_chars, image_tokens: image_tokens }
214
225
  @edited = false
226
+ text_tokens = usage[:prompt_tokens].to_i - image_tokens.to_i
227
+ @counted_chars_per_token = prompt_chars.to_f / text_tokens if prompt_chars.positive? && text_tokens.positive?
215
228
  value = display_for(used_tokens: usage[:total_tokens], window_tokens: usage[:context_window_tokens] || window&.tokens)
216
229
  @display = value if value
217
230
  end
@@ -243,7 +256,7 @@ module Samagotchi
243
256
  source = "server"
244
257
  else
245
258
  window_tokens = counted_against(window.tokens)
246
- used_tokens = (prompt_chars / @chars_per_token).ceil + image_tokens
259
+ used_tokens = (prompt_chars / @estimate_chars_per_token).ceil + image_tokens
247
260
  source = "estimate"
248
261
  end
249
262
 
@@ -85,6 +85,7 @@ require_relative "guardrail_wiring"
85
85
  require_relative "plugin_tasks"
86
86
  require_relative "prompt_warmup"
87
87
  require_relative "working_set"
88
+ require_relative "session_participants"
88
89
 
89
90
  module Samagotchi
90
91
  # Engine owns the core agent logic: system prompt construction, tool
@@ -218,6 +219,11 @@ module Samagotchi
218
219
  Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
219
220
  @resume_session = session_id ? Session.load(session_id) : nil
220
221
  @prompt_builder = build_system_prompt(memories, appended_prompt_files)
222
+ # The session's participants (participants.json), read at each turn's
223
+ # start: their names for the messages they wrote.
224
+ @participants = SessionParticipants.new(state_dir: -> { session_state_dir })
225
+ # Whether the system prompt carries the participants line.
226
+ @participants_line = false
221
227
  @session = nil
222
228
  @session_observer = SessionObserver.new
223
229
  @metrics = SessionMetrics.new
@@ -414,7 +420,8 @@ module Samagotchi
414
420
  SystemPrompt.new(profile: -> { profile }, tools: -> { @tools }, session: -> { @session },
415
421
  thinking: -> { turn_thinking }, model: -> { model_identity },
416
422
  model_notes: -> { model_notes }, memories: memories,
417
- muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
423
+ muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files,
424
+ participants: -> { @participants_line })
418
425
  end
419
426
  private :build_system_prompt
420
427
 
@@ -1462,9 +1469,35 @@ module Samagotchi
1462
1469
  session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
1463
1470
  origin: source, exhausted: limit ? true : nil, limit: limit,
1464
1471
  enqueued_id: enqueued_id.to_s.empty? ? nil : enqueued_id.to_s,
1472
+ author: Steer.sender_author(origin.is_a?(Hash) ? origin[:author] : nil),
1465
1473
  **stop_facts(event))
1466
1474
  end
1467
1475
 
1476
+ # Reads the session's participants at a turn's start; the first to join
1477
+ # and the last to leave rebuild the system prompt (its participants
1478
+ # line), once each: a turn in between sends the same prefix.
1479
+ def refresh_participants!(session)
1480
+ active = @participants.refresh!(session).active?
1481
+ return if active == @participants_line
1482
+
1483
+ @participants_line = active
1484
+ @prompt_builder.reset!
1485
+ end
1486
+ private :refresh_participants!
1487
+
1488
+ # The author fields a message +author+ wrote carries (Steer.author_fields:
1489
+ # author:, and author_name: from participants.json), {} for the owner.
1490
+ # The worker's drain names a participant's merged lines with it, and
1491
+ # its running save a participant's prompt.
1492
+ # @param origin [Hash, String, nil] a turn's origin, or an author
1493
+ # @param session [Session, nil] the session it is for (the turn's)
1494
+ def author_fields(origin, session: @session)
1495
+ author = origin.is_a?(Hash) ? origin[:author] : origin
1496
+ return {} unless Steer.participant_author?(author)
1497
+
1498
+ Steer.author_fields(author, @participants.name_for(author, session: session))
1499
+ end
1500
+
1468
1501
  # Why a turn stopped, from its end event (LastTurn::STOP_FACTS), for a
1469
1502
  # parent agent's wait (ReplyWait): a provider error's kind and whether
1470
1503
  # a retry may help, a cancel's reason and the hook that stopped it.
@@ -1858,6 +1891,11 @@ module Samagotchi
1858
1891
  @recap&.write_now(on_start: on_start)
1859
1892
  end
1860
1893
 
1894
+ # The longest #write_recap_now waits (recap.timeout), nil when no recap
1895
+ # is set up.
1896
+ # @return [Float, nil]
1897
+ def recap_timeout = @recap&.timeout
1898
+
1861
1899
  # Ask for a recap now (/recap), without waiting for the idle window.
1862
1900
  # @return [Symbol] IdleRecap#request_now's answer, or :off
1863
1901
  def request_recap
@@ -1887,9 +1925,12 @@ module Samagotchi
1887
1925
  self
1888
1926
  end
1889
1927
 
1890
- # Stop the shared idle scheduler (TerminalUI calls this when the REPL exits).
1891
- def stop_idle
1892
- @idle_scheduler&.stop
1928
+ # Stop the shared idle scheduler (TerminalUI calls this when the REPL
1929
+ # exits). +join+: wait for a tick under way (IdleScheduler#stop); a
1930
+ # caller holding #synchronize_events passes false, since a recap's emit
1931
+ # in that tick waits for the same lock.
1932
+ def stop_idle(join: true)
1933
+ @idle_scheduler&.stop(join: join)
1893
1934
  self
1894
1935
  end
1895
1936
 
@@ -1947,9 +1988,11 @@ module Samagotchi
1947
1988
  # run it resumes started (ModelResult#run_start, kept in the offer by
1948
1989
  # TurnFlow), so it keeps that run's reasoning; nil starts cold (a
1949
1990
  # model change while the offer stood, every other turn).
1950
- # @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:});
1951
- # when given, the turn's boundary events (:turn_started, :turn_completed,
1952
- # :turn_canceled, :turn_failed) carry it as `origin:`
1991
+ # @param origin [Hash, nil] who queued the turn ({client_id:, enqueued_id:},
1992
+ # and author: when someone other than the session's owner wrote it, from
1993
+ # its input file; absent for the owner); when given, the turn's boundary
1994
+ # events (:turn_started, :turn_completed, :turn_canceled, :turn_failed)
1995
+ # carry it as `origin:`, and GuardrailWiring#begin_turn gets it
1953
1996
  # @param images [Array<Hash>] the prompt's images: {path:} (a file on this
1954
1997
  # machine; in-process and attached-TUI callers only) or {file:, name:}
1955
1998
  # (already in the session's images/, e.g. a web upload). They are
@@ -2093,6 +2136,7 @@ module Samagotchi
2093
2136
  # prompt built next declares, unchanged until the next turn's start.
2094
2137
  apply_staged_tools!
2095
2138
  apply_toolkit!(session, turn.on_event)
2139
+ refresh_participants!(session)
2096
2140
  # A Stop during the wait above: the turn ends here,
2097
2141
  # before the hooks run or a reminder is used up.
2098
2142
  return false if turn.controller.cancelled?
@@ -2221,7 +2265,7 @@ module Samagotchi
2221
2265
  end
2222
2266
 
2223
2267
  unless turn.continue
2224
- user_message = { role: "user", content: turn.prompt, turn_id: turn.id }
2268
+ user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin) }
2225
2269
  user_message[:images] = image_refs unless image_refs.empty?
2226
2270
  turn.messages << user_message
2227
2271
  session.last_prompt = turn.prompt
@@ -83,12 +83,18 @@ module Samagotchi
83
83
  @protected = nil
84
84
  end
85
85
 
86
- # A turn starts: its origin, and the git facts read afresh.
86
+ # A turn starts: its origin (Engine#run_turn's: {client_id:,
87
+ # enqueued_id:, author:}, each optional), and the git facts read afresh.
87
88
  def begin_turn(origin)
88
89
  @origin = origin
89
90
  @git = Guardrails::GitInfo.new
90
91
  end
91
92
 
93
+ # Who wrote the running turn when it isn't the session's owner (its
94
+ # origin's author); nil for the owner's turns and every other origin.
95
+ # @return [String, nil]
96
+ def turn_author = @origin.is_a?(Hash) ? @origin[:author] : nil
97
+
92
98
  # The gate every tool call asks first.
93
99
  # @return [Guardrails::Gate]
94
100
  def gate
@@ -254,7 +254,7 @@ module Samagotchi
254
254
  end
255
255
 
256
256
  # @return [Array(Integer, Integer)] the recap length range, e.g. [2, 4]
257
- attr_reader :generation, :inactivity, :min_user_turns, :sentences
257
+ attr_reader :generation, :inactivity, :min_user_turns, :sentences, :timeout
258
258
 
259
259
  # @param callable [#call] true while a continue offer waits for an
260
260
  # answer (the Worker's or the REPL's TurnFlow); read at each attempt
@@ -18,23 +18,37 @@ module Samagotchi
18
18
  # raises is logged and isolated — the remaining jobs keep polling.
19
19
  class IdleScheduler
20
20
  POLL_INTERVAL_SECONDS = 0.5
21
+ # How long #stop waits for the thread to finish its tick before it
22
+ # kills it.
23
+ STOP_JOIN_SECONDS = 1.0
24
+ THREAD_NAME = "idle_scheduler"
21
25
 
22
- attr_reader :engine, :jobs
26
+ attr_reader :engine, :jobs, :interval
23
27
 
24
- def initialize(engine:, jobs: [])
28
+ # +interval+: the pause between ticks (a spec passes a long one, so its
29
+ # own #tick is the only one after the first).
30
+ def initialize(engine:, jobs: [], interval: POLL_INTERVAL_SECONDS)
25
31
  raise ArgumentError, "IdleScheduler requires an engine" unless engine
26
32
 
27
33
  @engine = engine
28
34
  @jobs = jobs.compact.freeze
35
+ @interval = interval
36
+ # The loop's pause: a pop with the interval as its timeout, which
37
+ # #stop ends at once by pushing.
38
+ @wake = Queue.new
29
39
  @thread = nil
40
+ # The thread a stop(join: false) left to finish its tick.
41
+ @unjoined = nil
30
42
  @stopped = false
31
43
  end
32
44
 
33
45
  # Spawn the polling thread (idempotent; no-op when there are no jobs).
46
+ # A stopped scheduler doesn't start again.
34
47
  def start
35
48
  return self if running? || @jobs.empty?
36
49
 
37
50
  @thread = Thread.new { run_loop }
51
+ @thread.name = THREAD_NAME
38
52
  self
39
53
  end
40
54
 
@@ -42,11 +56,24 @@ module Samagotchi
42
56
  !@thread.nil? && @thread.alive? && !@stopped
43
57
  end
44
58
 
45
- # Stop the polling thread (idempotent).
46
- def stop
59
+ # Stop the polling thread (idempotent): it ticks no more and its pause
60
+ # ends at once. +join+: wait up to STOP_JOIN_SECONDS for a tick under
61
+ # way to finish, then kill the thread. A caller holding a lock a tick
62
+ # may take (Worker#leave_idle holds the event log, which a recap's emit
63
+ # takes) passes join: false: the thread finishes its tick and exits by
64
+ # itself, and a later joining stop (Engine#shutdown, the worker's
65
+ # teardown) still joins it. From the scheduler's own thread (a job's
66
+ # tick) it never joins.
67
+ def stop(join: true)
47
68
  @stopped = true
48
- @thread&.kill
69
+ @wake << :stop
70
+ thread = @thread || @unjoined
49
71
  @thread = nil
72
+ @unjoined = join ? nil : thread
73
+ return if thread.nil? || !join || thread == Thread.current
74
+
75
+ thread.kill unless thread.join(STOP_JOIN_SECONDS)
76
+ nil
50
77
  end
51
78
 
52
79
  # Swap the job +old+ for +new+ (Engine#session= rebuilding the recap):
@@ -78,11 +105,9 @@ module Samagotchi
78
105
  private
79
106
 
80
107
  def run_loop
81
- loop do
82
- break if @stopped
83
-
108
+ until @stopped
84
109
  tick
85
- sleep(POLL_INTERVAL_SECONDS)
110
+ @wake.pop(timeout: @interval)
86
111
  end
87
112
  rescue StandardError => e
88
113
  Log.exception(:idle, "scheduler_crashed", e, echo: "[IdleScheduler] scheduler thread crashed: #{e.class}: #{e.message}")